From 67bc212ffd20ab578c9e92e119350c92b58451ca Mon Sep 17 00:00:00 2001 From: Aminark <27661225+Aminark@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:48:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20EPUB=20=E9=98=85?= =?UTF-8?q?=E8=AF=BB=E5=B9=B6=E6=94=B9=E5=96=84=E8=A7=84=E5=88=99=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E4=B8=8E=E9=98=85=E8=AF=BB=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。 修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。 更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。 --- App/Compatibility.swift | 21 +- App/DesignSystem.swift | 17 +- .../Activity/FileManagementView.swift | 10 +- App/Features/Books/BookLibraryModel.swift | 2 +- App/Features/Books/LocalBookDetailView.swift | 8 +- App/Features/Books/SourceSwitchView.swift | 2 +- App/Features/RSS/RSSLibraryModel.swift | 100 +++-- App/Features/RSS/RSSViews.swift | 39 +- .../Reader/EPUBIllustrationsView.swift | 74 ++++ App/Features/Reader/ReaderScrollView.swift | 31 +- App/Features/Reader/ReaderView.swift | 13 +- .../Rules/Editor/SyntaxCodeEditor.swift | 67 ++- .../Rules/ReplaceRuleEditorView.swift | 5 +- App/Features/Search/SearchViewModel.swift | 3 +- App/Features/Settings/AboutView.swift | 1 + App/Info.plist | 18 + App/LegadoApp.swift | 10 + App/Resources/ZIPFoundation-LICENSE.txt | 21 + App/RootTabView.swift | 6 +- CHANGELOG.md | 11 + Legado.xcodeproj/project.pbxproj | 34 +- .../xcshareddata/swiftpm/Package.resolved | 11 +- .../xcschemes/LegadoRSSFlowUITests.xcscheme | 8 + Package.resolved | 11 +- Package.swift | 12 +- README.md | 9 +- .../LegadoCore/Book/ContentSanitizer.swift | 14 +- Sources/LegadoCore/LocalBook/EPUBBook.swift | 399 ++++++++++++++++++ .../LocalBook/LocalBookService.swift | 53 ++- .../LocalBook/LocalFileService.swift | 23 +- .../Networking/LegadoURLBuilder.swift | 31 +- Sources/LegadoCore/RSS/RSSRuleParser.swift | 7 + .../RuleEngine/JSONPathEvaluator.swift | 47 ++- Sources/LegadoCore/RuleEngine/JSONValue.swift | 3 +- .../RuleEngine/JavaScriptRuleEvaluator.swift | 136 ++++-- .../RuleEngine/RegexEvaluator.swift | 68 ++- .../LegadoCore/RuleEngine/RuleEngine.swift | 67 ++- .../RuleEngine/RuleExpression.swift | 8 +- THIRD_PARTY_NOTICES.md | 6 + .../EPUBReadingRegressionTests.swift | 81 ++++ .../SearchRegressionTests.swift | 20 + .../BookSourceRemoteProbe.swift | 89 ++++ Tests/LegadoCoreTests/EPUBBookTests.swift | 193 +++++++++ .../LegadoCoreTests/LegadoDatabaseTests.swift | 14 + .../ReplaceRuleExecutionTests.swift | 141 +++++++ .../SampledRequestRegressionTests.swift | 89 ++++ .../SampledSourceRegressionTests.swift | 104 +++++ .../ReaderScrollGeometryTests.swift | 48 +++ .../WebReaderBridgeTests.swift | 156 +++++++ .../LegadoReaderUITests/DarkModeUITests.swift | 17 +- .../LiveReadingFlowUITests.swift | 5 +- .../ReaderScrollGestureTests.swift | 7 +- .../RuleEditorUITests.swift | 113 ++++- docs/DEVELOPMENT.md | 18 +- scripts/fixtures/SourceImportFixture.swift | 4 + scripts/fixtures/make-epub.py | 23 + scripts/fixtures/rss-flow-server.py | 44 ++ scripts/prepare-source-import-fixture.sh | 3 +- 58 files changed, 2331 insertions(+), 244 deletions(-) create mode 100644 App/Features/Reader/EPUBIllustrationsView.swift create mode 100644 App/Resources/ZIPFoundation-LICENSE.txt create mode 100644 Legado.xcodeproj/xcshareddata/xcschemes/LegadoRSSFlowUITests.xcscheme create mode 100644 Sources/LegadoCore/LocalBook/EPUBBook.swift create mode 100644 Tests/LegadoAppModelsTests/EPUBReadingRegressionTests.swift create mode 100644 Tests/LegadoCoreTests/BookSourceRemoteProbe.swift create mode 100644 Tests/LegadoCoreTests/EPUBBookTests.swift create mode 100644 Tests/LegadoCoreTests/ReplaceRuleExecutionTests.swift create mode 100644 Tests/LegadoCoreTests/SampledRequestRegressionTests.swift create mode 100644 Tests/LegadoCoreTests/SampledSourceRegressionTests.swift create mode 100644 scripts/fixtures/make-epub.py create mode 100644 scripts/fixtures/rss-flow-server.py diff --git a/App/Compatibility.swift b/App/Compatibility.swift index 40a038f..81e0a90 100644 --- a/App/Compatibility.swift +++ b/App/Compatibility.swift @@ -207,7 +207,7 @@ struct ReadingPhotoPickerController: UIViewControllerRepresentable { } } -private struct ReadingTXTFileImporterModifier: ViewModifier { +private struct ReadingLocalBookFileImporterModifier: ViewModifier { @Binding var isPresented: Bool let onFiles: ([LocalBookImportFile]) -> Void let onError: (Error) -> Void @@ -219,7 +219,7 @@ private struct ReadingTXTFileImporterModifier: ViewModifier { pickerHasDismissed = true deliverFilesWhenReady() }) { - ReadingTXTFilePicker { files in + ReadingLocalBookFilePicker { files in pickedFiles = files isPresented = false deliverFilesWhenReady() @@ -239,7 +239,7 @@ private struct ReadingTXTFileImporterModifier: ViewModifier { guard pickerHasDismissed, !pickedFiles.isEmpty else { return } let files = pickedFiles pickedFiles = [] - guard files.allSatisfy({ $0.url.pathExtension.lowercased() == "txt" && !$0.url.hasDirectoryPath }) else { + guard files.allSatisfy({ LocalBookService.supports($0.url) && !$0.url.hasDirectoryPath }) else { onError(LocalBookError.unsupportedFormat) return } @@ -247,15 +247,16 @@ private struct ReadingTXTFileImporterModifier: ViewModifier { } } -private struct ReadingTXTFilePicker: UIViewControllerRepresentable { +private struct ReadingLocalBookFilePicker: UIViewControllerRepresentable { let onPick: @MainActor ([LocalBookImportFile]) -> Void let onCancel: @MainActor () -> Void func makeCoordinator() -> Coordinator { Coordinator(parent: self) } func makeUIViewController(context: Context) -> UIDocumentPickerViewController { - // Plain text includes UTF-8/UTF-16 TXT, while .text and .data also allow JSON. - let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.plainText], asCopy: true) + // Keep source JSON out of the local-book picker. + let epub = UTType(filenameExtension: "epub") ?? UTType(importedAs: "org.idpf.epub-container") + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.plainText, epub], asCopy: true) picker.allowsMultipleSelection = true picker.shouldShowFileExtensions = true picker.delegate = context.coordinator @@ -266,9 +267,9 @@ private struct ReadingTXTFilePicker: UIViewControllerRepresentable { @MainActor final class Coordinator: NSObject, UIDocumentPickerDelegate { - let parent: ReadingTXTFilePicker + let parent: ReadingLocalBookFilePicker - init(parent: ReadingTXTFilePicker) { self.parent = parent } + init(parent: ReadingLocalBookFilePicker) { self.parent = parent } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { parent.onCancel() } @@ -281,12 +282,12 @@ private struct ReadingTXTFilePicker: UIViewControllerRepresentable { enum ReadingSheetDetent { case medium, large } extension View { - func readingTXTFileImporter( + func readingLocalBookFileImporter( isPresented: Binding, onFiles: @escaping ([LocalBookImportFile]) -> Void, onError: @escaping (Error) -> Void ) -> some View { - modifier(ReadingTXTFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError)) + modifier(ReadingLocalBookFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError)) } func readingPhotoPicker( diff --git a/App/DesignSystem.swift b/App/DesignSystem.swift index 445924c..b066a5f 100644 --- a/App/DesignSystem.swift +++ b/App/DesignSystem.swift @@ -300,11 +300,20 @@ struct BookCoverArtwork: View { } var body: some View { - AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in - if case .success(let image) = phase { - image.resizable().scaledToFill() + Group { + if let remoteURL, remoteURL.hasPrefix("data:image/"), + let comma = remoteURL.firstIndex(of: ","), + let data = Data(base64Encoded: String(remoteURL[remoteURL.index(after: comma)...])), + let image = UIImage(data: data) { + Image(uiImage: image).resizable().scaledToFill() } else { - placeholder + AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in + if case .success(let image) = phase { + image.resizable().scaledToFill() + } else { + placeholder + } + } } } .frame(width: width, height: height) diff --git a/App/Features/Activity/FileManagementView.swift b/App/Features/Activity/FileManagementView.swift index d08c805..b68494c 100644 --- a/App/Features/Activity/FileManagementView.swift +++ b/App/Features/Activity/FileManagementView.swift @@ -126,7 +126,7 @@ struct FileManagementView: View { Button("完成") { selecting = false; selection.removeAll() } } else { Menu { - Button("导入 TXT 文件", systemImage: "square.and.arrow.down") { importsFile = true } + Button("导入 TXT / EPUB", systemImage: "square.and.arrow.down") { importsFile = true } if tab == 0 { Button("选择文件", systemImage: "checkmark.circle") { selecting = true } .disabled(displayedFiles.isEmpty) @@ -163,7 +163,7 @@ struct FileManagementView: View { private var importAndRenameView: some View { fileList - .readingTXTFileImporter(isPresented: $importsFile) { files in + .readingLocalBookFileImporter(isPresented: $importsFile) { files in Task { await model.importFiles(files, library: library) } } onError: { error in model.errorMessage = error.localizedDescription @@ -177,7 +177,7 @@ struct FileManagementView: View { Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } } } } message: { _ in - Text("保留 TXT 格式,书架中的书名、书签和阅读进度不变。") + Text("保留原文件格式,书架中的书名、书签和阅读进度不变。") } } @@ -239,8 +239,8 @@ struct FileManagementView: View { } Section("文件") { if displayedFiles.isEmpty { - ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT,文件会复制到应用中。")) - Button("导入 TXT 文件", systemImage: "plus") { importsFile = true } + ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT 或 EPUB,文件会复制到应用中。")) + Button("导入 TXT / EPUB", systemImage: "plus") { importsFile = true } } ForEach(displayedFiles) { file in Group { diff --git a/App/Features/Books/BookLibraryModel.swift b/App/Features/Books/BookLibraryModel.swift index 106c8f1..8e6c4a5 100644 --- a/App/Features/Books/BookLibraryModel.swift +++ b/App/Features/Books/BookLibraryModel.swift @@ -360,7 +360,7 @@ final class BookLibraryModel: ObservableObject { func addLocalFile(at url: URL) async throws { guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return } - let report = try await Task.detached { [localService] in try localService.openTextFile(at: url) }.value + let report = try await Task.detached { [localService] in try localService.openFile(at: url) }.value guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return } guard await persist(books + [report.book]) else { throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库") diff --git a/App/Features/Books/LocalBookDetailView.swift b/App/Features/Books/LocalBookDetailView.swift index d555ab5..387268a 100644 --- a/App/Features/Books/LocalBookDetailView.swift +++ b/App/Features/Books/LocalBookDetailView.swift @@ -33,16 +33,20 @@ struct LocalBookDetailView: View { ScrollView { VStack(spacing: 16) { VStack(spacing: 14) { - BookCoverArtwork(title: book.name, author: book.author, width: 108, height: 154) + BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.customCoverUrl ?? book.coverUrl, width: 108, height: 154) Text(book.name).font(.title2.bold()) + if !book.author.isEmpty { Text(book.author).font(.subheadline).foregroundStyle(.secondary) } HStack { - StatusPill(text: "本地 TXT", systemImage: "doc.text.fill") + StatusPill(text: "本地 " + (URL(string: book.bookUrl)?.pathExtension.uppercased() ?? "TXT"), systemImage: "doc.text.fill") if let charset = book.charset { StatusPill(text: charset, color: ReadingTheme.coral, systemImage: "character") } } Text(book.originName).font(.caption).foregroundStyle(.secondary) } .padding(.top, 12) + if let intro = book.intro, !intro.isEmpty { + ReadingCard { Text(intro).font(.subheadline).frame(maxWidth: .infinity, alignment: .leading) } + } ReadingCard { VStack(alignment: .leading, spacing: 0) { HStack { diff --git a/App/Features/Books/SourceSwitchView.swift b/App/Features/Books/SourceSwitchView.swift index b54770b..eb56c97 100644 --- a/App/Features/Books/SourceSwitchView.swift +++ b/App/Features/Books/SourceSwitchView.swift @@ -151,6 +151,6 @@ struct SourceSwitchView: View { .padding(.vertical, 5) } .disabled(model.switchingID != nil) - .accessibilityIdentifier("sourceSwitch.candidate.\(candidate.book.origin)") + .accessibilityIdentifier("sourceSwitch.candidate.\(candidate.id)") } } diff --git a/App/Features/RSS/RSSLibraryModel.swift b/App/Features/RSS/RSSLibraryModel.swift index 60e569a..62fa9dd 100644 --- a/App/Features/RSS/RSSLibraryModel.swift +++ b/App/Features/RSS/RSSLibraryModel.swift @@ -10,29 +10,42 @@ final class RSSLibraryModel: ObservableObject { @Published private(set) var isWorking = false @Published var errorMessage: String? - private let sourceRepository: SQLiteRssSourceRepository - private let articleRepository: SQLiteRssArticleRepository - private let starRepository: SQLiteRssStarRepository - private let recordRepository: SQLiteRssReadRecordRepository + private var sourceRepository: SQLiteRssSourceRepository { get throws { .init(database: try database) } } + private var articleRepository: SQLiteRssArticleRepository { get throws { .init(database: try database) } } + private var starRepository: SQLiteRssStarRepository { get throws { .init(database: try database) } } + private var recordRepository: SQLiteRssReadRecordRepository { get throws { .init(database: try database) } } private let service: RSSService private let importer = RSSSourceImporter() - private let database: LegadoDatabase + private let databaseResult: Result + private var database: LegadoDatabase { get throws { try databaseResult.get() } } private var nextURL: String? private var page = 1 private var order: Int64 = 0 + private struct Feed: Equatable { + let origin: String + let sort: String + let key: String? + } + private var activeFeed: Feed? + private var articleRequestID = UUID() + private var loadingMoreRequestID: UUID? + private var activeOperations = 0 init(database: LegadoDatabase, service: RSSService = .init()) { - self.database = database - sourceRepository = .init(database: database) - articleRepository = .init(database: database) - starRepository = .init(database: database) - recordRepository = .init(database: database) + databaseResult = .success(database) self.service = service } - static func live() -> RSSLibraryModel { - let database = try! LegadoDatabase(url: LegadoDatabase.defaultURL()) - return .init(database: database) + private init(databaseError: Error) { + let error = LegadoDatabaseError.open("RSS 数据库无法打开:\(databaseError.localizedDescription)。请检查存储空间后重新启动应用。") + databaseResult = .failure(error) + service = .init() + errorMessage = error.localizedDescription + } + + static func live(databaseURL: URL = LegadoDatabase.defaultURL()) -> RSSLibraryModel { + do { return .init(database: try LegadoDatabase(url: databaseURL)) } + catch { return .init(databaseError: error) } } func load() async { @@ -62,15 +75,16 @@ final class RSSLibraryModel: ObservableObject { func importText(_ text: String) async { await perform { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + var merged: [RssSource] if let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased() ?? "") { - self.sources = try await self.importRemote(trimmed, existing: self.sources) + merged = try await self.importRemote(trimmed, existing: self.sources) } else { let payload = try self.importer.decode(Data(trimmed.utf8)) - var merged = try self.importer.merge(payload.sources, into: self.sources) + merged = try self.importer.merge(payload.sources, into: self.sources) for remote in payload.subscriptionURLs { merged = try await self.importRemote(remote, existing: merged) } - self.sources = merged } - try await self.sourceRepository.save(self.sources) + try await self.sourceRepository.save(merged) + self.sources = try await self.sourceRepository.all() } } @@ -130,31 +144,53 @@ final class RSSLibraryModel: ObservableObject { } func refresh(_ source: RssSource, sortName: String? = nil, sortURL: String? = nil, key: String? = nil) async { - page = 1; order = Int64(Date().timeIntervalSince1970 * 1_000) let name = sortName ?? source.sourceName let url = sortURL ?? source.sortUrl?.nilIfBlank ?? source.sourceUrl - await perform { + let feed = Feed(origin: source.sourceUrl, sort: name, key: key) + let requestID = UUID() + articleRequestID = requestID + loadingMoreRequestID = nil + if activeFeed != feed { articles = [] } + activeFeed = feed + nextURL = nil + page = 1 + await perform(isCurrent: { self.articleRequestID == requestID }) { let report = try await self.service.articles(sortName: name, sortURL: url, source: source, page: 1, key: key) + guard self.articleRequestID == requestID else { return } + var nextOrder = Int64(Date().timeIntervalSince1970 * 1_000) var values = report.result.articles - for index in values.indices { values[index].order = self.order; self.order -= 1 } + for index in values.indices { values[index].order = nextOrder; nextOrder -= 1 } try await self.articleRepository.put(values) - if source.ruleNextPage?.isEmpty == false { try await self.articleRepository.clearOld(origin: source.sourceUrl, sort: name, before: self.order) } + if source.ruleNextPage?.isEmpty == false { try await self.articleRepository.clearOld(origin: source.sourceUrl, sort: name, before: nextOrder) } + let articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name) + guard self.articleRequestID == requestID else { return } + self.order = nextOrder self.nextURL = report.result.nextURL - self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name) + self.articles = articles } } func loadMore(_ source: RssSource, sortName: String? = nil, key: String? = nil) async { - guard let nextURL, !nextURL.isEmpty else { return } - page += 1 let name = sortName ?? source.sourceName - await perform { - let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: self.page, key: key) + guard activeFeed == Feed(origin: source.sourceUrl, sort: name, key: key), + loadingMoreRequestID == nil, let nextURL, !nextURL.isEmpty else { return } + let requestID = articleRequestID + loadingMoreRequestID = requestID + defer { if loadingMoreRequestID == requestID { loadingMoreRequestID = nil } } + let nextPage = page + 1 + await perform(isCurrent: { self.articleRequestID == requestID }) { + let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: nextPage, key: key) + guard self.articleRequestID == requestID else { return } + var nextOrder = self.order var values = report.result.articles - for index in values.indices { values[index].order = self.order; self.order -= 1 } + for index in values.indices { values[index].order = nextOrder; nextOrder -= 1 } try await self.articleRepository.append(values) + let articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name) + guard self.articleRequestID == requestID else { return } + self.page = nextPage + self.order = nextOrder self.nextURL = report.result.nextURL - self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name) + self.articles = articles } } @@ -231,9 +267,11 @@ final class RSSLibraryModel: ObservableObject { let payload = try importer.decode(data) return try importer.merge(payload.sources, into: existing) } - private func perform(_ operation: () async throws -> Void) async { - isWorking = true; defer { isWorking = false } - do { try await operation() } catch { errorMessage = error.localizedDescription } + private func perform(isCurrent: () -> Bool = { true }, _ operation: () async throws -> Void) async { + activeOperations += 1 + isWorking = true + defer { activeOperations -= 1; isWorking = activeOperations > 0 } + do { try await operation() } catch { if isCurrent() { errorMessage = error.localizedDescription } } } } diff --git a/App/Features/RSS/RSSViews.swift b/App/Features/RSS/RSSViews.swift index dd5fa75..f0da2f6 100644 --- a/App/Features/RSS/RSSViews.swift +++ b/App/Features/RSS/RSSViews.swift @@ -151,14 +151,20 @@ private struct RSSArticleListView: View { .overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } } .navigationTitle(source.sourceName) .searchable(text: $searchKey, prompt: "源内搜索") + .onSubmit(of: .search) { Task { await refresh() } } .refreshable { await refresh() } .toolbar { ToolbarItem(placement: .primaryAction) { Button("刷新", systemImage: "arrow.clockwise") { Task { await refresh() } } } ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") } } ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试", systemImage: "stethoscope") } } - ToolbarItem(placement: .bottomBar) { Button("加载下一页", systemImage: "arrow.down") { Task { await library.loadMore(source, sortName: activeName, key: searchKey.nilIfEmpty) } } } + ToolbarItem(placement: .bottomBar) { + Button("加载下一页", systemImage: "arrow.down") { + Task { await library.loadMore(source, sortName: activeName, key: searchKey.nilIfEmpty) } + }.disabled(library.isWorking) + } } .task { sorts = await RSSSortResolver().resolve(source); await refresh() } + .readingTabBarHidden() } private var activeSort: RSSSort { sorts.indices.contains(selectedSort) ? sorts[selectedSort] : .init(name: "", url: source.sourceUrl) } @@ -464,6 +470,32 @@ private struct RSSBinaryDocument: FileDocument { func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) } } +private struct RSSPhotoToolbar: ViewModifier { + let canSave: Bool + let save: () -> Void + let close: () -> Void + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.toolbar { + ToolbarItem(placement: .primaryAction) { + Button("保存到照片", systemImage: "square.and.arrow.down", action: save).disabled(!canSave) + }.sharedBackgroundVisibility(.hidden) + ToolbarItem(placement: .confirmationAction) { + Button("完成", action: close).buttonStyle(.plain) + }.sharedBackgroundVisibility(.hidden) + } + } else { + content.toolbar { + ToolbarItem(placement: .primaryAction) { + Button("保存到照片", systemImage: "square.and.arrow.down", action: save).disabled(!canSave) + } + ToolbarItem(placement: .confirmationAction) { Button("完成", action: close) } + } + } + } +} + struct RSSPhotoView: View { @Environment(\.dismiss) private var dismiss let value: String @@ -487,10 +519,7 @@ struct RSSPhotoView: View { } .navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline) .readingNavigationBarBackground() - .toolbar { - ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) } - ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } - } + .modifier(RSSPhotoToolbar(canSave: image != nil, save: save, close: { dismiss() })) .task(id: value) { await load() } .alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") } } diff --git a/App/Features/Reader/EPUBIllustrationsView.swift b/App/Features/Reader/EPUBIllustrationsView.swift new file mode 100644 index 0000000..e15e0da --- /dev/null +++ b/App/Features/Reader/EPUBIllustrationsView.swift @@ -0,0 +1,74 @@ +import SwiftUI +import ImageIO + +struct EPUBIllustrationsView: View { + @Environment(\.dismiss) private var dismiss + let book: Book + let chapter: BookChapter + @State private var images: [EPUBIllustration] = [] + @State private var isLoading = true + @State private var errorMessage: String? + + var body: some View { + ReadingNavigationStack { + ScrollView { + LazyVStack(spacing: 24) { + if isLoading { + ProgressView("正在读取插图…").padding() + } else if let errorMessage { + ReadingUnavailableView("无法读取插图", systemImage: "photo", description: Text(errorMessage)) + } else if images.isEmpty { + ReadingUnavailableView("本章没有插图", systemImage: "photo", description: Text(chapter.title)) + } else { + ForEach(images) { image in + VStack(spacing: 8) { + EPUBIllustrationImage(data: image.data) + Text("插图 \(image.id)\(image.caption.isEmpty ? "" : " · " + image.caption)") + .font(.caption).foregroundStyle(.secondary) + } + } + } + }.padding() + } + .background(ReadingTheme.canvas) + .navigationTitle("本章插图") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } } + .task(id: chapter.id) { + isLoading = true + errorMessage = nil + do { + images = try await Task.detached { try LocalBookService().loadIllustrations(book: book, chapter: chapter) }.value + } catch { errorMessage = error.localizedDescription } + isLoading = false + } + } + } +} + +private struct EPUBIllustrationImage: View { + let data: Data + @State private var image: UIImage? + + var body: some View { + Group { + if let image { + Image(uiImage: image).resizable().scaledToFit().accessibilityLabel("书籍插图") + } else { + Text("图片无法显示").foregroundStyle(.secondary) + } + } + .task { + // Decode a bounded thumbnail off the main thread, even for huge source dimensions. + image = await Task.detached { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 2_048, + kCGImageSourceCreateThumbnailWithTransform: true + ] as CFDictionary) else { return Optional.none } + return UIImage(cgImage: thumbnail) + }.value + } + } +} diff --git a/App/Features/Reader/ReaderScrollView.swift b/App/Features/Reader/ReaderScrollView.swift index 2eda514..172a52c 100644 --- a/App/Features/Reader/ReaderScrollView.swift +++ b/App/Features/Reader/ReaderScrollView.swift @@ -129,7 +129,8 @@ struct ReaderScrollView: UIViewRepresentable { let layout = view.collectionViewLayout as! ReaderContinuousLayout let geometry = ReaderContinuousLayout.geometry( blocks: newBlocks, width: view.bounds.width, - top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom), viewportHeight: view.bounds.height + top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom), + viewportHeight: view.bounds.height, scale: view.traitCollection.displayScale ) let targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) } let correction = targetY.map { $0 - view.contentOffset.y } ?? 0 @@ -277,10 +278,14 @@ struct ReaderScrollView: UIViewRepresentable { private func captureAnchor() -> Anchor? { guard let view, !blocks.isEmpty else { return nil } let layout = view.collectionViewLayout as! ReaderContinuousLayout - let y = max(view.contentOffset.y, layout.frames.first?.minY ?? 0) - guard let row = layout.row(at: y) else { return nil } + // The reading position starts below the configured top padding. + // Preserve that reference point when earlier chapters are inserted. + let y = max(view.contentOffset.y + CGFloat(max(parent.configuration.paddingTop, 0)), + layout.frames.first?.minY ?? 0) + let scale = max(view.traitCollection.displayScale, 1) + guard let row = layout.row(at: (y * scale).rounded() / scale) else { return nil } let block = blocks[row], chapter = block.chapter - let localY = y - layout.frames[row].minY + block.top + let localY = max(y - layout.frames[row].minY, 0) + block.top let glyph = chapter.manager.glyphIndex(for: CGPoint(x: 0, y: localY + 0.5), in: chapter.container) let safeGlyph = min(glyph, max(chapter.manager.numberOfGlyphs - 1, 0)) let character = chapter.manager.characterIndexForGlyph(at: safeGlyph) @@ -385,17 +390,21 @@ final class ReaderContinuousLayout: UICollectionViewLayout { private var size = CGSize.zero override var collectionViewContentSize: CGSize { size } - static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat) -> (frames: [CGRect], size: CGSize) { - var y = top + static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat, scale: CGFloat) -> (frames: [CGRect], size: CGSize) { + let scale = max(scale, 1) + // Keep insertion offsets in whole physical pixels. UIKit rounds scroll + // offsets, so fractional chapter heights otherwise accumulate drift. + var pixels = (top * scale).rounded() let frames = blocks.map { block -> CGRect in - let frame = CGRect(x: 0, y: y, width: width, height: block.height) - y += block.height + let height = ceil(block.height * scale) + let frame = CGRect(x: 0, y: pixels / scale, width: width, height: height / scale) + pixels += height return frame } let finalChapter = blocks.last?.chapter.chapter.id let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter }) .map { frames[$0].minY } ?? top - return (frames, CGSize(width: width, height: max(y, finalStart + viewportHeight) + bottom)) + return (frames, CGSize(width: width, height: max(pixels / scale, finalStart + viewportHeight) + bottom)) } func install(frames: [CGRect], size: CGSize, correction: CGFloat) { @@ -412,9 +421,9 @@ final class ReaderContinuousLayout: UICollectionViewLayout { var low = 0, high = frames.count while low < high { let middle = (low + high) / 2 - if frames[middle].maxY <= y { low = middle + 1 } else { high = middle } + if frames[middle].minY <= y { low = middle + 1 } else { high = middle } } - return min(low, frames.count - 1) + return max(low - 1, 0) } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { diff --git a/App/Features/Reader/ReaderView.swift b/App/Features/Reader/ReaderView.swift index c55ad9f..2f1f8c8 100644 --- a/App/Features/Reader/ReaderView.swift +++ b/App/Features/Reader/ReaderView.swift @@ -18,6 +18,7 @@ struct ReaderView: View { @StateObject private var speech = ReaderSpeechController() @State private var showsTableOfContents = false @State private var showsBookmarks = false + @State private var illustrationChapter: BookChapter? @State private var showsAutoReadControls = false @State private var showsStyleControls = false @State private var isAutoReading = false @@ -257,6 +258,9 @@ struct ReaderView: View { } .sheet(isPresented: $showsTableOfContents) { tableOfContents } .sheet(isPresented: $showsBookmarks) { bookmarksSheet } + .sheet(item: $illustrationChapter) { chapter in + EPUBIllustrationsView(book: book, chapter: chapter) + } .sheet(isPresented: $showsAutoReadControls) { autoReadControls } .sheet(isPresented: $showsStyleControls) { styleControls } .sheet(isPresented: $showsContentSearch) { contentSearchSheet } @@ -698,6 +702,7 @@ struct ReaderView: View { || showsReaderMenu || showsTableOfContents || showsBookmarks + || illustrationChapter != nil || showsAutoReadControls || showsStyleControls || showsContentSearch @@ -834,6 +839,12 @@ struct ReaderView: View { showsReaderMenu = false showsContentSearch = true } + if book.origin == "local", URL(string: book.bookUrl)?.pathExtension.lowercased() == "epub" { + Button("本章插图", systemImage: "photo.on.rectangle") { + showsReaderMenu = false + illustrationChapter = model.currentChapter + } + } Button("书签列表", systemImage: "bookmark") { showsReaderMenu = false showsBookmarks = true @@ -1575,7 +1586,7 @@ struct ReaderView: View { Text("正文") } footer: { if !model.canEditContent { - Text("本地 TXT 直接来自原文件;为避免破坏章节偏移,本章编辑与网络刷新仅对网络书籍开放。") + Text("本地 TXT / EPUB 直接读取原文件。本章编辑与网络刷新仅对网络书籍开放。") } } diff --git a/App/Features/Rules/Editor/SyntaxCodeEditor.swift b/App/Features/Rules/Editor/SyntaxCodeEditor.swift index fbd8195..32c8c11 100644 --- a/App/Features/Rules/Editor/SyntaxCodeEditor.swift +++ b/App/Features/Rules/Editor/SyntaxCodeEditor.swift @@ -59,7 +59,12 @@ struct SyntaxCodeEditor: UIViewRepresentable { UIBarButtonItem(title: symbol == "\n" ? "↵" : symbol == "\t" ? "⇥" : symbol, primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.insertText(symbol) }) } + [UIBarButtonItem(systemItem: .flexibleSpace), UIBarButtonItem(title: "完成", primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.resignFirstResponder() })] - view.inputAccessoryView = bar + bar.items?.last?.accessibilityIdentifier = "syntaxCodeEditor.done" + if #available(iOS 26.0, *) { + view.inputAccessoryView = CodeInputAccessory(items: bar.items ?? []) + } else { + view.inputAccessoryView = bar + } view.text = text context.coordinator.highlight(view) return view @@ -184,6 +189,66 @@ struct SyntaxCodeEditor: UIViewRepresentable { } } +// Keep Done visible while allowing the editing shortcuts to scroll on narrow keyboards. +private final class CodeInputAccessory: UIInputView { + init(items: [UIBarButtonItem]) { + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 44), inputViewStyle: .keyboard) + autoresizingMask = [.flexibleWidth] + backgroundColor = .secondarySystemGroupedBackground + let scroll = UIScrollView() + scroll.showsHorizontalScrollIndicator = false + scroll.delaysContentTouches = false + scroll.translatesAutoresizingMaskIntoConstraints = false + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + let actions = items.filter { $0.primaryAction != nil } + var done: UIButton? + for (index, item) in actions.enumerated() { + let button = UIButton(type: .system) + if let action = item.primaryAction { button.addAction(action, for: .touchUpInside) } + button.setImage(item.image, for: .normal) + button.setTitle(item.title, for: .normal) + button.tintColor = UIColor(ReadingTheme.accent) + button.titleLabel?.font = .systemFont(ofSize: 16) + button.accessibilityIdentifier = item.accessibilityIdentifier + button.accessibilityLabel = item.accessibilityLabel + button.translatesAutoresizingMaskIntoConstraints = false + if index == actions.count - 1 { + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) + done = button + addSubview(button) + } else { + if index < 3 { button.accessibilityLabel = ["查找与替换", "撤销", "重做"][index] } + stack.addArrangedSubview(button) + button.widthAnchor.constraint(equalToConstant: 36).isActive = true + } + } + addSubview(scroll) + scroll.addSubview(stack) + guard let done else { return } + NSLayoutConstraint.activate([ + done.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + done.widthAnchor.constraint(equalToConstant: 60), + done.topAnchor.constraint(equalTo: topAnchor), + done.bottomAnchor.constraint(equalTo: bottomAnchor), + scroll.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), + scroll.trailingAnchor.constraint(equalTo: done.leadingAnchor, constant: -4), + scroll.topAnchor.constraint(equalTo: topAnchor), + scroll.bottomAnchor.constraint(equalTo: bottomAnchor), + stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor), + stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), + stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor) + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: 44) } +} + struct RuleCodeFieldView: View { let title: String let path: String diff --git a/App/Features/Rules/ReplaceRuleEditorView.swift b/App/Features/Rules/ReplaceRuleEditorView.swift index 3cc7355..42ff473 100644 --- a/App/Features/Rules/ReplaceRuleEditorView.swift +++ b/App/Features/Rules/ReplaceRuleEditorView.swift @@ -92,9 +92,10 @@ struct ReplaceRuleEditorView: View { RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex) } label: { fieldPreview("匹配内容", value: rule.pattern) } NavigationLink { - RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement, language: .regex) + RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement, + language: rule.isRegex && rule.replacement.hasPrefix("@js:") ? .javascript : .regex) } label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) } - Text("正则替换使用 $1、$2 引用捕获组;普通文本替换按字面匹配。") + Text("正则替换使用 $1、$2 引用捕获组;以 @js: 开头可运行 JavaScript,result 为当前匹配文本,可读取 book 和 chapter。普通文本替换按字面匹配。") .font(.footnote).foregroundStyle(.secondary) } Section("作用范围") { diff --git a/App/Features/Search/SearchViewModel.swift b/App/Features/Search/SearchViewModel.swift index aebe40e..348dfb3 100644 --- a/App/Features/Search/SearchViewModel.swift +++ b/App/Features/Search/SearchViewModel.swift @@ -64,7 +64,8 @@ final class SearchViewModel: ObservableObject { pendingSourceIDs = Set(pendingSources.map(\.id)) pagedSources = pendingSources.filter { let url = $0.searchUrl ?? "" - return url.contains("{{page}}") || url.contains("searchPage") || url.contains("<") + return url.range(of: #"\{\{[^}]*\bpage\b[^}]*\}\}"#, options: .regularExpression) != nil + || url.contains("searchPage") || url.contains("<") } sourceNames = Dictionary(pendingSources.map { ($0.id, $0.bookSourceName) }, uniquingKeysWith: { first, _ in first }) accumulator = .init() diff --git a/App/Features/Settings/AboutView.swift b/App/Features/Settings/AboutView.swift index acb4d8c..1698156 100644 --- a/App/Features/Settings/AboutView.swift +++ b/App/Features/Settings/AboutView.swift @@ -80,6 +80,7 @@ private struct OpenSourceLicensesView: View { license("小浣熊阅读 / Legado", resource: "Legado-LICENSE", detail: "GNU GPL v3") license("SwiftSoup", resource: "SwiftSoup-LICENSE", detail: "MIT License") license("Kanna", resource: "Kanna-LICENSE", detail: "MIT License") + license("ZIPFoundation", resource: "ZIPFoundation-LICENSE", detail: "MIT License") }.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline) } diff --git a/App/Info.plist b/App/Info.plist index bb272a2..50afac9 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -22,6 +22,24 @@ public.data + + CFBundleTypeNameEPUB 电子书 + CFBundleTypeRoleViewer + LSHandlerRankAlternate + LSItemContentTypesorg.idpf.epub-container + + + UTImportedTypeDeclarations + + + UTTypeIdentifierorg.idpf.epub-container + UTTypeDescriptionEPUB 电子书 + UTTypeConformsTopublic.data + UTTypeTagSpecification + public.filename-extensionepub + public.mime-typeapplication/epub+zip + + CFBundleExecutable $(EXECUTABLE_NAME) diff --git a/App/LegadoApp.swift b/App/LegadoApp.swift index 97dcf64..6fdfe29 100644 --- a/App/LegadoApp.swift +++ b/App/LegadoApp.swift @@ -52,6 +52,7 @@ struct LegadoApp: App { await bookLibrary.load() await replaceRuleLibrary.load() await readingActivity.load() + await rssLibrary.load() await ruleSubscriptions.load() if UserDefaults.standard.bool(forKey: "auto_refresh") { await bookLibrary.checkForUpdates(sources: sourceLibrary.sources, onlyRead: UserDefaults.standard.bool(forKey: "onlyUpdateRead")) @@ -60,6 +61,15 @@ struct LegadoApp: App { } .onOpenURL { url in Task { + if url.isFileURL && url.pathExtension.lowercased() == "epub" { + let file = LocalBookImportFile(url: url) + await bookLibrary.load() + await bookLibrary.importPickedLocalFiles([file]) + sourceLibrary.notice = .init(kind: bookLibrary.errorMessage == nil ? .success : .error, + title: bookLibrary.errorMessage == nil ? "EPUB 已加入书架" : "导入失败", + detail: bookLibrary.errorMessage ?? url.lastPathComponent) + return + } await sourceLibrary.load() if url.scheme?.lowercased() == "legado", url.path.lowercased() == "/replacerule" { await replaceRuleLibrary.load() diff --git a/App/Resources/ZIPFoundation-LICENSE.txt b/App/Resources/ZIPFoundation-LICENSE.txt new file mode 100644 index 0000000..07362ff --- /dev/null +++ b/App/Resources/ZIPFoundation-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/App/RootTabView.swift b/App/RootTabView.swift index fab388e..d2a36ae 100644 --- a/App/RootTabView.swift +++ b/App/RootTabView.swift @@ -227,7 +227,7 @@ private struct BookshelfView: View { } .background(ReadingTheme.canvas) .navigationBarHidden(true) - .readingTXTFileImporter(isPresented: $showsLocalImporter) { files in + .readingLocalBookFileImporter(isPresented: $showsLocalImporter) { files in pendingLocalFiles = files } onError: { error in library.reportError(error) @@ -449,7 +449,7 @@ private struct BookshelfBookDestination: View { let source: BookSource? var body: some View { - if ProcessInfo.processInfo.arguments.contains("-uiDemo") { + if ProcessInfo.processInfo.arguments.contains("-uiDemo"), book.origin != "local" { ReaderView(demoBook: book, chapters: DemoContent.readerChapters, initialIndex: 0, content: DemoContent.readerText) } else { BookshelfReadView(book: book, source: source) @@ -912,6 +912,8 @@ struct SearchView: View { } label: { Label("搜索选项", systemImage: "slider.horizontal.3") } + .accessibilityLabel("搜索选项") + .accessibilityIdentifier("search.options") } } .sheet(isPresented: $showsScope) { diff --git a/CHANGELOG.md b/CHANGELOG.md index af3a704..ff7ddc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 更新记录 +## 0.1.12(2026-10-24) + +- 增加无 DRM EPUB 2/3 导入和阅读,识别元数据、封面、NCX/nav 目录及同页章节锚点,并按书脊顺序保留未列入目录的正文。 +- EPUB 沿用阅读进度、书签、朗读和阅读样式;正文中的插图可从「更多 → 本章插图」查看。 +- 本地文件管理支持 EPUB 重命名、原格式导出、删除及从系统文件应用打开。 + +- 改善 JSON 规则模板、候选选择、组合替换和请求参数兼容,修复 JavaScript 数字类型与动态净化替换。 +- 修复 RSS 翻页失败后的重试、切换订阅时的过期结果和重复翻页请求。 +- 改善连续滚动中的章节插入定位,调整 iOS 26 代码编辑工具栏。 +- 应用版本为 `0.1.12`,构建号为 `18`。 + ## 0.1.11(2026-10-22) - 修复书架和文件管理中的本地 TXT 选择、导入确认与文件访问权限,支持大小写扩展名和多选,并排除 JSON 书源。 diff --git a/Legado.xcodeproj/project.pbxproj b/Legado.xcodeproj/project.pbxproj index 4cd840f..3a89bb1 100644 --- a/Legado.xcodeproj/project.pbxproj +++ b/Legado.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ B10000000000000000000029 /* ReaderScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000029 /* ReaderScrollView.swift */; }; F10000000000000000000001 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000001 /* SwiftSoup */; }; F10000000000000000000002 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000002 /* Kanna */; }; + F10000000000000000000003 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000003 /* ZIPFoundation */; }; A10000000000000000000001 /* LegadoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* LegadoApp.swift */; }; A10000000000000000000002 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* RootTabView.swift */; }; A10000000000000000000003 /* SourceLibraryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* SourceLibraryModel.swift */; }; @@ -123,6 +124,9 @@ AA1100000000000000000008 = {isa = PBXBuildFile; fileRef = AA2200000000000000000008; }; AA1100000000000000000009 = {isa = PBXBuildFile; fileRef = AA2200000000000000000009; }; AA1100000000000000000010 = {isa = PBXBuildFile; fileRef = AA2200000000000000000010; }; + EB1000000000000000000001 /* EPUBBook.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2000000000000000000001; }; + EB1000000000000000000002 /* ZIPFoundation-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = EB2000000000000000000002; }; + EB1000000000000000000003 /* EPUBIllustrationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2000000000000000000003; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -245,6 +249,9 @@ AA2200000000000000000008 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/LegadoCore/Import/QRCodeDecoder.swift; sourceTree = SOURCE_ROOT; }; AA2200000000000000000009 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/RuleEditorUITests.swift; sourceTree = SOURCE_ROOT; }; AA2200000000000000000010 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift; sourceTree = SOURCE_ROOT; }; + EB2000000000000000000001 /* EPUBBook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/LegadoCore/LocalBook/EPUBBook.swift; sourceTree = SOURCE_ROOT; }; + EB2000000000000000000002 /* ZIPFoundation-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = App/Resources/ZIPFoundation-LICENSE.txt; sourceTree = SOURCE_ROOT; }; + EB2000000000000000000003 /* EPUBIllustrationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/Features/Reader/EPUBIllustrationsView.swift; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -256,6 +263,7 @@ files = ( F10000000000000000000001 /* SwiftSoup in Frameworks */, F10000000000000000000002 /* Kanna in Frameworks */, + F10000000000000000000003 /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -353,6 +361,7 @@ A20000000000000000000029 /* ReaderSpeechController.swift */, A20000000000000000000028 /* ReaderAppearance.swift */, A2000000000000000000002B /* ReaderSupport.swift */, + EB2000000000000000000003 /* EPUBIllustrationsView.swift */, ); path = Features/Reader; sourceTree = ""; @@ -527,6 +536,7 @@ children = ( F21000000000000000000002 /* LocalFileService.swift */, C20000000000000000000018 /* LocalBookService.swift */, + EB2000000000000000000001 /* EPUBBook.swift */, ); path = LocalBook; sourceTree = ""; @@ -589,6 +599,7 @@ E20000000000000000000008 /* Legado-LICENSE.txt */, E20000000000000000000009 /* SwiftSoup-LICENSE.txt */, E2000000000000000000000A /* Kanna-LICENSE.txt */, + EB2000000000000000000002 /* ZIPFoundation-LICENSE.txt */, ); path = Resources; sourceTree = ""; @@ -622,6 +633,7 @@ packageProductDependencies = ( F20000000000000000000001 /* SwiftSoup */, F20000000000000000000002 /* Kanna */, + F20000000000000000000003 /* ZIPFoundation */, ); productName = Legado; productReference = D20000000000000000000001 /* Legado.app */; @@ -659,6 +671,7 @@ packageReferences = ( F30000000000000000000001 /* XCRemoteSwiftPackageReference "SwiftSoup" */, F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */, + F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); productRefGroup = D40000000000000000000002 /* Products */; projectDirPath = ""; @@ -677,6 +690,7 @@ 910000000000000000000008 /* Legado-LICENSE.txt in Resources */, A10000000000000000000020 /* Assets.xcassets in Resources */, A1000000000000000000001D /* txtTocRule.json in Resources */, + EB1000000000000000000002 /* ZIPFoundation-LICENSE.txt in Resources */, A1000000000000000000001E /* httpTTS.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -770,6 +784,8 @@ C10000000000000000000016 /* BookExploreService.swift in Sources */, C10000000000000000000017 /* BookSourceDebugger.swift in Sources */, C10000000000000000000018 /* LocalBookService.swift in Sources */, + EB1000000000000000000003 /* EPUBIllustrationsView.swift in Sources */, + EB1000000000000000000001 /* EPUBBook.swift in Sources */, C10000000000000000000019 /* ContentSanitizer.swift in Sources */, C1000000000000000000001A /* ReplaceRuleRepository.swift in Sources */, C1000000000000000000001B /* ReadingActivityRepository.swift in Sources */, @@ -864,12 +880,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_ASSET_PATHS = ""; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.1.11; + MARKETING_VERSION = 0.1.12; OTHER_LDFLAGS = "$(inherited) -lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -885,11 +901,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.1.11; + MARKETING_VERSION = 0.1.12; OTHER_LDFLAGS = "$(inherited) -lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -930,6 +946,11 @@ repositoryURL = "https://github.com/tid-kijyun/Kanna.git"; requirement = { kind = exactVersion; version = 6.1.0; }; }; + F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZIPFoundation.git"; + requirement = { kind = exactVersion; version = 0.9.20; }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -943,6 +964,11 @@ package = F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */; productName = Kanna; }; + F20000000000000000000003 /* ZIPFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; + productName = ZIPFoundation; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D70000000000000000000001 /* Project object */; diff --git a/Legado.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Legado.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fad215a..214015b 100644 --- a/Legado.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Legado.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5cf2eb5dea5b10523b3afc86a164d9a3315a7269a1aefdfe8c91f0530c3f3522", + "originHash" : "a20ba014518540ad01fe4db62ea950304db8c0b8eb880b62b87a5f1d19569a3c", "pins" : [ { "identity" : "kanna", @@ -18,6 +18,15 @@ "revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface", "version" : "2.13.5" } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } } ], "version" : 3 diff --git a/Legado.xcodeproj/xcshareddata/xcschemes/LegadoRSSFlowUITests.xcscheme b/Legado.xcodeproj/xcshareddata/xcschemes/LegadoRSSFlowUITests.xcscheme new file mode 100644 index 0000000..ea4c70b --- /dev/null +++ b/Legado.xcodeproj/xcshareddata/xcschemes/LegadoRSSFlowUITests.xcscheme @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Package.resolved b/Package.resolved index 81fcec7..6809799 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7f0bb8624f715c1c036934addf347a13f5c877af397c008dd678bb519c74d937", + "originHash" : "9d25b5f7ff3ac25767fea9b5152f5607bdb185299d3ebda18cd9b9255a772731", "pins" : [ { "identity" : "kanna", @@ -18,6 +18,15 @@ "revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface", "version" : "2.13.5" } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index c80a10c..f0e9962 100644 --- a/Package.swift +++ b/Package.swift @@ -12,14 +12,16 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/scinfu/SwiftSoup.git", exact: "2.13.5"), - .package(url: "https://github.com/tid-kijyun/Kanna.git", exact: "6.1.0") + .package(url: "https://github.com/tid-kijyun/Kanna.git", exact: "6.1.0"), + .package(url: "https://github.com/weichsel/ZIPFoundation.git", exact: "0.9.20") ], targets: [ .target( name: "LegadoCore", dependencies: [ .product(name: "SwiftSoup", package: "SwiftSoup"), - .product(name: "Kanna", package: "Kanna") + .product(name: "Kanna", package: "Kanna"), + .product(name: "ZIPFoundation", package: "ZIPFoundation") ], linkerSettings: [ .linkedFramework("JavaScriptCore"), @@ -29,7 +31,7 @@ let package = Package( ), .testTarget( name: "LegadoCoreTests", - dependencies: ["LegadoCore"], + dependencies: ["LegadoCore", .product(name: "ZIPFoundation", package: "ZIPFoundation")], resources: [.copy("Fixtures")] ), .target( @@ -42,7 +44,7 @@ let package = Package( "Rules/ReplaceRuleManagementView.swift", "Rules/ReplaceRuleEditorView.swift", "Rules/RuleSubscriptionView.swift", "Rules/TxtTocRuleManagementView.swift", "Rules/Editor", "Books/BookDetailView.swift", "Books/LocalBookDetailView.swift", "Books/SourceSwitchView.swift", "Reader/ReaderAppearance.swift", "Reader/ReaderSpeechController.swift", "Reader/ReaderSupport.swift", - "Reader/ReaderTextView.swift", "Reader/ReaderScrollView.swift", "Reader/ReaderView.swift" + "Reader/EPUBIllustrationsView.swift", "Reader/ReaderTextView.swift", "Reader/ReaderScrollView.swift", "Reader/ReaderView.swift" ], sources: [ "Sources/SourceLibraryModel.swift", "Rules/ReplaceRuleLibraryModel.swift", "Search/SearchViewModel.swift", @@ -52,7 +54,7 @@ let package = Package( ), .testTarget( name: "LegadoAppModelsTests", - dependencies: ["LegadoAppModels", "LegadoCore"] + dependencies: ["LegadoAppModels", "LegadoCore", .product(name: "ZIPFoundation", package: "ZIPFoundation")] ) ] ) diff --git a/README.md b/README.md index 3efbe6d..83ea9f3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ 项目仍在边用边改,书源兼容和阅读体验会继续完善。 -**小浣熊阅读不提供书源或在线书库。** 书源需由用户自行从网络公开分享获取,或自行编写后导入;也可以直接阅读本地 TXT。 +**小浣熊阅读不提供书源或在线书库。** 书源需由用户自行从网络公开分享获取,或自行编写后导入;也可以直接阅读本地 TXT / EPUB。 ## 下载安装 @@ -42,7 +42,7 @@ | 快速搜书 | 多书源并发搜索,结果陆续展示;重复搜索可先显示有效缓存,同书多源合并 | | 书架管理 | 列表、网格、分组、章节更新、换源和本机阅读进度 | | 阅读排版 | 字号、间距、边距、日夜主题、滚动与分页阅读、目录和书签 | -| 本地 TXT | 导入本地文件、自定义目录规则和章节阅读 | +| 本地 TXT / EPUB | TXT 自定义目录规则;EPUB 2/3 目录、封面、分章阅读与插图查看 | | 语音朗读 | 系统朗读与 HTTP TTS 配置 | | 阅读工具 | 替换净化、阅读记录、时长统计和缓存管理 | | RSS 订阅 | 订阅源管理与内容阅读 | @@ -85,9 +85,12 @@ 1. 自行准备你有权使用的阅读书源,安装后进入「我的 → 书源管理」导入。 2. 在书架点击搜索,输入书名或作者,打开书籍详情后开始阅读。 -3. 将书籍加入书架,方便下次继续阅读;也可以从书架直接导入本地 TXT。 +3. 将书籍加入书架,方便下次继续阅读;也可以从书架直接导入本地 TXT 或 EPUB。 + 4. 在阅读页调整排版、主题、书签和朗读设置。 +EPUB 导入入口:书架 → 更多 → 本地书,或「我的 → 文件管理 → 导入 TXT / EPUB」。支持无 DRM 的 EPUB 2/3,识别书名、作者、封面、NCX/nav 目录和章节锚点。正文使用现有阅读器重新排版,支持阅读进度、书签、朗读和样式设置;插图从阅读菜单「更多 → 本章插图」查看。暂不还原原书 CSS、固定版式和交互内容。 + 应用支持 `legado://` 规则导入链接。来源网站改版或停止服务时,书源规则可能需要更新。 ## 书源说明 diff --git a/Sources/LegadoCore/Book/ContentSanitizer.swift b/Sources/LegadoCore/Book/ContentSanitizer.swift index fcef25b..dd0643e 100644 --- a/Sources/LegadoCore/Book/ContentSanitizer.swift +++ b/Sources/LegadoCore/Book/ContentSanitizer.swift @@ -115,9 +115,9 @@ public struct ContentSanitizer: Sendable { var diagnostics: [RuleDiagnostic] = [] for rule in ordered where rule.scopeTitle && applies(rule, to: book) { - let result = apply(rule, to: title) + let result = apply(rule, to: title, book: book, chapter: chapter) diagnostics += result.diagnostics - if result.value != title { + if result.value != title, !result.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { title = result.value effective.append(rule.id) } @@ -146,7 +146,7 @@ public struct ContentSanitizer: Sendable { } for rule in ordered where rule.scopeContent && applies(rule, to: book) { - let result = apply(rule, to: body) + let result = apply(rule, to: body, book: book, chapter: chapter) diagnostics += result.diagnostics if result.value != body { body = result.value @@ -172,10 +172,16 @@ public struct ContentSanitizer: Sendable { return (!book.name.isEmpty && scope.contains(book.name)) || (!book.origin.isEmpty && scope.contains(book.origin)) } - private func apply(_ rule: ReplaceRule, to input: String) -> (value: String, diagnostics: [RuleDiagnostic]) { + private func apply(_ rule: ReplaceRule, to input: String, book: Book, chapter: BookChapter) -> (value: String, diagnostics: [RuleDiagnostic]) { guard !rule.pattern.isEmpty else { return (input, []) } do { if rule.isRegex { + if rule.replacement.hasPrefix("@js:") { + let evaluate = JavaScriptRuleEvaluator().replacementEvaluator( + script: String(rule.replacement.dropFirst(4)), book: book, chapter: chapter) + return (try regex.replacingStrict(pattern: rule.pattern, in: input, + timeoutMilliseconds: rule.timeoutMillisecond, replacement: evaluate), []) + } return (try regex.replacingStrict(pattern: rule.pattern, in: input, with: rule.replacement, timeoutMilliseconds: rule.timeoutMillisecond), []) } return (input.replacingOccurrences(of: rule.pattern, with: rule.replacement), []) diff --git a/Sources/LegadoCore/LocalBook/EPUBBook.swift b/Sources/LegadoCore/LocalBook/EPUBBook.swift new file mode 100644 index 0000000..1e11cac --- /dev/null +++ b/Sources/LegadoCore/LocalBook/EPUBBook.swift @@ -0,0 +1,399 @@ +import Foundation +import ImageIO +import SwiftSoup +import ZIPFoundation + +public enum EPUBError: Error, LocalizedError, Sendable { + case invalidArchive, invalidPackage, missingResource(String), encrypted, resourceTooLarge, invalidChapter + + public var errorDescription: String? { + switch self { + case .invalidArchive: "EPUB 文件已损坏或不是有效的压缩包" + case .invalidPackage: "EPUB 缺少有效的书籍信息或阅读顺序" + case .missingResource(let path): "EPUB 中缺少文件:\(path)" + case .encrypted: "此 EPUB 的正文或图片已加密,暂不支持读取" + case .resourceTooLarge: "EPUB 单个章节或资源过大,无法安全读取" + case .invalidChapter: "EPUB 章节或目录锚点无效,请重新打开书籍" + } + } +} + +public struct EPUBIllustration: Identifiable, Sendable { + public let id: Int + public let caption: String + public let data: Data +} + +/// Reads resources on demand without unpacking untrusted archive paths to disk. +/// Navigation follows the spine; EPUB 2 NCX and EPUB 3 nav supply chapter labels/anchors. +struct EPUBBook { + private struct Item { + let id: String + let path: String + let mediaType: String + let properties: Set + } + private struct Reference { + let path: String + let fragment: String? + let title: String + } + private let archive: Archive + private let entries: [String: Entry] + private let package: EPUBXMLNode + private let packagePath: String + private let manifest: [Item] + private let spine: [Item] + private let encryptedPaths: Set + private static let resourceLimit = 32 * 1_024 * 1_024 + + init(url: URL) throws { + let archive: Archive + do { archive = try Archive(url: url, accessMode: .read) } + catch { throw EPUBError.invalidArchive } + var entries: [String: Entry] = [:] + for entry in archive { + guard entries.count < 50_000 else { throw EPUBError.resourceTooLarge } + // Directories and symlinks are never used as book resources. + guard entry.type == .file else { continue } + guard Self.safePath(entry.path), entries[entry.path] == nil else { throw EPUBError.invalidArchive } + entries[entry.path] = entry + } + self.archive = archive + self.entries = entries + let container = try EPUBXMLNode.parse(Self.read("META-INF/container.xml", archive: archive, entries: entries)) + guard let rootfile = container.descendants("rootfile").first, + let fullPath = rootfile.attributes["full-path"], + let path = Self.resolve(fullPath, relativeTo: "")?.path else { throw EPUBError.invalidPackage } + packagePath = path + package = try EPUBXMLNode.parse(Self.read(path, archive: archive, entries: entries)) + guard package.name == "package", let manifestNode = package.children.first(where: { $0.name == "manifest" }), + let spineNode = package.children.first(where: { $0.name == "spine" }) else { throw EPUBError.invalidPackage } + manifest = manifestNode.children.compactMap { node in + guard node.name == "item", let id = node.attributes["id"], let href = node.attributes["href"], + let ref = Self.resolve(href, relativeTo: path) else { return nil } + return Item(id: id, path: ref.path, mediaType: node.attributes["media-type"] ?? "", + properties: Set((node.attributes["properties"] ?? "").split(whereSeparator: \.isWhitespace).map(String.init))) + } + let items = manifest + let readable = spineNode.children.filter { $0.name == "itemref" && $0.attributes["linear"] != "no" } + spine = try readable.map { node in + guard let item = items.first(where: { $0.id == node.attributes["idref"] }), + ["application/xhtml+xml", "text/html"].contains(item.mediaType), entries[item.path] != nil else { + throw EPUBError.invalidPackage + } + return item + } + guard !spine.isEmpty else { throw EPUBError.invalidPackage } + if entries["META-INF/encryption.xml"] != nil { + let encryption = try EPUBXMLNode.parse(Self.read("META-INF/encryption.xml", archive: archive, entries: entries)) + encryptedPaths = Set(encryption.descendants("CipherReference").compactMap { + $0.attributes["URI"].flatMap { Self.resolve($0, relativeTo: "")?.path } + }) + } else { encryptedPaths = [] } + // Font obfuscation is common and does not prevent text reading. + guard !spine.contains(where: { encryptedPaths.contains($0.path) }) else { throw EPUBError.encrypted } + } + + func report(url: URL, originalName: String) throws -> LocalBookImportReport { + let metadata = package.children.first { $0.name == "metadata" } + let title = metadata?.descendants("title").first?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let authors = metadata?.descendants("creator").map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } ?? [] + var book = Book(bookUrl: url.absoluteString, name: title.isEmpty ? (originalName as NSString).deletingPathExtension : title, + author: authors.joined(separator: "、"), origin: "local") + book.originName = originalName + book.intro = MetadataTextSanitizer().clean(metadata?.descendants("description").first?.text) + book.canUpdate = false + var configuration = BookReadConfig() + configuration.useReplaceRule = false + book.readConfig = configuration + let coverID = metadata?.descendants("meta").first { $0.attributes["name"] == "cover" }?.attributes["content"] + if let cover = manifest.first(where: { $0.properties.contains("cover-image") }) ?? manifest.first(where: { $0.id == coverID }), + ["image/jpeg", "image/png", "image/gif", "image/webp"].contains(cover.mediaType), + let entry = entries[cover.path], entry.uncompressedSize <= 2 * 1_024 * 1_024, + let data = try? read(cover.path), let thumbnail = Self.coverThumbnail(data) { + // A bounded embedded thumbnail survives file renames, backups and cache eviction. + book.coverUrl = "data:image/png;base64,\(thumbnail.base64EncodedString())" + } + let chapters = try chapters(book: book) + book.totalChapterNum = chapters.count + book.latestChapterTitle = chapters.last?.title + return .init(book: book, chapters: chapters) + } + + func chapters(book: Book) throws -> [BookChapter] { + let references = navigation() + var chapters: [BookChapter] = [] + for item in spine where !item.properties.contains("nav") { + let document = try document(item.path) + guard let body = document.body() else { continue } + let elements = try body.getAllElements().array() + var positions: [String: Int] = [:] + for (index, element) in elements.enumerated() { + for key in [element.id(), (try? element.attr("name")) ?? ""] where !key.isEmpty && positions[key] == nil { + positions[key] = index + } + } + var seen = Set() + let refs = references.filter { $0.path == item.path } + .filter { ref in + guard ref.fragment == nil || positions[ref.fragment!] != nil else { return false } + return seen.insert(ref.fragment ?? "").inserted + } + .sorted { (positions[$0.fragment ?? ""] ?? -1) < (positions[$1.fragment ?? ""] ?? -1) } + let documentTitle = try document.title().trimmingCharacters(in: .whitespacesAndNewlines) + let heading = try body.select("h1,h2,h3").first()?.text() ?? "" + let fallback = !documentTitle.isEmpty ? documentTitle : (!heading.isEmpty ? heading : "第\(chapters.count + 1)章") + var boundaries = refs + if boundaries.isEmpty { + boundaries = [Reference(path: item.path, fragment: nil, title: fallback)] + } else if let first = boundaries.first, let fragment = first.fragment { + let prefix = try render(body: body, from: nil, to: fragment).text + if !prefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + boundaries.insert(Reference(path: item.path, fragment: nil, title: fallback), at: 0) + } + } + for (index, ref) in boundaries.enumerated() { + var chapter = BookChapter(url: Self.referenceURL(path: item.path, fragment: ref.fragment), + title: ref.title.isEmpty ? fallback : ref.title, bookUrl: book.bookUrl, index: chapters.count) + chapter.resourceUrl = item.path + chapter.startFragmentId = ref.fragment + chapter.endFragmentId = boundaries.indices.contains(index + 1) ? boundaries[index + 1].fragment : nil + chapters.append(chapter) + } + } + guard !chapters.isEmpty else { throw EPUBError.invalidPackage } + return chapters + } + + func content(chapter: BookChapter) throws -> ChapterContentReport { + let (body, _) = try body(chapter: chapter) + let value = try render(body: body, from: chapter.startFragmentId, to: chapter.endFragmentId) + return .init(title: chapter.title, content: value.text, pagesVisited: 1) + } + + func illustrations(chapter: BookChapter) throws -> [EPUBIllustration] { + let (body, path) = try body(chapter: chapter) + let value = try render(body: body, from: chapter.startFragmentId, to: chapter.endFragmentId) + var totalBytes = 0 + return try value.images.enumerated().map { index, image in + guard let ref = Self.resolve(image.src, relativeTo: path), + let item = manifest.first(where: { $0.path == ref.path }), item.mediaType.hasPrefix("image/") else { + throw EPUBError.missingResource(image.src) + } + let data = try read(ref.path) + totalBytes += data.count + guard totalBytes <= Self.resourceLimit else { throw EPUBError.resourceTooLarge } + return EPUBIllustration(id: index + 1, caption: image.alt, data: data) + } + } + + private func body(chapter: BookChapter) throws -> (Element, String) { + guard let path = chapter.resourceUrl, spine.contains(where: { $0.path == path }), + let body = try document(path).body() else { throw EPUBError.invalidChapter } + return (body, path) + } + + private func document(_ path: String) throws -> Document { + let data = try read(path) + let text: String + if data.starts(with: [0xff, 0xfe]) || data.starts(with: [0xfe, 0xff]), let utf16 = String(data: data, encoding: .utf16) { + text = utf16 + } else if let utf8 = String(data: data, encoding: .utf8) { text = utf8 } + else { throw EPUBError.invalidPackage } + return try SwiftSoup.parse(text) + } + + private func navigation() -> [Reference] { + if let nav = manifest.first(where: { $0.properties.contains("nav") }), let doc = try? document(nav.path), + let nodes = try? doc.select("nav").array() { + let toc = nodes.first { node in + let type = (try? node.attr("epub:type")) ?? "" + return type.split(whereSeparator: \.isWhitespace).contains("toc") || (try? node.attr("role")) == "doc-toc" + } + if let links = try? toc?.select("a[href]").array() { + let refs = links.compactMap { element -> Reference? in + guard let href = try? element.attr("href"), let ref = Self.resolve(href, relativeTo: nav.path) else { return nil } + return Reference(path: ref.path, fragment: ref.fragment, title: (try? element.text()) ?? "") + } + if !refs.isEmpty { return refs } + } + } + let tocID = package.children.first { $0.name == "spine" }?.attributes["toc"] + if let ncx = manifest.first(where: { $0.id == tocID }) ?? manifest.first(where: { $0.mediaType == "application/x-dtbncx+xml" }), + let data = try? read(ncx.path), let root = try? EPUBXMLNode.parse(data) { + return root.descendants("navPoint").compactMap { node in + guard let href = node.children.first(where: { $0.name == "content" })?.attributes["src"], + let ref = Self.resolve(href, relativeTo: ncx.path) else { return nil } + return Reference(path: ref.path, fragment: ref.fragment, + title: node.children.first(where: { $0.name == "navLabel" })?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") + } + } + return [] + } + + /// Walk DOM boundaries rather than slicing serialized HTML: anchors can be nested + /// in different parents, and all text after the end anchor belongs to the next chapter. + private func render(body: Element, from start: String?, to end: String?) throws -> (text: String, images: [(src: String, alt: String)]) { + var active = start == nil + var foundStart = start == nil + var foundEnd = end == nil + var finished = false + var output = "" + var images: [(src: String, alt: String)] = [] + let blocks: Set = ["p", "div", "section", "article", "li", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "tr", "hr"] + func visit(_ node: Node, depth: Int) throws { + guard depth < 512 else { throw EPUBError.invalidPackage } + guard !finished else { return } + guard let element = node as? Element else { + if active, let text = node as? TextNode { + output += text.getWholeText().replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + } + return + } + let id = element.id() + let name = try element.attr("name") + if let end, id == end || name == end { active = false; finished = true; foundEnd = true; return } + if let start, id == start || name == start { active = true; foundStart = true } + let tag = element.tagName().lowercased() + let style = try element.attr("style").lowercased().replacingOccurrences(of: #"\s+"#, with: "", options: .regularExpression) + if ["script", "style", "noscript", "rt", "rp", "title"].contains(tag) || element.hasAttr("hidden") || style.contains("display:none") { return } + if active, blocks.contains(tag) || tag == "br" { output += "\n" } + if active, tag == "img" || tag == "image" { + var src = try element.attr(tag == "img" ? "src" : "xlink:href") + if src.isEmpty, tag == "image" { src = try element.attr("href") } + let alt = try element.attr("alt") + if !src.isEmpty { + images.append((src, alt)) + output += "\n【插图 \(images.count)\(alt.isEmpty ? "" : ":" + alt)】\n" + } + } + for child in element.getChildNodes() { try visit(child, depth: depth + 1) } + if active, blocks.contains(tag) { output += "\n" } + if active, tag == "td" || tag == "th" { output += " " } + } + try visit(body, depth: 0) + guard foundStart && foundEnd else { throw EPUBError.invalidChapter } + let text = output.components(separatedBy: .newlines).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }.joined(separator: "\n") + return (text, images) + } + + private func read(_ path: String) throws -> Data { + guard !encryptedPaths.contains(path) else { throw EPUBError.encrypted } + return try Self.read(path, archive: archive, entries: entries) + } + + private static func read(_ path: String, archive: Archive, entries: [String: Entry]) throws -> Data { + guard let entry = entries[path] else { throw EPUBError.missingResource(path) } + guard entry.uncompressedSize <= resourceLimit else { throw EPUBError.resourceTooLarge } + var result = Data() + let checksum = try archive.extract(entry) { chunk in + guard result.count + chunk.count <= resourceLimit else { throw EPUBError.resourceTooLarge } + result.append(chunk) + } + guard checksum == entry.checksum, result.count == entry.uncompressedSize else { throw EPUBError.invalidArchive } + return result + } + + private static func coverThumbnail(_ data: Data) -> Data? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 512, + kCGImageSourceCreateThumbnailWithTransform: true + ] as CFDictionary) else { return nil } + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(output, "public.png" as CFString, 1, nil) else { return nil } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { return nil } + return output as Data + } + + private static func safePath(_ path: String) -> Bool { + !path.isEmpty && !path.hasPrefix("/") && !path.contains("\\") && !path.contains("\0") && !path.split(separator: "/").contains("..") + } + + private static func resolve(_ href: String, relativeTo base: String) -> (path: String, fragment: String?)? { + guard !href.hasPrefix("/"), !href.contains("\\"), + URLComponents(string: href)?.scheme == nil else { return nil } + let parts = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) + guard let pathPart = String(parts[0]).removingPercentEncoding else { return nil } + var components = pathPart.isEmpty ? base.split(separator: "/").map(String.init) : base.split(separator: "/").dropLast().map(String.init) + if !pathPart.isEmpty { + for part in pathPart.split(separator: "/") { + if part == "." { continue } + if part == ".." { guard !components.isEmpty else { return nil }; components.removeLast() } + else { components.append(String(part)) } + } + } + let path = components.joined(separator: "/") + guard safePath(path), !pathPart.hasPrefix("/") else { return nil } + let fragment = parts.count > 1 ? String(parts[1]).removingPercentEncoding : nil + return (path, fragment?.isEmpty == false ? fragment : nil) + } + + private static func referenceURL(path: String, fragment: String?) -> String { + var url = URLComponents() + url.scheme = "epub" + url.host = "book" + url.path = "/" + path + url.fragment = fragment + return url.string ?? path + } +} + +/// Small namespace-independent XML tree for package metadata and navigation. +private final class EPUBXMLNode: NSObject, XMLParserDelegate { + var name = "" + var attributes: [String: String] = [:] + var children: [EPUBXMLNode] = [] + var text = "" + private var stack: [EPUBXMLNode] = [] + private var count = 0 + + func descendants(_ name: String) -> [EPUBXMLNode] { + children.flatMap { ($0.name == name ? [$0] : []) + $0.descendants(name) } + } + + static func parse(_ data: Data) throws -> EPUBXMLNode { + let delegate = EPUBXMLNode() + let parser = XMLParser(data: data) + parser.shouldResolveExternalEntities = false + parser.delegate = delegate + guard parser.parse(), let root = delegate.children.first else { throw EPUBError.invalidPackage } + return root + } + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) { + count += 1 + guard stack.count < 128, count <= 50_000 else { parser.abortParsing(); return } + let node = EPUBXMLNode() + node.name = elementName.split(separator: ":").last.map(String.init) ?? elementName + node.attributes = attributeDict + (stack.last ?? self).children.append(node) + stack.append(node) + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + for node in stack { + guard node.text.utf8.count + string.utf8.count <= 4 * 1_024 * 1_024 else { parser.abortParsing(); return } + node.text += string + } + } + + func parser(_ parser: XMLParser, foundInternalEntityDeclarationWithName name: String, value: String?) { + parser.abortParsing() + } + + func parser(_ parser: XMLParser, foundExternalEntityDeclarationWithName name: String, publicID: String?, systemID: String?) { + parser.abortParsing() + } + + func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) { + if let value = String(data: CDATABlock, encoding: .utf8) { self.parser(parser, foundCharacters: value) } + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + _ = stack.popLast() + } +} diff --git a/Sources/LegadoCore/LocalBook/LocalBookService.swift b/Sources/LegadoCore/LocalBook/LocalBookService.swift index 3827e4c..776e060 100644 --- a/Sources/LegadoCore/LocalBook/LocalBookService.swift +++ b/Sources/LegadoCore/LocalBook/LocalBookService.swift @@ -10,7 +10,7 @@ public enum LocalBookError: Error, LocalizedError, Sendable { public var errorDescription: String? { switch self { - case .unsupportedFormat: "当前仅支持 TXT 本地书" + case .unsupportedFormat: "当前支持 TXT 和 EPUB 本地书" case .oversized: "本地书超过当前配置允许的大小" case .unreadableEncoding: "无法识别 TXT 编码" case .missingFile: "本地书文件已丢失" @@ -104,6 +104,49 @@ public struct LocalBookService: Sendable { LocalBookService(tocRules: TxtTocRuleDefaults.load()) } + public static func supports(_ url: URL) -> Bool { + url.isFileURL && ["txt", "epub"].contains(url.pathExtension.lowercased()) + } + + public func openFile(at url: URL) throws -> LocalBookImportReport { + guard Self.supports(url) else { throw LocalBookError.unsupportedFormat } + if url.pathExtension.lowercased() == "epub" { + try validateFile(url) + return try EPUBBook(url: url).report(url: url, originalName: url.lastPathComponent) + } + return try openTextFile(at: url) + } + + public func importFile(at source: URL, into directory: URL) throws -> LocalBookImportReport { + guard Self.supports(source) else { throw LocalBookError.unsupportedFormat } + try validateFile(source) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let destination = directory.appendingPathComponent(UUID().uuidString + "." + source.pathExtension.lowercased()) + try FileManager.default.copyItem(at: source, to: destination) + do { + if source.pathExtension.lowercased() == "epub" { + return try EPUBBook(url: destination).report(url: destination, originalName: source.lastPathComponent) + } + return try textFileReport(data: Data(contentsOf: destination, options: .mappedIfSafe), sourceURL: source, destination: destination) + } catch { + try? FileManager.default.removeItem(at: destination) + throw error + } + } + + public func loadIllustrations(book: Book, chapter: BookChapter) throws -> [EPUBIllustration] { + guard let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" else { return [] } + try validateFile(url) + return try EPUBBook(url: url).illustrations(chapter: chapter) + } + + private func validateFile(_ url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile } + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { throw LocalBookError.missingFile } + guard Int64(values.fileSize ?? 0) <= maximumFileSize else { throw LocalBookError.oversized } + } + public func importTextFile(at sourceURL: URL, into directory: URL) throws -> LocalBookImportReport { guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat } let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) @@ -181,6 +224,10 @@ public struct LocalBookService: Sendable { } public func loadChapters(book: Book) throws -> ChapterListReport { + if let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" { + try validateFile(url) + return try .init(chapters: EPUBBook(url: url).chapters(book: book)) + } let decoded = try read(book) let selectionVolumeState = TxtVolumeState() let probeText = textProbe(decoded.data, encoding: decoded.encoding) @@ -201,6 +248,10 @@ public struct LocalBookService: Sendable { } public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport { + if let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" { + try validateFile(url) + return try EPUBBook(url: url).content(chapter: chapter) + } guard let url = URL(string: book.bookUrl), url.isFileURL, FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile } let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 diff --git a/Sources/LegadoCore/LocalBook/LocalFileService.swift b/Sources/LegadoCore/LocalBook/LocalFileService.swift index d560d54..83496db 100644 --- a/Sources/LegadoCore/LocalBook/LocalFileService.swift +++ b/Sources/LegadoCore/LocalBook/LocalFileService.swift @@ -32,7 +32,7 @@ public struct StagedLocalFile: Sendable { public actor LocalFileService { public let directory: URL - public init(directory: URL) { self.directory = directory.standardizedFileURL } + public init(directory: URL) { self.directory = URL(fileURLWithPath: directory.path, isDirectory: true).standardizedFileURL } public func list() throws -> [LocalFileEntry] { guard FileManager.default.fileExists(atPath: directory.path) else { return [] } @@ -41,13 +41,13 @@ public actor LocalFileService { options: [.skipsHiddenFiles] ).compactMap { url in let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey]) - guard values.isRegularFile == true, values.isSymbolicLink != true, url.pathExtension.lowercased() == "txt" else { return nil } + guard values.isRegularFile == true, values.isSymbolicLink != true, LocalBookService.supports(url) else { return nil } return .init(url: url, bytes: Int64(values.fileSize ?? 0), modified: values.contentModificationDate) }.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending } } public func importFile(from source: URL) throws -> URL { - guard source.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat } + guard LocalBookService.supports(source) else { throw LocalBookError.unsupportedFormat } let values = try source.resourceValues(forKeys: [.isRegularFileKey]) guard values.isRegularFile == true else { throw LocalFileError.missingFile } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -55,7 +55,7 @@ public actor LocalFileService { var target = directory.appendingPathComponent(source.lastPathComponent) var suffix = 2 while FileManager.default.fileExists(atPath: target.path) { - target = directory.appendingPathComponent("\(stem) (\(suffix)).txt") + target = directory.appendingPathComponent("\(stem) (\(suffix)).\(source.pathExtension)") suffix += 1 } try FileManager.default.copyItem(at: source, to: target) @@ -69,7 +69,8 @@ public actor LocalFileService { !trimmed.contains(where: { $0 == "/" || $0 == "\\" || $0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else { throw LocalFileError.invalidName } - let filename = trimmed.lowercased().hasSuffix(".txt") ? trimmed : trimmed + ".txt" + let suffix = "." + source.pathExtension + let filename = trimmed.lowercased().hasSuffix(suffix.lowercased()) ? trimmed : trimmed + suffix guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName } let target = directory.appendingPathComponent(filename) if source.standardizedFileURL == target.standardizedFileURL { return source } @@ -83,7 +84,7 @@ public actor LocalFileService { guard FileManager.default.fileExists(atPath: source.path) else { return nil } let trash = directory.appendingPathComponent(".Trash", isDirectory: true) try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true) - let staged = trash.appendingPathComponent(UUID().uuidString + ".txt") + let staged = trash.appendingPathComponent(UUID().uuidString + "." + source.pathExtension) try FileManager.default.moveItem(at: source, to: staged) return .init(original: source, staged: staged) } @@ -104,7 +105,7 @@ public actor LocalFileService { } private func validate(_ url: URL, allowMissing: Bool = false) throws { - guard url.isFileURL, url.pathExtension.lowercased() == "txt", + guard url.isFileURL, LocalBookService.supports(url), url.standardizedFileURL.deletingLastPathComponent() == directory, url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else { throw LocalFileError.outsideLibrary @@ -131,7 +132,7 @@ public actor BookFileExporter { } public func originalFile(_ source: URL, filename: String) throws -> BookFileExport { - let target = try destination(filename: filename) + let target = try destination(filename: filename, extension: source.pathExtension.lowercased()) do { try FileManager.default.copyItem(at: source, to: target) return .init(url: target, cachedChapters: 0, totalChapters: 0) @@ -178,11 +179,11 @@ public actor BookFileExporter { try FileManager.default.removeItem(at: export.url.deletingLastPathComponent()) } - private func destination(filename: String) throws -> URL { + private func destination(filename: String, extension fileExtension: String = "txt") throws -> URL { let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let safe = filename.components(separatedBy: CharacterSet(charactersIn: "/\\:").union(.controlCharacters)).joined(separator: "_") - let stem = String(safe.replacingOccurrences(of: ".txt", with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70)) - return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + ".txt") + let stem = String(safe.replacingOccurrences(of: "." + fileExtension, with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70)) + return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + "." + fileExtension) } } diff --git a/Sources/LegadoCore/Networking/LegadoURLBuilder.swift b/Sources/LegadoCore/Networking/LegadoURLBuilder.swift index 1e4dc49..d54f61b 100644 --- a/Sources/LegadoCore/Networking/LegadoURLBuilder.swift +++ b/Sources/LegadoCore/Networking/LegadoURLBuilder.swift @@ -548,17 +548,32 @@ public struct LegadoURLBuilder: Sendable { case "{", ",": output.append(character) expectingKey = true - case ":": - output.append(character) + case ":", "=": + // Gson also accepts = and => between an object key and value. + // Quoted strings are consumed above, so Cookie/URL values stay intact. + output.append(":") + if character == "=", peek() == ">" { index += 1 } expectingKey = false - if quoteUnquotedValues { + do { var start = index while start < characters.count, characters[start].isWhitespace { start += 1 } if start < characters.count, !["\"", "'", "{", "[", "}", ","].contains(characters[start]) { var end = start - while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 } - output.append(escapedJSONString(String(characters[start.. start { + let token = String(characters[start.. [JSONValue] { values.flatMap { value in if case .array(let items) = value { return items } + // Text rules serialize selected JSON arrays. Expand them before + // evaluating article fields, just as for an already parsed array. + if case .string(let text) = value, + text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("["), + let items = try? JSONDecoder().decode([JSONValue].self, from: Data(text.utf8)) { + return items + } return [value] } } diff --git a/Sources/LegadoCore/RuleEngine/JSONPathEvaluator.swift b/Sources/LegadoCore/RuleEngine/JSONPathEvaluator.swift index 4936c43..60c4322 100644 --- a/Sources/LegadoCore/RuleEngine/JSONPathEvaluator.swift +++ b/Sources/LegadoCore/RuleEngine/JSONPathEvaluator.swift @@ -17,6 +17,48 @@ public struct JSONPathEvaluator: Sendable { public init() {} + // Legado's JSON rules also allow literal templates such as /book/{$.id}. + // Balance braces and quoted strings instead of stopping at the first }. + func evaluateRule(_ rule: String, in root: JSONValue) throws -> [JSONValue] { + var cursor = rule.startIndex + var output = "" + var foundTemplate = false + while let opening = rule.range(of: "{$.", range: cursor.. [JSONValue] { let segments = try parse(path) var current = [root] @@ -59,7 +101,10 @@ public struct JSONPathEvaluator: Sendable { guard next < rule.endIndex else { throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾") } - if rule[next] == "." { + if rule[next] == "[" { + // Legacy sources use $.[*] as well as $[*]. + index = next + } else if rule[next] == "." { index = rule.index(after: next) let parsed = try parseDotSelector(rule, from: index, original: path) segments.append(.recursive(parsed.selector)) diff --git a/Sources/LegadoCore/RuleEngine/JSONValue.swift b/Sources/LegadoCore/RuleEngine/JSONValue.swift index a73fe5a..cdfe1c4 100644 --- a/Sources/LegadoCore/RuleEngine/JSONValue.swift +++ b/Sources/LegadoCore/RuleEngine/JSONValue.swift @@ -84,7 +84,8 @@ public extension JSONValue { if value is NSNull { return .null } if let value = value as? JSONValue { return value } if let value = value as? String { return .string(value) } - if let value = value as? Bool { return .bool(value) } + // NSNumber(0/1) also casts to Bool. Inspect its CF type first so + // JavaScript page arithmetic and numeric IDs remain numbers. if let value = value as? NSNumber { if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) } return .number(value.doubleValue) diff --git a/Sources/LegadoCore/RuleEngine/JavaScriptRuleEvaluator.swift b/Sources/LegadoCore/RuleEngine/JavaScriptRuleEvaluator.swift index 3b594f5..ee596d0 100644 --- a/Sources/LegadoCore/RuleEngine/JavaScriptRuleEvaluator.swift +++ b/Sources/LegadoCore/RuleEngine/JavaScriptRuleEvaluator.swift @@ -208,6 +208,33 @@ public struct JavaScriptRuleEvaluator: Sendable { script: String, result: JSONValue, executionContext: RuleExecutionContext + ) throws -> JavaScriptRuleResult { + try evaluate(script: script, result: result, executionContext: executionContext, + virtualMachine: JSVirtualMachine(), stringifyResult: false) + } + + /// A rule shares java.get/put storage, but each match gets fresh JS globals. + /// Keep this closure on the synchronous replacement call's thread. + func replacementEvaluator(script: String, book: Book, chapter: BookChapter) -> (String) throws -> String { + var virtualMachine: JSVirtualMachine? + var executionContext = RuleExecutionContext(book: book, chapter: chapter) + return { match in + if virtualMachine == nil { virtualMachine = JSVirtualMachine() } + let result = try autoreleasepool { + try evaluate(script: script, result: .string(match), executionContext: executionContext, + virtualMachine: virtualMachine!, stringifyResult: true) + } + executionContext.variables = result.variables + return result.value.stringValue + } + } + + private func evaluate( + script: String, + result: JSONValue, + executionContext: RuleExecutionContext, + virtualMachine: JSVirtualMachine, + stringifyResult: Bool ) throws -> JavaScriptRuleResult { guard script.utf8.count <= maximumScriptLength else { throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 规则超过长度上限") @@ -215,8 +242,7 @@ public struct JavaScriptRuleEvaluator: Sendable { try rejectUnboundedExecution(script) let box = JSVariableBox(executionContext.variables) - let vm = JSVirtualMachine() - guard let context = JSContext(virtualMachine: vm) else { + guard let context = JSContext(virtualMachine: virtualMachine) else { throw RuleEngineError.invalidInput(rule: script, expected: "无法创建 JavaScriptCore 上下文") } var exceptionMessage: String? @@ -271,7 +297,28 @@ public struct JavaScriptRuleEvaluator: Sendable { let normalized = evaluated.flatMap { context.objectForKeyedSubscript("__legadoUnboxString")?.call(withArguments: [$0]) } - let value = JSONValue.bridge(normalized?.toObject()) + let value: JSONValue + if stringifyResult { + // Rhino unwraps undefined to null before RegexExtensions.kt calls + // toString(). Preserve strings directly, rather than JSON encoding. + if let evaluated, !evaluated.isUndefined, !evaluated.isNull { + let text = evaluated.toString() ?? "null" + if evaluated.isNumber { + // The reference runs Rhino in interpreted mode, yielding JVM Doubles. + let number = evaluated.toDouble() + value = .string(number.isFinite ? String(number) : text) + } else { + value = .string(text) + } + } else { + value = .string("null") + } + } else { + value = JSONValue.bridge(normalized?.toObject()) + } + if let exceptionMessage { + throw RuleEngineError.malformedRule(rule: script, detail: "JavaScript 执行失败:\(exceptionMessage)") + } guard value.stringValue.utf8.count <= maximumResultLength else { throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 结果超过长度上限") } @@ -974,53 +1021,48 @@ public struct JavaScriptRuleEvaluator: Sendable { return resolveJavaScriptRuleURL(direct.stringValue, enabled: isURL, context: executionContext) } - let plan = try RuleExpressionParser().parse(trimmedRule) + let jsonContent: JSONValue? + if case .string(let text) = content { jsonContent = try? JSONValue.decode(data: Data(text.utf8)) } + else { jsonContent = content } + let plan = try RuleExpressionParser().parse(trimmedRule, inputLooksLikeJSON: jsonContent != nil) var branchResults: [[String]] = [] for branch in plan.branches { var values: [String] - switch branch.mode { - case .css: - values = try HTMLRuleEvaluator().extractCSS( - branch.expression, - html: content.stringValue, - baseURL: executionContext.baseURL, - isFragment: executionContext.isHTMLFragment - ) - case .xpath: - values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment) - case .jsonPath: - let json: JSONValue - if case .string(let text) = content { - json = try JSONValue.decode(data: Data(text.utf8)) - } else { - json = content - } - let path = branch.expression.hasPrefix("$") ? branch.expression : "." + branch.expression - let normalizedPath = path.hasPrefix("$") ? path : "$" + path - values = try JSONPathEvaluator().evaluate(path: normalizedPath, in: json) - .map(\.stringValue) - case .regex: - values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue) - .map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" } - case .javaScript: - values = [try evaluate( - script: branch.expression, - result: content, - executionContext: executionContext - ).value.stringValue] - case .webJavaScript: - throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript") - } - if let pattern = branch.replacePattern, !pattern.isEmpty { - values = try values.map { - try RegexEvaluator().replacing( - pattern: pattern, - in: $0, - with: branch.replacement, - firstOnly: branch.replaceFirst + if branch.expression.isEmpty { + values = [content.stringValue] + } else { + switch branch.mode { + case .css: + values = try HTMLRuleEvaluator().extractCSS( + branch.expression, + html: content.stringValue, + baseURL: executionContext.baseURL, + isFragment: executionContext.isHTMLFragment ) + case .xpath: + values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment) + case .jsonPath: + let json: JSONValue + if case .string(let text) = content { + json = try JSONValue.decode(data: Data(text.utf8)) + } else { + json = content + } + values = RuleEvaluation(values: try JSONPathEvaluator().evaluateRule(branch.expression, in: json)).strings + case .regex: + values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue) + .map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" } + case .javaScript: + values = [try evaluate( + script: branch.expression, + result: content, + executionContext: executionContext + ).value.stringValue] + case .webJavaScript: + throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript") } } + if plan.combination == .fallback, values.joined(separator: "\n").isEmpty { values = [] } branchResults.append(values) if plan.combination == .fallback, !values.isEmpty { break } } @@ -1037,7 +1079,11 @@ public struct JavaScriptRuleEvaluator: Sendable { branchResults.compactMap { $0.indices.contains(index) ? $0[index] : nil } } } - return resolveJavaScriptRuleURL(combined.joined(separator: "\n"), enabled: isURL, context: executionContext) + var result = combined.joined(separator: "\n") + if let branch = plan.branches.first, let pattern = branch.replacePattern, !pattern.isEmpty { + result = try RegexEvaluator().replacing(pattern: pattern, in: result, with: branch.replacement, firstOnly: branch.replaceFirst) + } + return resolveJavaScriptRuleURL(result, enabled: isURL, context: executionContext) } private func resolveJavaScriptRuleURL( diff --git a/Sources/LegadoCore/RuleEngine/RegexEvaluator.swift b/Sources/LegadoCore/RuleEngine/RegexEvaluator.swift index c7c4573..348c578 100644 --- a/Sources/LegadoCore/RuleEngine/RegexEvaluator.swift +++ b/Sources/LegadoCore/RuleEngine/RegexEvaluator.swift @@ -112,6 +112,25 @@ public struct RegexEvaluator: Sendable { public func replacingStrict(pattern: String, in input: String, with replacement: String, timeoutMilliseconds: Int64 = 3_000) throws -> String { + try replacingStrict(pattern: pattern, in: input, timeoutMilliseconds: timeoutMilliseconds) { expression, match in + expression.replacementString(for: match, in: input, offset: 0, template: replacement) + } + } + + /// Legado's JS replacement still expands capture references, while quoting + /// backslashes returned by the script (quoteReplacementJs in the reference). + func replacingStrict(pattern: String, in input: String, timeoutMilliseconds: Int64 = 3_000, + replacement: (String) throws -> String) throws -> String { + let original = input as NSString + return try replacingStrict(pattern: pattern, in: input, timeoutMilliseconds: timeoutMilliseconds) { expression, match in + let template = try replacement(original.substring(with: match.range)) + .replacingOccurrences(of: "\\", with: "\\\\") + return expression.replacementString(for: match, in: input, offset: 0, template: template) + } + } + + private func replacingStrict(pattern: String, in input: String, timeoutMilliseconds: Int64, + replacement: (NSRegularExpression, NSTextCheckingResult) throws -> String) throws -> String { guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength, !hasObviousNestedQuantifier(pattern) else { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词") @@ -145,30 +164,51 @@ public struct RegexEvaluator: Sendable { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)") } let untouched = original.substring(with: NSRange(location: offset, length: match.range.location - offset)) - let replaced = expression.replacementString(for: match, in: input, offset: 0, template: replacement) - output += untouched + replaced + let replaced = try replacement(expression, match) + guard DispatchTime.now().uptimeNanoseconds < deadline else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)") + } outputLength += untouched.utf16.count + replaced.utf16.count offset = NSMaxRange(match.range) guard outputLength <= limits.maximumInputLength * 2 else { throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制") } + output += untouched + replaced } - output += original.substring(from: offset) + let tail = original.substring(from: offset) + guard outputLength + tail.utf16.count <= limits.maximumInputLength * 2 else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制") + } + output += tail return output } private func hasObviousNestedQuantifier(_ pattern: String) -> Bool { - let risky = #"\(((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*)\)([+*]|\{[^}]+\})"# - guard let expression = try? NSRegularExpression(pattern: risky) else { return false } - return expression.matches(in: pattern, range: NSRange(pattern.startIndex..., in: pattern)).contains { match in - guard let innerRange = Range(match.range(at: 1), in: pattern), - let outerRange = Range(match.range(at: 2), in: pattern) else { return true } - let inner = String(pattern[innerRange]) - let outer = String(pattern[outerRange]) - if outer.range(of: #"^\{[1-9]\}$"#, options: .regularExpression) != nil { return false } - if !pattern.contains("(?s"), - inner.range(of: #"^(?:\\n[^\[\]()*+?\\]*\.\*|\.\+\\n)$"#, options: .regularExpression) != nil { return false } - return true + // Reject clear cases such as (a+)+ and (\w*)*. A '+' inside a character + // class/escape is not a quantifier, and bounded or delimited repeated + // groups are not proof of catastrophic backtracking. Other expressions + // remain protected by enumerateMatches' progress/deadline checks. + let atom = #"^(?:\?:)?(?:\\[dDsSwW]|\\[^pPkKgG0-9]|\[(?:\\.|[^\]\\])*\]|[^\[\]()\\?*+{}|^$])(?:[+*]|\{[0-9]+,\})\??$"# + guard let repeatedAtom = try? NSRegularExpression(pattern: atom), + let unbounded = try? NSRegularExpression(pattern: #"^(?:[+*]|\{[0-9]+,\})(?!\+)"#) else { return false } + var groups: [String.Index] = [] + var classDepth = 0 + var escaped = false + for index in pattern.indices { + let character = pattern[index] + if escaped { escaped = false; continue } + if character == "\\" { escaped = true; continue } + if character == "[" { classDepth += 1; continue } + if character == "]", classDepth > 0 { classDepth -= 1; continue } + guard classDepth == 0 else { continue } + if character == "(" { groups.append(pattern.index(after: index)) } + if character == ")", let start = groups.popLast() { + let inner = String(pattern[start.. RuleEvaluation { do { @@ -80,7 +81,8 @@ public struct RuleEngine: Sendable { var diagnostics: [RuleDiagnostic] = [] for branch in plan.branches { do { - let values = try await evaluate(branch, in: input, context: context) + var values = try await evaluate(branch, in: input, context: context) + if plan.combination == .fallback, isEmpty(values, listMode: listMode) { values = [] } results.append(values) if plan.combination == .fallback, !values.isEmpty { break } } catch let error as RuleEngineError { @@ -97,7 +99,7 @@ public struct RuleEngine: Sendable { } } return .init( - values: combine(results, with: plan.combination), + values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode), diagnostics: diagnostics, variables: context.variables ) @@ -174,6 +176,11 @@ public struct RuleEngine: Sendable { listMode: Bool, context: RuleExecutionContext ) async -> RuleEvaluation { + if let root = try? JSONValue.decode(data: Data(input.utf8)), + let plan = try? parser.parse(rule, inputLooksLikeJSON: true), + plan.branches.allSatisfy({ $0.mode == .jsonPath }) { + return await evaluatePrepared(rule, in: root, listMode: listMode, context: context) + } if rule.hasPrefix(":"), rule.count > 1 { do { let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty } @@ -217,7 +224,7 @@ public struct RuleEngine: Sendable { } } return .init( - values: combine(results, with: plan.combination), + values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode), diagnostics: diagnostics, variables: context.variables ) @@ -241,15 +248,11 @@ public struct RuleEngine: Sendable { try Task.checkCancellation() let rawValues: [JSONValue] if branch.expression.isEmpty { - return try [JSONValue.string(regex.replacing( - pattern: branch.replacePattern ?? "", in: input.stringValue, - with: branch.replacement, firstOnly: branch.replaceFirst - ))] + return [input] } switch branch.mode { case .jsonPath: - let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)" - rawValues = try jsonPath.evaluate(path: path, in: input) + rawValues = try jsonPath.evaluateRule(branch.expression, in: input) case .regex: let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue) rawValues = matches.map { .array($0.map(JSONValue.string)) } @@ -266,15 +269,7 @@ public struct RuleEngine: Sendable { case .webJavaScript: throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript") } - guard let pattern = branch.replacePattern, !pattern.isEmpty else { return rawValues } - return try rawValues.map { - .string(try regex.replacing( - pattern: pattern, - in: $0.stringValue, - with: branch.replacement, - firstOnly: branch.replaceFirst - )) - } + return rawValues } private func evaluateTextBranch( @@ -287,10 +282,7 @@ public struct RuleEngine: Sendable { try Task.checkCancellation() let raw: [String] if branch.expression.isEmpty { - return try [regex.replacing( - pattern: branch.replacePattern ?? "", in: input, - with: branch.replacement, firstOnly: branch.replaceFirst - )] + return [input] } switch branch.mode { case .css: @@ -308,8 +300,7 @@ public struct RuleEngine: Sendable { } catch { throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON") } - let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)" - raw = try jsonPath.evaluate(path: path, in: root).map(\.stringValue) + raw = try jsonPath.evaluateRule(branch.expression, in: root).map(\.stringValue) case .regex: raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? "" @@ -323,14 +314,22 @@ public struct RuleEngine: Sendable { case .webJavaScript: throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript") } - guard let pattern = branch.replacePattern, !pattern.isEmpty else { return raw } - return try raw.map { - try regex.replacing( - pattern: pattern, - in: $0, - with: branch.replacement, - firstOnly: branch.replaceFirst - ) + return raw + } + + private func isEmpty(_ values: [JSONValue], listMode: Bool) -> Bool { + let result = RuleEvaluation(values: values) + return listMode + ? result.listItems(rule: "").filter { $0 != .null }.isEmpty + : result.strings.joined(separator: "\n").isEmpty + } + + private func applyingReplacement(to values: [JSONValue], plan: RulePlan, listMode: Bool) throws -> [JSONValue] { + guard let branch = plan.branches.first, let pattern = branch.replacePattern, !pattern.isEmpty else { return values } + let strings = RuleEvaluation(values: values).strings + let inputs = listMode ? strings : [strings.joined(separator: "\n")] + return try inputs.map { + .string(try regex.replacing(pattern: pattern, in: $0, with: branch.replacement, firstOnly: branch.replaceFirst)) } } diff --git a/Sources/LegadoCore/RuleEngine/RuleExpression.swift b/Sources/LegadoCore/RuleEngine/RuleExpression.swift index d038733..8e053d0 100644 --- a/Sources/LegadoCore/RuleEngine/RuleExpression.swift +++ b/Sources/LegadoCore/RuleEngine/RuleExpression.swift @@ -52,8 +52,12 @@ public struct RuleExpressionParser: Sendable { guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空") } - let split = try splitAtTopLevel(normalized) - let branches = try split.parts.map { try parseBranch($0, inputLooksLikeJSON: inputLooksLikeJSON) } + // ## belongs to the whole selector stage. Regex/replacement text may + // contain quotes, brackets and && that are not selector operators. + let replacementStart = normalized.range(of: "##")?.lowerBound ?? normalized.endIndex + let split = try splitAtTopLevel(String(normalized[..,固定版本 `0.9.20`。 +- 用途:按需读取 EPUB ZIP 容器与校验资源 CRC。 +- 许可:MIT,完整版权及许可见 [ZIPFoundation-LICENSE.txt](App/Resources/ZIPFoundation-LICENSE.txt)。 + ## 测试数据与演示内容 测试中的规则样本仅用于兼容验证,不打包为 App 默认书源。具体说明见 [测试样本说明](Tests/LegadoCoreTests/Fixtures/README.md)。演示模式使用虚构书籍元数据和项目内演示文本;发布截图不使用个人书架、登录信息或付费章节。 diff --git a/Tests/LegadoAppModelsTests/EPUBReadingRegressionTests.swift b/Tests/LegadoAppModelsTests/EPUBReadingRegressionTests.swift new file mode 100644 index 0000000..2a8c9eb --- /dev/null +++ b/Tests/LegadoAppModelsTests/EPUBReadingRegressionTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +import ZIPFoundation +import LegadoCore +@testable import LegadoAppModels + +@MainActor +struct EPUBReadingRegressionTests { + private func fixture(at root: URL) throws -> URL { + let url = root.appendingPathComponent("示例.epub") + let archive = try Archive(url: url, accessMode: .create) + let files = [ + ("mimetype", "application/epub+zip"), + ("META-INF/container.xml", #""#), + ("book.opf", #"回归书籍测试作者"#), + ("first.xhtml", "第一章

第一章

第一章测试正文

"), + ("last.xhtml", "第二章

第二章

第二章测试正文

") + ] + for (path, value) in files { + let data = Data(value.utf8) + try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count), compressionMethod: .deflate) { position, size in + data.subdata(in: Int(position).. HTTPResponse { + do { + let response = try await transport.data(for: request) + var record: [String: JSONValue] = [ + "url": .string(request.url?.absoluteString ?? ""), + "finalURL": .string(response.finalURL.absoluteString), + "status": .number(Double(response.statusCode)), + "bytes": .number(Double(response.data.count)), + "preview": .string(String(response.decodedText.prefix(240))) + ] + if let directory = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_SNAPSHOT_DIR"] { + let folder = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let file = folder.appendingPathComponent(UUID().uuidString + ".response") + try response.data.write(to: file) + record["snapshot"] = .string(file.path) + } + values.append(.object(record)) + return response + } catch { + values.append(.object(["url": .string(request.url?.absoluteString ?? ""), "error": .string(error.localizedDescription)])) + throw error + } + } + + func records() -> [JSONValue] { values } +} diff --git a/Tests/LegadoCoreTests/EPUBBookTests.swift b/Tests/LegadoCoreTests/EPUBBookTests.swift new file mode 100644 index 0000000..f16b8f5 --- /dev/null +++ b/Tests/LegadoCoreTests/EPUBBookTests.swift @@ -0,0 +1,193 @@ +import Foundation +import Testing +import ZIPFoundation +@testable import LegadoCore + +struct EPUBBookTests { + private let service = LocalBookService() + private let png = Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABgCAIAAAAip+O/AAABxklEQVR4nNXCA3YjAABF0becsW12bNdIGptt7Nq2bXeMjc06/j2XI8UG6RwtMUrnWGmtdI6XmaRzotwsnZMVFumcqrRK53SVTTpnqu3SOVvjkM45g1M6540u6VyodUvnoskjnUtmr3QuW3zSuWL1S+eqLSCda/agdK47QtK54QxL56arTjq33PXSue2JSOeONyqdu76YdO7549K5H0hI50EwKZ2HoZR0isJp6Tyqy0jncX1WOk8iOek8jeal8yxWkM7zeIN0XiQapfMy2SSdV6lm6bxOt0jnTaZVOm+zbdJ5l2uXzvt8h3Q+FDql87GhSzqfGrul87mpRzpfmnulU9zSJ52S1n7plLYNSKesfVA65R1D0qnoHJZOZdeIdKq6R6VT3TMmnZrecekY+iakY+yflE7twJR0TIPT0jEPzUjHMjwrHevInHRso/PSsY8tSMcxvigd58SSdFyTy9JxT61IxzO9Kh3vzJp0fLPr0vHPbUgnML8pneDClnRCi9vSCS/tSKdueVc69St70oms7ksnunYgndj6oXTiG1+lk9j8Jp3k1nfppLZ/SCe981M6md1f0snu/ZZObv+PdPIHf6VTOPwn/T/zbe3/96qaXAAAAABJRU5ErkJggg==")! + + private func archive(_ files: [(String, Data)]) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("示例.EPUB") + let archive = try Archive(url: url, accessMode: .create) + for (path, data) in files { + try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count), compressionMethod: path == "mimetype" ? .none : .deflate) { position, size in + data.subdata(in: Int(position).. URL { + let navItem = epub3 + ? #""# + : #""# + let package = """ + + + + 测试书籍作者甲作者乙 + <p>测试简介 &amp; 内容</p> + + + + + \(navigation ? navItem : "") + + + + + + + + """ + let body = chapter ?? """ + 正文 +

锚点前的引言

第一章

甲 & 乙相遇。

+

隐藏内容

+

字zi(zi)形

风景
+

第二章

第二章正文

+ + """ + let nav = """ + + + + """ + let ncx = """ + + 第一卷 + 第一章 + 第二章 + 尾声 + + """ + var files: [(String, Data)] = [ + ("mimetype", Data("application/epub+zip".utf8)), + ("META-INF/container.xml", Data(#""#.utf8)), + ("OPS/book.opf", Data(package.utf8)), + ("OPS/Text/intro.xhtml", Data("序言

开篇内容

".utf8)), + ("OPS/Text/中文 章节.xhtml", Data(body.utf8)), + ("OPS/Text/extra.xhtml", Data("

未列入目录

不应遗漏正文

".utf8)), + ("OPS/Text/end.xhtml", Data("

完结正文

".utf8)), + ("OPS/Images/封面.png", png) + ] + if navigation { files.append((epub3 ? "OPS/目录/nav.xhtml" : "OPS/目录/toc.ncx", Data((epub3 ? nav : ncx).utf8))) } + return try archive(files + extras) + } + + @Test(arguments: [true, false]) func readsEPUB2And3MetadataSpineAnchorsAndIllustrations(epub3: Bool) throws { + let url = try fixture(epub3: epub3) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let report = try service.openFile(at: url) + #expect(report.book.name == "测试书籍") + #expect(report.book.author == "作者甲、作者乙") + #expect(report.book.intro == "测试简介 & 内容") + #expect(report.book.coverUrl?.hasPrefix("data:image/png;base64,") == true) + #expect(report.book.readConfig?.useReplaceRule == false) + #expect(!report.book.canUpdate) + #expect(report.chapters.map(\.title) == ["序言", "正文", "第一章", "第二章", "未列入目录", "尾声"]) + #expect(report.chapters.map(\.index) == Array(0..<6)) + #expect(Set(report.chapters.map(\.id)).count == 6) + #expect(try service.loadChapters(book: report.book).chapters == report.chapters) + let contents = try report.chapters.map { try service.loadContent(book: report.book, chapter: $0).content } + #expect(contents[0] == "开篇内容") + #expect(contents[1] == "锚点前的引言") + #expect(contents[2] == "第一章\n甲 & 乙相遇。\n字形\n【插图 1:风景】") + #expect(contents[3] == "第二章\n第二章正文") + #expect(contents[4].contains("不应遗漏正文")) + #expect(contents[5] == "完结正文") + let images = try service.loadIllustrations(book: report.book, chapter: report.chapters[2]) + #expect(images.count == 1 && images.first?.data == png && images.first?.caption == "风景") + #expect(try service.loadIllustrations(book: report.book, chapter: report.chapters[3]).isEmpty) + } + + @Test func fallsBackToSpineWithoutNavigationAndDoesNotUseManifestOrder() throws { + let url = try fixture(navigation: false) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let report = try service.openFile(at: url) + #expect(report.chapters.count == 4) + #expect(report.chapters.first?.title == "序言") + let body = try service.loadContent(book: report.book, chapter: report.chapters[1]).content + #expect(body.contains("锚点前的引言") && body.contains("第一章") && body.contains("第二章正文")) + } + + @Test func importsCopyPreservingMetadataAndOriginalFile() throws { + let url = try fixture() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let report = try service.importFile(at: url, into: url.deletingLastPathComponent().appendingPathComponent("managed")) + let destination = try #require(URL(string: report.book.bookUrl)) + #expect(destination != url && destination.pathExtension == "epub") + #expect(report.book.originName == "示例.EPUB") + #expect(try Data(contentsOf: destination) == Data(contentsOf: url)) + #expect(try service.loadContent(book: report.book, chapter: report.chapters[2]).content.contains("甲 & 乙")) + } + + @Test func fileManagementPreservesEPUBOnRenameExportAndRemoval() async throws { + let url = try fixture() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let root = url.deletingLastPathComponent() + let files = LocalFileService(directory: root.appendingPathComponent("managed")) + let first = try await files.importFile(from: url) + let second = try await files.importFile(from: url) + #expect(second.lastPathComponent == "示例 (2).EPUB") + let renamed = try await files.rename(first, to: "新名字") + #expect(renamed.pathExtension == "EPUB") + #expect(try await files.list().count == 2) + let exporter = BookFileExporter(directory: root.appendingPathComponent("exports")) + let export = try await exporter.originalFile(renamed, filename: "测试书籍.epub") + #expect(export.url.lastPathComponent == "测试书籍.epub") + #expect(try Data(contentsOf: export.url) == Data(contentsOf: url)) + let staged = try #require(try await files.stageRemoval(renamed)) + try await files.restore(staged) + #expect(try service.openFile(at: renamed).book.name == "测试书籍") + let removed = try #require(try await files.stageRemoval(renamed)) + try await files.finishRemoval(removed) + #expect(try await files.list().count == 1) + } + + @Test func rejectsCorruptionTraversalEncryptionAndOversizeWithoutLeavingImportedFiles() throws { + let good = try fixture() + defer { try? FileManager.default.removeItem(at: good.deletingLastPathComponent()) } + #expect(throws: LocalBookError.self) { try LocalBookService(maximumFileSize: 1).openFile(at: good) } + let broken = good.deletingLastPathComponent().appendingPathComponent("损坏.epub") + try Data("不是压缩包".utf8).write(to: broken) + let managed = good.deletingLastPathComponent().appendingPathComponent("managed") + #expect(throws: EPUBError.self) { try service.importFile(at: broken, into: managed) } + #expect(try FileManager.default.contentsOfDirectory(atPath: managed.path).isEmpty) + let malicious = try fixture(extras: [("../escape", Data("outside".utf8))]) + defer { try? FileManager.default.removeItem(at: malicious.deletingLastPathComponent()) } + #expect(throws: EPUBError.self) { try service.openFile(at: malicious) } + let encrypted = try fixture(extras: [("META-INF/encryption.xml", Data(#""#.utf8))]) + defer { try? FileManager.default.removeItem(at: encrypted.deletingLastPathComponent()) } + #expect(throws: EPUBError.self) { try service.openFile(at: encrypted) } + let fontOnly = try fixture(extras: [("META-INF/encryption.xml", Data(#""#.utf8))]) + defer { try? FileManager.default.removeItem(at: fontOnly.deletingLastPathComponent()) } + #expect(try service.openFile(at: fontOnly).chapters.count == 6) + } + + @Test func rejectsForgedChapterResourceAndMissingAnchors() throws { + let url = try fixture() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let report = try service.openFile(at: url) + var chapter = report.chapters[2] + chapter.resourceUrl = "../../outside" + #expect(throws: EPUBError.self) { try service.loadContent(book: report.book, chapter: chapter) } + chapter = report.chapters[2] + chapter.startFragmentId = "missing" + #expect(throws: EPUBError.self) { try service.loadContent(book: report.book, chapter: chapter) } + } + + @Test func ignoresBadNavigationAnchorsAndOrdersValidAnchorsByDocumentPosition() throws { + let url = try fixture(chapter: "

第二章

先读

第一章

后读

") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let report = try service.openFile(at: url) + #expect(report.chapters.map(\.title) == ["序言", "第二章", "第一章", "未列入目录", "尾声"]) + #expect(try service.loadContent(book: report.book, chapter: report.chapters[1]).content == "第二章\n先读") + } +} diff --git a/Tests/LegadoCoreTests/LegadoDatabaseTests.swift b/Tests/LegadoCoreTests/LegadoDatabaseTests.swift index f4d7b8a..11d1298 100644 --- a/Tests/LegadoCoreTests/LegadoDatabaseTests.swift +++ b/Tests/LegadoCoreTests/LegadoDatabaseTests.swift @@ -393,6 +393,20 @@ struct LegadoDatabaseTests { #expect(report.result.nextURL == "https://rss.example/list?page=1") } + @Test func rssJSONListWithoutWildcardExtractsIndividualArticles() async throws { + var source = RssSource(sourceUrl: "https://rss.example", sourceName: "JSON 订阅") + source.ruleArticles = "$.items" + source.ruleTitle = "$.title" + source.ruleLink = "$.link" + let report = try await RSSRuleParser().parse( + sortName: "全部", sortURL: "https://rss.example/feed", redirectURL: "https://rss.example/feed", + body: #"{"items":[{"title":"第一篇","link":"/one"},{"title":"第二篇","link":"/two"}]}"#, + source: source + ) + #expect(report.result.articles.map(\.title) == ["第一篇", "第二篇"]) + #expect(report.result.articles.map(\.link) == ["https://rss.example/one", "https://rss.example/two"]) + } + @Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws { let importer = RSSSourceImporter() let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8)) diff --git a/Tests/LegadoCoreTests/ReplaceRuleExecutionTests.swift b/Tests/LegadoCoreTests/ReplaceRuleExecutionTests.swift new file mode 100644 index 0000000..290a6ff --- /dev/null +++ b/Tests/LegadoCoreTests/ReplaceRuleExecutionTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct ReplaceRuleExecutionTests { + private func process(_ content: String, rules: [ReplaceRule], title: String = "第十二章") -> ContentProcessingReport { + let book = Book(bookUrl: "book", name: "测试书", origin: "https://source.test") + let chapter = BookChapter(url: "chapter", title: title, bookUrl: book.bookUrl, index: 11) + return ContentSanitizer().process(book: book, chapter: chapter, content: content, rules: rules, + removeDuplicateTitle: false, resegment: false) + } + + @Test func evaluatesJavaScriptForEveryMatchIncludingZeroWidthMatches() { + let width = ReplaceRule(id: 1, name: "全角", pattern: "[A-Z0-9]", + replacement: "@js:String.fromCharCode(result.charCodeAt(0) - 65248)") + var punctuation = ReplaceRule(id: 2, name: "标点", pattern: #"(?<=说道)(?=“)|\.{3,}"#, + replacement: #"@js:if (result === '') { ':' } else { '…' }"#) + punctuation.sortOrder = 1 + let result = process("ABC123\n他说道“等待...”", rules: [punctuation, width]) + #expect(result.content == "ABC123\n他说道:“等待…”") + #expect(result.effectiveRuleIDs == [1, 2]) + #expect(result.diagnostics.isEmpty) + } + + @Test func bindsBookAndChapterAndSharesOnlyJavaVariablesBetweenMatches() { + let rule = ReplaceRule(id: 1, name: "上下文", pattern: "[ab]", replacement: """ + @js:n = typeof n === 'undefined' ? 1 : n + 1; + java.put('count', String(Number(java.get('count') || 0) + 1)); + [book.name, book.getName(), chapter.title, chapter.getIndex(), result, n, java.get('count')].join(':') + """) + let report = process("a b", rules: [rule]) + #expect(report.content == "测试书:测试书:第十二章:11:a:1:1 测试书:测试书:第十二章:11:b:1:2") + #expect(report.diagnostics.isEmpty) + // A new application of the same rule must not inherit another chapter's variables. + #expect(process("a", rules: [rule]).content == "测试书:测试书:第十二章:11:a:1:1") + } + + @Test(arguments: [ + (#"'$1'"#, "x"), + (#"String.fromCharCode(92) + 'n'"#, #"\n"#), + ("null", "null"), + ("undefined", "null"), + ("['a', 'b']", "a,b"), + ("123", "123.0"), + ("false", "false") + ]) + func convertsScriptCompletionAndPreservesReplacementEscaping(_ sample: (String, String)) { + let rule = ReplaceRule(id: 1, name: "返回值", pattern: "(x)", replacement: "@js:" + sample.0) + let report = process("x", rules: [rule]) + #expect(report.content == sample.1) + #expect(report.diagnostics.isEmpty) + } + + @Test func failedScriptDiscardsAllPartialReplacementsAndLaterRulesStillRun() { + let failing = ReplaceRule(id: 1, name: "失败", pattern: "[ab]", + replacement: "@js:if (result === 'b') { throw new Error('bad match') }; 'changed'") + var following = ReplaceRule(id: 2, name: "后续", pattern: "tail", replacement: "后续成功") + following.sortOrder = 1 + let report = process("ab tail", rules: [failing, following]) + #expect(report.content == "ab 后续成功") + #expect(report.effectiveRuleIDs == [2]) + #expect(report.diagnostics.count == 1) + #expect(report.diagnostics.first?.code == "rule.malformed") + } + + @Test func skipsDisabledExcludedAndUnmatchedScriptsAndKeepsLiteralReplacementsLiteral() { + var excluded = ReplaceRule(id: 1, name: "排除", pattern: "x", replacement: "@js:throw new Error('excluded')") + excluded.excludeScope = "测试书" + var disabled = excluded + disabled.id = 2 + disabled.excludeScope = nil + disabled.isEnabled = false + let unmatched = ReplaceRule(id: 3, name: "未匹配", pattern: "z", replacement: "@js:throw new Error('unmatched')") + var literal = ReplaceRule(id: 4, name: "普通文本", pattern: "x", replacement: "@js:'literal'") + literal.isRegex = false + let report = process("x", rules: [excluded, disabled, unmatched, literal]) + #expect(report.content == "@js:'literal'") + #expect(report.diagnostics.isEmpty) + } + + @Test func preservesLastNonblankTitleButAllowsDeletingBody() { + var rename = ReplaceRule(id: 1, name: "标题", pattern: "十二", replacement: "12") + rename.scopeTitle = true + rename.scopeContent = false + var erase = ReplaceRule(id: 2, name: "空白", pattern: "^.+$", replacement: "@js:'  \\n'") + erase.scopeTitle = true + erase.sortOrder = 1 + let report = process("正文", rules: [erase, rename]) + #expect(report.title == "第12章") + #expect(report.content == "  \n") + #expect(report.effectiveRuleIDs == [1, 2]) + erase.replacement = "" + erase.scopeContent = false + let titleOnly = process("正文", rules: [erase]) + #expect(titleOnly.title == "第十二章") + #expect(titleOnly.effectiveRuleIDs.isEmpty) + } + + @Test(arguments: [ + (#"(?:\n[^\n]+){0,15}$"#, "正文\nPS:说明\n感谢支持", "正文"), + (#"^(?:[^\n\w]\w+){0,2}"#, ",第一章,开始\n正文", "\n正文"), + (#""((?:[\w,。]|‘\w+’|\b[%\+\-])+)["]"#, ""你好,世界。"", "“你好,世界。”"), + (#"(?:[+*]|\\)+"#, #"+*\"#, "") + ]) + func acceptsBoundedDelimitedAndEscapedQuantifiers(_ sample: (String, String, String)) throws { + let replacement = sample.0.hasPrefix(""") ? "“$1”" : "" + #expect(try RegexEvaluator().replacingStrict(pattern: sample.0, in: sample.1, with: replacement) == sample.2) + } + + @Test(arguments: ["(a+)+$", #"(\w*)*"#, "(?:[ab]+){2,}"]) + func retainsChecksForClearlyNestedUnboundedRepetition(_ pattern: String) { + #expect(throws: RuleEngineError.self) { + try RegexEvaluator().replacingStrict(pattern: pattern, in: "aaa!", with: "changed") + } + } + + @Test func dynamicReplacementRespectsMatchOutputAndWholeOperationTimeLimits() throws { + let limited = RegexEvaluator(limits: .init(maximumInputLength: 4, maximumMatches: 2)) + #expect(throws: RuleEngineError.self) { + try limited.replacingStrict(pattern: "a", in: "aaa", replacement: { _ in "b" }) + } + #expect(try limited.replacingStrict(pattern: "a", in: "aa", replacement: { _ in "b" }) == "bb") + // Include untouched tail in the output limit, not only replaced spans. + #expect(throws: RuleEngineError.self) { + try limited.replacingStrict(pattern: "a", in: "abcd", replacement: { _ in "1234567" }) + } + #expect(throws: RuleEngineError.self) { + try limited.replacingStrict(pattern: "a", in: "a", timeoutMilliseconds: 1, replacement: { _ in + Thread.sleep(forTimeInterval: 0.02) + return "b" + }) + } + } + + @Test func rejectsKnownUnboundedJavaScriptAndReportsErrorWithoutChangingInput() { + let rule = ReplaceRule(id: 1, name: "循环", pattern: "x", replacement: "@js:while(true){}") + let report = process("x", rules: [rule]) + #expect(report.content == "x") + #expect(report.diagnostics.first?.code == "rule.resource_limit") + } +} diff --git a/Tests/LegadoCoreTests/SampledRequestRegressionTests.swift b/Tests/LegadoCoreTests/SampledRequestRegressionTests.swift new file mode 100644 index 0000000..a80a459 --- /dev/null +++ b/Tests/LegadoCoreTests/SampledRequestRegressionTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import LegadoCore + +// The payloads and host below are synthetic and never use network access. +struct SampledRequestRegressionTests { + @Test func equalsSeparatedSourceHeaderReachesSearchTransport() async throws { + var source = source() + source.header = #"{"type"="app"}"# + source.searchUrl = #"/search,{"method":"POST","body":"keyword={{key}}&page={{page-1}}"}"# + let transport = SampledRequestTransport() + let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source) + #expect(result.diagnostics.isEmpty) + #expect(result.books.count == 1) + let request = try #require(await transport.requests().first) + #expect(request.value(forHTTPHeaderField: "type") == "app") + #expect(request.httpBody.map { String(decoding: $0, as: UTF8.self) } == "keyword=%E6%96%97%E7%BD%97%E5%A4%A7%E9%99%86&page=0") + } + + @Test func stringEncodedLenientHeadersReachSearchTransport() async throws { + var source = source() + source.searchUrl = #"/search?word={{key}},{"headers":"{os:'pc'}"}"# + let transport = SampledRequestTransport() + let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source) + #expect(result.diagnostics.isEmpty) + #expect(result.books.count == 1) + let request = try #require(await transport.requests().first) + #expect(request.value(forHTTPHeaderField: "os") == "pc") + } + + @Test func unquotedKeywordInObjectBodyPreservesJSONValueTypes() async throws { + var source = source() + source.searchUrl = #"/search,{"method":"POST","body":{"keyword":{{key}},"page_index":{{page}},"page_size":20,"enabled":true,"cursor":null}}"# + let transport = SampledRequestTransport() + let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source) + #expect(result.diagnostics.isEmpty) + #expect(result.books.count == 1) + let request = try #require(await transport.requests().first) + let body = try JSONValue.decode(data: #require(request.httpBody)) + #expect(body["keyword"] == .string("斗罗大陆")) + #expect(body["page_index"] == .number(1)) + #expect(body["page_size"] == .number(20)) + #expect(body["enabled"] == .bool(true)) + #expect(body["cursor"] == .null) + #expect(request.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("application/json") == true) + } + + @Test func lenientSeparatorsDoNotRewriteQuotedCookieOrURLValues() throws { + let value = try LegadoURLBuilder.decodeLenientJSON(#"{type=>app, 'Cookie':'token=a=b; mode=pc', referer:'https://fixture.invalid/?a=b', retry:2, enabled:false, empty:null}"#) + #expect(value["type"] == .string("app")) + #expect(value["Cookie"] == .string("token=a=b; mode=pc")) + #expect(value["referer"] == .string("https://fixture.invalid/?a=b")) + #expect(value["retry"] == .number(2)) + #expect(value["enabled"] == .bool(false)) + #expect(value["empty"] == .null) + } + + @Test func unquotedWebViewFlagUsedByAudioSourceParses() throws { + let request = try LegadoURLBuilder().prepare("https://request.fixture.invalid/audio,{webView:“true”}") + #expect(request.useWebView) + } + + @Test func lenientValuesKeepCommentsAndPutRuleExpressionsWorking() throws { + let options = try LegadoURLBuilder.decodeLenientJSON("{retry: 2 /* comment */, enabled: FALSE, word: 小说}") + #expect(options["retry"] == .number(2)) + #expect(options["enabled"] == .bool(false)) + #expect(options["word"] == .string("小说")) + let put = try LegadoURLBuilder.decodeLenientJSON("{id:$.books[0].id, name:name}", quoteUnquotedValues: true) + #expect(put["id"] == .string("$.books[0].id")) + #expect(put["name"] == .string("name")) + #expect(throws: (any Error).self) { try LegadoURLBuilder.decodeLenientJSON("{broken:}") } + } + + private func source() -> BookSource { + var source = BookSource(bookSourceUrl: "https://request.fixture.invalid", bookSourceName: "请求兼容测试") + source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url") + return source + } +} + +private actor SampledRequestTransport: HTTPTransport { + private var values: [URLRequest] = [] + func data(for request: URLRequest) async throws -> HTTPResponse { + values.append(request) + return .init(data: Data(#"{"books":[{"name":"测试书","url":"/book/42"}]}"#.utf8), + statusCode: 200, headers: [:], finalURL: try #require(request.url)) + } + func requests() -> [URLRequest] { values } +} diff --git a/Tests/LegadoCoreTests/SampledSourceRegressionTests.swift b/Tests/LegadoCoreTests/SampledSourceRegressionTests.swift new file mode 100644 index 0000000..d1205dd --- /dev/null +++ b/Tests/LegadoCoreTests/SampledSourceRegressionTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing +@testable import LegadoCore + +// All responses and hosts are synthetic; these tests never contact source websites. +struct SampledSourceRegressionTests { + @Test func JavaScriptNumbersZeroAndOneRemainNumeric() throws { + let evaluator = JavaScriptRuleEvaluator() + let value = try evaluator.evaluate(script: "({zero: 0, one: 1, yes: true, no: false, list: [0, 1, true]})", result: .null, executionContext: .init()).value + #expect(value["zero"] == .number(0)) + #expect(value["one"] == .number(1)) + #expect(value["yes"] == .bool(true)) + #expect(value["no"] == .bool(false)) + #expect(value["list"] == .array([.number(0), .number(1), .bool(true)])) + } + + @Test func JavaScriptGetStringUsesTheSameJSONTemplateAndReplacementSemantics() async throws { + let input = try JSONValue.decode(data: Data(#"{"id":42,"name":"测试小说","alternate":"备用小说"}"#.utf8)) + let value = await RuleEngine().evaluate("@js:java.getString('/book/{$.id}') + ':' + java.getString('$.name||$.alternate##小说')", in: input) + #expect(value.diagnostics.isEmpty) + #expect(value.strings == ["/book/42:测试"]) + } + + @Test func legacyJSONTemplatesKeepEachSearchBookURLDistinct() async throws { + var source = BookSource(bookSourceUrl: "https://fixture.invalid", bookSourceName: "旧式 JSON 模板") + source.searchUrl = "/search?word={{key}}" + source.ruleSearch = .init(bookList: "data.bookList", name: "name", bookUrl: "/pc/book/{$.id}/catalog") + let outcome = await BookSearchService(transport: SampledResponseTransport(body: + #"{"data":{"bookList":[{"id":42,"name":"测试甲"},{"id":43,"name":"测试乙"}]}}"# + )).search(keyword: "测试", page: 1, source: source) + #expect(outcome.diagnostics.isEmpty) + #expect(outcome.books.map(\.bookURL) == ["https://fixture.invalid/pc/book/42/catalog", "https://fixture.invalid/pc/book/43/catalog"]) + } + + @Test func legacyJSONRootWildcardAndRecursiveURLTemplate() async throws { + var source = BookSource(bookSourceUrl: "https://fixture.invalid", bookSourceName: "旧式 JSON 列表") + source.searchUrl = "/search?word={{key}}" + source.ruleSearch = .init(bookList: "$.[*]", name: "$..name", bookUrl: "https://fixture.invalid{$..url}") + let outcome = await BookSearchService(transport: SampledResponseTransport(body: + #"[{"name":"测试甲","url":"/book/42"},{"name":"测试乙","url":"/book/43"}]"# + )).search(keyword: "测试", page: 1, source: source) + #expect(outcome.diagnostics.isEmpty) + #expect(outcome.books.map(\.bookURL) == ["https://fixture.invalid/book/42", "https://fixture.invalid/book/43"]) + } + + @Test func legacyJSONTemplatesRespectQuotedBracesAndReplacement() async throws { + let input = try JSONValue.decode(data: Data(#"{"id":42,"words":1200,"meta":{"label}":"玄幻"},"tags":["甲","乙"]}"#.utf8)) + let value = await RuleEngine().evaluate(#"{$.meta['label}']}/{$.words}字/{$.tags[*]}##1200##1.2千"#, in: input) + #expect(value.diagnostics.isEmpty) + #expect(value.strings == ["玄幻/1.2千字/甲\n乙"]) + let fromText = await RuleEngine().evaluate("/book/{$.id}", inText: input.stringValue) + #expect(fromText.diagnostics.isEmpty) + #expect(fromText.strings == ["/book/42"]) + } + + @Test func JSONFallbackSkipsEmptyArraysAndEmptyNames() async throws { + let input = try JSONValue.decode(data: Data(#"{"empty":[],"books":[{"name":"备用书"}],"name":"","alternate":"备用书","zero":0}"#.utf8)) + for useText in [false, true] { + let list = if useText { + await RuleEngine().evaluate("$.empty||$.books", inText: input.stringValue, listMode: true) + } else { + await RuleEngine().evaluate("$.empty||$.books", in: input, listMode: true) + } + #expect(list.diagnostics.isEmpty) + #expect(list.listItems(rule: "$.empty||$.books").count == 1) + let name = await RuleEngine().evaluate("$.name||$.alternate", in: input) + #expect(name.strings == ["备用书"]) + let zero = await RuleEngine().evaluate("$.zero||$.alternate", in: input) + #expect(zero.strings == ["0"]) + } + } + + @Test func replacementAppliesToTheCombinedSelectorResult() async throws { + // Search names in the sampled fallback sources and multi-part metadata + // use one trailing ## expression for the entire selector combination. + let input = try JSONValue.decode(data: Data(#"{"name":"测试小说","fallback":"备用小说","a":"A1","b":"B2"}"#.utf8)) + let fallback = await RuleEngine().evaluate("$.name||$.fallback##小说", in: input) + #expect(fallback.strings == ["测试"]) + let combined = await RuleEngine().evaluate("$.a&&$.b##[0-9]", in: input) + #expect(combined.strings.joined(separator: "\n") == "A\nB") + let anchored = await RuleEngine().evaluate(#"$.a&&$.b##^A1\nB2$##整段"#, in: input) + #expect(anchored.strings == ["整段"]) + let list = await RuleEngine().evaluate("$.a&&$.b##[0-9]", in: input, listMode: true) + #expect(list.strings == ["A", "B"]) + let html = await RuleEngine().evaluate("h1@text||h2@text##小说", inText: "

测试小说

") + #expect(html.strings == ["测试"]) + } + + @Test func replacementTextIsNotParsedAsSelectorSyntax() async { + let result = await RuleEngine().evaluate(#"h1@text##["']|[()]"#, inText: "

书名(测试)'

") + #expect(result.diagnostics.isEmpty) + #expect(result.strings == ["书名测试"]) + let replacement = await RuleEngine().evaluate("h1@text##书##A&&B", inText: "

书

") + #expect(replacement.diagnostics.isEmpty) + #expect(replacement.strings == ["A&&B"]) + } +} + +private struct SampledResponseTransport: HTTPTransport { + let body: String + func data(for request: URLRequest) async throws -> HTTPResponse { + .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: try #require(request.url)) + } +} diff --git a/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift b/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift index f8ad78f..feea125 100644 --- a/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift +++ b/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift @@ -246,6 +246,54 @@ final class ReaderScrollGeometryTests: XCTestCase { XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, accuracy: 1 / fixture.window.screen.scale) } + func testRepeatedShortChapterPrependingDoesNotAccumulateOffsetDrift() async throws { + func short(_ index: Int) -> ReaderScrollChapter { + .init(id: index, title: "第\(index + 1)章 · 连续阅读", content: "短章正文,继续向下可以自然读到下一章。") + } + let fixture = host(chapters: (20...22).map(short), active: 20) + defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true } + for first in stride(from: 19, through: 0, by: -1) { + fixture.coordinator.parent = surface(chapters: (first...22).map(short), active: 20) + fixture.coordinator.update(fixture.view) + try await Task.sleep(nanoseconds: 10_000_000) + } + let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout + let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 20 }) + XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, + accuracy: 1 / fixture.window.screen.scale) + XCTAssertTrue(fixture.view.accessibilityValue?.contains("chapter=20 ") == true, + fixture.view.accessibilityValue ?? "Missing viewport report") + } + + func testFractionalPixelBoundariesBelongToTheFollowingRow() { + let layout = ReaderContinuousLayout() + let frames = (0..<100).map { CGRect(x: 0, y: CGFloat($0 * 5) / 3, width: 390, height: 5.0 / 3) } + layout.install(frames: frames, size: CGSize(width: 390, height: 500.0 / 3), correction: 0) + for index in frames.indices { + XCTAssertEqual(layout.row(at: frames[index].minY), index) + } + } + + func testPrependingPreservesTheChapterBelowTopPadding() async throws { + func short(_ index: Int) -> ReaderScrollChapter { + .init(id: index, title: "短章\(index)", content: "短章正文") + } + let fixture = host(chapters: [short(20)], active: 20) + defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true } + // A short initial chapter can settle at offset zero with its heading + // still below the reading area's top padding. + fixture.view.setContentOffset(.zero, animated: false) + fixture.coordinator.parent = surface(chapters: (19...21).map(short), active: 20) + fixture.coordinator.update(fixture.view) + try await Task.sleep(nanoseconds: 10_000_000) + let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout + let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 20 }) + XCTAssertEqual(layout.frames[row].minY - fixture.view.contentOffset.y, 16, + accuracy: 1 / fixture.window.screen.scale) + XCTAssertTrue(fixture.view.accessibilityValue?.contains("chapter=20 ") == true, + fixture.view.accessibilityValue ?? "Missing viewport report") + } + private func chapter(_ index: Int) -> ReaderScrollChapter { .init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨‍👩‍👧‍👦,用于验证连续阅读的排版和位置。\n", count: 100)) } diff --git a/Tests/LegadoReaderTests/WebReaderBridgeTests.swift b/Tests/LegadoReaderTests/WebReaderBridgeTests.swift index c420f9d..4e35050 100644 --- a/Tests/LegadoReaderTests/WebReaderBridgeTests.swift +++ b/Tests/LegadoReaderTests/WebReaderBridgeTests.swift @@ -88,3 +88,159 @@ final class WebReaderBridgeTests: XCTestCase { XCTAssertEqual(result as? String, "已保存|规则正文") } } + +private actor RSSPaginationTransport: HTTPTransport { + private var requests: [URL] = [] + private var shouldFailSecondPage = true + + func data(for request: URLRequest) async throws -> HTTPResponse { + let url = try XCTUnwrap(request.url) + requests.append(url) + if url.host == "unavailable.test" { throw URLError(.cannotConnectToHost) } + let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? + .first { $0.name == "page" }?.value ?? "1" + if page == "2", shouldFailSecondPage { + shouldFailSecondPage = false + throw URLError(.networkConnectionLost) + } + let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}" + return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url) + } + + func requestedPages() -> [String] { + requests.compactMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "page" }?.value } + } + + func requestCount() -> Int { requests.count } +} + +private actor RSSDelayedTransport: HTTPTransport { + let pausedHost: String + let pausedPage: String + private var requestTotal = 0 + private var pending: CheckedContinuation? + + init(host: String, page: String) { pausedHost = host; pausedPage = page } + + func data(for request: URLRequest) async throws -> HTTPResponse { + let url = try XCTUnwrap(request.url) + let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? + .first { $0.name == "page" }?.value ?? "1" + requestTotal += 1 + if url.host == pausedHost, page == pausedPage { + await withCheckedContinuation { continuation in + pending = continuation + } + } + let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}" + return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url) + } + + func waitUntilPaused() async throws { + for _ in 0..<500 { + if pending != nil { return } + try await Task.sleep(nanoseconds: 10_000_000) + } + throw URLError(.timedOut) + } + + func resume() { pending?.resume(); pending = nil } + func requestCount() -> Int { requestTotal } +} + +@MainActor +final class RSSLibraryRegressionTests: XCTestCase { + func testUnavailableDatabaseDoesNotCrashOrPublishUnsavedImports() async throws { + let blocked = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data("existing file".utf8).write(to: blocked) + defer { try? FileManager.default.removeItem(at: blocked) } + let model = RSSLibraryModel.live(databaseURL: blocked.appendingPathComponent("rss.sqlite3")) + XCTAssertNotNil(model.errorMessage) + await model.load() + XCTAssertNotNil(model.errorMessage) + let saved = await model.save(source(), replacing: nil) + XCTAssertFalse(saved) + await model.importText(#"{"sourceUrl":"https://feed.test","sourceName":"未保存的订阅"}"#) + XCTAssertTrue(model.sources.isEmpty) + XCTAssertFalse(model.isWorking) + XCTAssertEqual(try Data(contentsOf: blocked), Data("existing file".utf8)) + } + + private func source(host: String = "feed.test") -> RssSource { + var source = RssSource(sourceUrl: "https://\(host)", sourceName: host) + source.sortUrl = "https://\(host)/list?page={{page}}" + source.ruleArticles = "$.items" + source.ruleTitle = "$.title" + source.ruleLink = "$.link" + source.ruleNextPage = "PAGE" + return source + } + + func testFailedNextPageRetriesTheSamePageWithoutSkippingArticles() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3")) + let transport = RSSPaginationTransport() + let model = RSSLibraryModel(database: database, service: RSSService(transport: transport)) + let feed = source() + await model.refresh(feed) + XCTAssertEqual(model.articles.map(\.title), ["Page 1"]) + await model.loadMore(feed) + XCTAssertNotNil(model.errorMessage) + XCTAssertEqual(model.articles.map(\.title), ["Page 1"]) + await model.loadMore(feed) + let pages = await transport.requestedPages() + XCTAssertEqual(pages, ["1", "2", "2"]) + XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"]) + } + + func testFailedSourceChangeDoesNotReusePreviousSourcesArticlesOrNextPage() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3")) + let transport = RSSPaginationTransport() + let model = RSSLibraryModel(database: database, service: RSSService(transport: transport)) + await model.refresh(source()) + let unavailable = source(host: "unavailable.test") + await model.refresh(unavailable) + XCTAssertTrue(model.articles.isEmpty) + let count = await transport.requestCount() + await model.loadMore(unavailable) + let afterLoadMore = await transport.requestCount() + XCTAssertEqual(afterLoadMore, count) + } + + func testSlowPreviousSourceCannotReplaceTheCurrentFeed() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3")) + let transport = RSSDelayedTransport(host: "slow.test", page: "1") + let model = RSSLibraryModel(database: database, service: RSSService(transport: transport)) + let slow = Task { await model.refresh(source(host: "slow.test")) } + try await transport.waitUntilPaused() + await model.refresh(source()) + XCTAssertTrue(model.isWorking) + await transport.resume() + await slow.value + XCTAssertEqual(model.articles.map(\.origin), ["https://feed.test"]) + XCTAssertFalse(model.isWorking) + } + + func testRepeatedLoadMoreWhileWaitingRequestsOnlyOnePage() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3")) + let transport = RSSDelayedTransport(host: "feed.test", page: "2") + let model = RSSLibraryModel(database: database, service: RSSService(transport: transport)) + let feed = source() + await model.refresh(feed) + let pending = Task { await model.loadMore(feed) } + try await transport.waitUntilPaused() + await model.loadMore(feed) + let count = await transport.requestCount() + XCTAssertEqual(count, 2) + await transport.resume() + await pending.value + XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"]) + } +} diff --git a/Tests/LegadoReaderUITests/DarkModeUITests.swift b/Tests/LegadoReaderUITests/DarkModeUITests.swift index 10be1c8..4f6a9e0 100644 --- a/Tests/LegadoReaderUITests/DarkModeUITests.swift +++ b/Tests/LegadoReaderUITests/DarkModeUITests.swift @@ -80,11 +80,14 @@ final class DarkModeUITests: XCTestCase { let keyboard = try luminances(app.keyboards.firstMatch, in: app) XCTAssertLessThan(keyboard[keyboard.count / 2], 0.15) } - let accessory = app.buttons["完成"] - let accessoryFrame = accessory.frame.intersection(app.frame) - if accessory.isHittable && !accessoryFrame.isNull && accessoryFrame.width > 20 && accessoryFrame.height > 20 { - try assertReadableDarkFill(accessory, in: app) - } + let accessory = app.buttons["syntaxCodeEditor.done"] + XCTAssertTrue(accessory.exists && accessory.isHittable) + try assertReadableDarkFill(accessory, in: app) + app.buttons["@"].tap() + XCTAssertTrue((app.textViews["syntaxCodeEditor"].value as? String)?.contains("@") == true) + accessory.tap() + let dismissed = expectation(for: NSPredicate { _, _ in !accessory.exists || !accessory.isHittable }, evaluatedWith: nil) + wait(for: [dismissed], timeout: 5) } func testDarkSettingsRulesAndManagementPages() throws { @@ -146,8 +149,8 @@ final class DarkModeUITests: XCTestCase { let app = launch(["-uiDemoTab", "search", "-themeMode", "2", "-uiAutomationSearch", "深色外观检查"]) let cancelSearch = app.buttons.matching(NSPredicate(format: "label IN %@", ["取消", "Cancel"])).firstMatch if cancelSearch.waitForExistence(timeout: 3) { cancelSearch.tap() } - XCTAssertTrue(app.buttons["搜索选项"].waitForExistence(timeout: 10)) - app.buttons["搜索选项"].tap() + XCTAssertTrue(app.buttons["search.options"].waitForExistence(timeout: 10)) + app.buttons["search.options"].tap() app.buttons["分组或书源"].tap() XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 5)) try captureDarkPage(app, name: "Search group filter") diff --git a/Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift b/Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift index e5ce4a0..b3cd3c9 100644 --- a/Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift +++ b/Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift @@ -108,7 +108,10 @@ final class LiveReadingFlowUITests: XCTestCase { showReaderMenu() app.buttons["换源"].tap() XCTAssertTrue(app.navigationBars["为《极道天魔》换源"].waitForExistence(timeout: 10)) - let replacement = app.buttons["sourceSwitch.candidate." + replacementID] + let replacement = app.buttons.matching(NSPredicate( + format: "identifier BEGINSWITH %@ AND label CONTAINS %@", + "sourceSwitch.candidate." + replacementID + "|", "滚开" + )).firstMatch XCTAssertTrue(replacement.waitForExistence(timeout: 240)) attach("07-source-candidates") replacement.tap() diff --git a/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift b/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift index 7360e0b..bd8b962 100644 --- a/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift +++ b/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift @@ -354,8 +354,13 @@ final class ReaderScrollGestureTests: XCTestCase { app.launch() let reader = app.collectionViews["reader.continuous"] XCTAssertTrue(reader.waitForExistence(timeout: 20)) + var lastInitialState = "" let ready = expectation(for: NSPredicate { _, _ in - let text = reader.value as? String ?? "" + let text = reader.value as? String ?? "" + if text != lastInitialState { + print("[ContinuousReaderInitial] \(text)") + lastInitialState = text + } return text.contains("chapter=20 ") && !text.contains("chapters=1 ") }, evaluatedWith: nil) wait(for: [ready], timeout: 20) diff --git a/Tests/LegadoReaderUITests/RuleEditorUITests.swift b/Tests/LegadoReaderUITests/RuleEditorUITests.swift index 59fe857..462826f 100644 --- a/Tests/LegadoReaderUITests/RuleEditorUITests.swift +++ b/Tests/LegadoReaderUITests/RuleEditorUITests.swift @@ -2,34 +2,89 @@ import XCTest @MainActor final class RuleEditorUITests: XCTestCase { - func testBookshelfTXTFilePickerImportsTXTAndDisablesJSON() throws { - try exerciseLocalTXTImport(fromFileManagement: false) - } - - func testFileManagementTXTFilePickerImportsTXTAndDisablesJSON() throws { - try exerciseLocalTXTImport(fromFileManagement: true) - } - - private func exerciseLocalTXTImport(fromFileManagement: Bool) throws { + func testRSSSearchSubmissionPaginationAndFavorites() throws { + guard let fixture = ProcessInfo.processInfo.environment["LEGADO_RSS_UI_FIXTURE_URL"] else { + throw XCTSkip("Set LEGADO_RSS_UI_FIXTURE_URL to the local RSS fixture source URL") + } continueAfterFailure = false let app = XCUIApplication() - app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3"] + app.launchArguments = ["-uiDemo", "-uiDemoTab", "rss", "-themeMode", "1"] + app.launch() + XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 20)) + app.buttons["RSS操作"].tap() + app.buttons["URL或粘贴JSON"].tap() + let editor = app.textViews.firstMatch + XCTAssertTrue(editor.waitForExistence(timeout: 5)) + editor.tap() + editor.typeText(fixture) + app.navigationBars["导入RSS源"].buttons["导入"].tap() + let source = app.staticTexts["RSS 全流程测试"] + XCTAssertTrue(source.waitForExistence(timeout: 10)) + source.tap() + XCTAssertTrue(app.staticTexts["首页文章"].waitForExistence(timeout: 10)) + let search = app.searchFields["源内搜索"] + if !search.exists { app.swipeDown() } + XCTAssertTrue(search.waitForExistence(timeout: 5)) + search.tap() + search.typeText("needle\n") + let result = app.staticTexts["搜索结果 needle 第1页"] + XCTAssertTrue(result.waitForExistence(timeout: 10)) + let nextPage = app.buttons["加载下一页"] + let ready = expectation(for: NSPredicate { _, _ in nextPage.exists && nextPage.isEnabled }, evaluatedWith: nil) + wait(for: [ready], timeout: 10) + nextPage.tap() + XCTAssertTrue(app.staticTexts["搜索结果 needle 第2页"].waitForExistence(timeout: 10)) + result.tap() + XCTAssertTrue(app.navigationBars["文章"].waitForExistence(timeout: 5)) + if app.buttons["取消收藏"].exists { app.buttons["取消收藏"].tap() } + XCTAssertTrue(app.buttons["收藏"].waitForExistence(timeout: 5)) + app.buttons["收藏"].tap() + XCTAssertTrue(app.buttons["取消收藏"].waitForExistence(timeout: 5)) + app.navigationBars["文章"].buttons.element(boundBy: 0).tap() + let cancelSearch = app.navigationBars["RSS 全流程测试"].buttons.matching(NSPredicate( + format: "label IN %@", ["Cancel", "取消", "Close", "关闭"] + )).firstMatch + if cancelSearch.exists { cancelSearch.tap() } + app.navigationBars["RSS 全流程测试"].buttons["RSS"].tap() + XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 5)) + app.segmentedControls.buttons["收藏"].tap() + XCTAssertTrue(result.waitForExistence(timeout: 5)) + attach(app, name: "RSS search article saved to favorites") + app.terminate() + app.launch() + XCTAssertTrue(app.staticTexts["RSS 全流程测试"].waitForExistence(timeout: 10)) + app.segmentedControls.buttons["收藏"].tap() + XCTAssertTrue(result.waitForExistence(timeout: 5)) + } + + func testBookshelfFilePickerImportsTXTAndEPUBAndDisablesJSON() throws { + try exerciseLocalBookImport(fromFileManagement: false) + } + + func testFileManagementFilePickerImportsTXTAndEPUBAndDisablesJSON() throws { + try exerciseLocalBookImport(fromFileManagement: true) + } + + private func exerciseLocalBookImport(fromFileManagement: Bool) throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3", "-pageAnim", "0"] if fromFileManagement { app.launchArguments += ["-uiDemoTab", "settings", "-uiDemoFiles"] } app.launch() - let importTitle = fromFileManagement ? "导入 TXT 文件" : "本地书" + let importTitle = fromFileManagement ? "导入 TXT / EPUB" : "本地书" let more = fromFileManagement ? app.navigationBars["文件管理"].buttons["更多"].firstMatch : app.buttons["更多"].firstMatch XCTAssertTrue(more.waitForExistence(timeout: 20)) more.tap() - app.buttons[importTitle].tap() + app.buttons[importTitle].firstMatch.tap() let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch XCTAssertTrue(cancel.waitForExistence(timeout: 10)) cancel.tap() let closed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil) wait(for: [closed], timeout: 10) more.tap() - app.buttons[importTitle].tap() + app.buttons[importTitle].firstMatch.tap() XCTAssertTrue(cancel.waitForExistence(timeout: 10)) let pickerFiles = app.collectionViews["File View"].cells @@ -66,6 +121,10 @@ final class RuleEditorUITests: XCTestCase { let uppercase = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch XCTAssertTrue(uppercase.waitForExistence(timeout: 5)) uppercase.tap() + let epub = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "EPUB-Book")).firstMatch + XCTAssertTrue(epub.waitForExistence(timeout: 5)) + XCTAssertTrue(epub.isEnabled) + epub.tap() XCTAssertTrue(open.waitForExistence(timeout: 5)) XCTAssertTrue(open.isEnabled) attach(app, name: "TXT selectable and JSON excluded") @@ -78,14 +137,34 @@ final class RuleEditorUITests: XCTestCase { XCTAssertTrue(app.navigationBars["导入本地书"].waitForExistence(timeout: 15)) XCTAssertFalse(app.staticTexts["JSON-Source.json"].exists) XCTAssertFalse(app.staticTexts["JSON-Uppercase.JSON"].exists) - XCTAssertTrue(app.staticTexts["TXT-Book.txt"].exists) - XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].exists) + XCTAssertTrue(app.staticTexts["TXT-Book.txt"].waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].waitForExistence(timeout: 10), app.debugDescription) app.buttons["导入"].tap() } // Repeated runs preserve managed files and correctly add a numeric suffix. XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Book")).firstMatch.waitForExistence(timeout: 15)) XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch.waitForExistence(timeout: 15)) - attach(app, name: "Local TXT books imported onto shelf") + let epubBook = app.staticTexts["EPUB 导入测试"].firstMatch + XCTAssertTrue(epubBook.waitForExistence(timeout: 15)) + attach(app, name: "Local TXT and EPUB books imported onto shelf") + epubBook.tap() + let reader = app.textViews["reader.text"] + XCTAssertTrue(reader.waitForExistence(timeout: 15)) + XCTAssertTrue((reader.value as? String)?.contains("这是 EPUB 导入后的第一章正文") == true) + reader.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + app.buttons["更多"].firstMatch.tap() + app.buttons["本章插图"].tap() + XCTAssertTrue(app.navigationBars["本章插图"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.images["书籍插图"].waitForExistence(timeout: 10)) + app.buttons["完成"].tap() + reader.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + app.buttons["目录"].tap() + app.staticTexts["第二章 EPUB"].firstMatch.tap() + let loaded = expectation(for: NSPredicate { _, _ in + (reader.value as? String)?.contains("目录锚点跳转成功") == true + }, evaluatedWith: nil) + wait(for: [loaded], timeout: 10) + attach(app, name: "EPUB second chapter after TOC navigation") } func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws { @@ -146,7 +225,7 @@ final class RuleEditorUITests: XCTestCase { attach(app, name: "JSON import review after file picker dismissal") app.buttons["导入"].tap() if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() } - XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10), app.debugDescription) XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists) } diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8b5188a..b162022 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -41,7 +41,7 @@ sh scripts/self-check.sh 阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。 -书源和本地 TXT 文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值): +书源和本地 TXT / EPUB 文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值): ```sh LEGADO_TEST_DEVICE="模拟器 UUID" @@ -49,12 +49,14 @@ sh scripts/prepare-source-import-fixture.sh "$LEGADO_TEST_DEVICE" xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \ -destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \ -only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \ - -only-testing:LegadoReaderUITests/RuleEditorUITests/testBookshelfTXTFilePickerImportsTXTAndDisablesJSON \ - -only-testing:LegadoReaderUITests/RuleEditorUITests/testFileManagementTXTFilePickerImportsTXTAndDisablesJSON \ + -only-testing:LegadoReaderUITests/RuleEditorUITests/testBookshelfFilePickerImportsTXTAndEPUBAndDisablesJSON \ + -only-testing:LegadoReaderUITests/RuleEditorUITests/testFileManagementFilePickerImportsTXTAndEPUBAndDisablesJSON \ -collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test ``` -独立测试应用提供 JSON 书源和 `.txt`、`.TXT` 小说样本,不会被打包进正式 App。此回归检查取消后重新打开、书源 JSON 多选、本地 TXT 可选且 JSON 不可选、导入确认及最终保存。 +独立测试应用提供 JSON 书源和 `.txt`、`.TXT`、`.epub` 小说样本,不会被打包进正式 App。此回归检查取消后重新打开、书源 JSON 多选、本地 TXT / EPUB 可选且 JSON 不可选、导入确认、最终保存、EPUB 正文、插图与目录跳转。 + +RSS 的导入、源内搜索、分页、正文、收藏以及重启恢复可通过本地自造响应回归,避免依赖第三方网站。先在一个终端运行 `python3 scripts/fixtures/rss-flow-server.py`,再在 Xcode 选择 `LegadoRSSFlowUITests` Scheme 和模拟器运行;也可用 `xcodebuild -project Legado.xcodeproj -scheme LegadoRSSFlowUITests -destination 'platform=iOS Simulator,id=模拟器UUID' CODE_SIGNING_ALLOWED=NO test`。该服务仅监听本机 `127.0.0.1:18763`,测试结束后按 Ctrl-C 停止。普通 UI Scheme 默认跳过此用例。 ## 兼容性验证 @@ -101,7 +103,7 @@ source_release_tag=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[ gh release download "$source_release_tag" --repo Aminark/legado-ios \ --pattern '*.ipa' --dir "$source_update_dir" python3 scripts/update-altstore-source.py \ - --ipa "$source_update_dir/legado-ios-v0.1.11-build17-unsigned.ipa" \ + --ipa "$source_update_dir/legado-ios-v0.1.12-build18-unsigned.ipa" \ --release "$source_update_dir/release.json" python3 -m unittest discover -s scripts/tests -v ``` @@ -111,3 +113,9 @@ python3 -m unittest discover -s scripts/tests -v 发布后检查固定地址可匿名访问、图标和截图能正常加载,并下载源中 IPA 再核对大小、摘要和版本。客户端的添加源、安装、更新与签名流程需分别实测;源文件校验通过不代表已完成真机安装验证。 格式说明:[AltStore 源规范](https://faq.altstore.io/developers/make-a-source)、[SideStore 源字段](https://sidestore.io/sidestore-source-types/interfaces/Source.html)、[LiveContainer 源用法](https://livecontainer.github.io/docs/guides/sources)。 + +## EPUB 回归 + +`swift test --disable-sandbox --filter EPUB` 覆盖 EPUB 2/3 元数据、中文资源路径、同页锚点、无目录降级、书脊顺序、插图、损坏与加密文件、重命名导出、导入失败回滚和阅读进度恢复。样本均由测试自行生成,不依赖网络或第三方书籍。 + +解析器按需读取 ZIP 资源,不解压到磁盘;单资源上限为 32 MiB,正文阅读不执行脚本或加载外部资源。字体混淆不影响文字阅读,加密正文会显示明确错误。原书 CSS、固定版式和交互内容不在当前重排阅读模式范围内。 diff --git a/scripts/fixtures/SourceImportFixture.swift b/scripts/fixtures/SourceImportFixture.swift index e5aac87..6c496bb 100644 --- a/scripts/fixtures/SourceImportFixture.swift +++ b/scripts/fixtures/SourceImportFixture.swift @@ -23,6 +23,10 @@ final class FixtureAppDelegate: UIResponder, UIApplicationDelegate { try? Data("第一章 开始\n这是用于验证本地 TXT 导入的小说正文。\n第二章 后续\n正文继续。".utf8) .write(to: documents.appendingPathComponent(name), options: .atomic) } + if let epub = Bundle.main.url(forResource: "EPUB-Book", withExtension: "epub"), + let data = try? Data(contentsOf: epub) { + try? data.write(to: documents.appendingPathComponent("EPUB-Book.epub"), options: .atomic) + } let controller = UIViewController() controller.view.backgroundColor = .systemBackground let label = UILabel() diff --git a/scripts/fixtures/make-epub.py b/scripts/fixtures/make-epub.py new file mode 100644 index 0000000..69e287b --- /dev/null +++ b/scripts/fixtures/make-epub.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Generate an original EPUB 3 fixture for Files import and reader UI tests.""" +import base64 +import sys +import zipfile +from pathlib import Path + +output = Path(sys.argv[1]) +output.parent.mkdir(parents=True, exist_ok=True) +with zipfile.ZipFile(output, "w") as archive: + archive.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED) + files = { + "META-INF/container.xml": '', + "OPS/book.opf": ''' + legado-ui-epubEPUB 导入测试测试作者zh2026-10-23T00:00:00Z + + ''', + "OPS/nav.xhtml": '''目录''', + "OPS/chapter.xhtml": '''EPUB 正文

第一章 EPUB

这是 EPUB 导入后的第一章正文。

测试插图

第二章 EPUB

这是 EPUB 的第二章正文,目录锚点跳转成功。

''', + } + for name, contents in files.items(): + archive.writestr(name, contents.encode("utf-8"), compress_type=zipfile.ZIP_DEFLATED) + archive.writestr("OPS/cover.png", base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABgCAIAAAAip+O/AAABxklEQVR4nNXCA3YjAABF0becsW12bNdIGptt7Nq2bXeMjc06/j2XI8UG6RwtMUrnWGmtdI6XmaRzotwsnZMVFumcqrRK53SVTTpnqu3SOVvjkM45g1M6540u6VyodUvnoskjnUtmr3QuW3zSuWL1S+eqLSCda/agdK47QtK54QxL56arTjq33PXSue2JSOeONyqdu76YdO7549K5H0hI50EwKZ2HoZR0isJp6Tyqy0jncX1WOk8iOek8jeal8yxWkM7zeIN0XiQapfMy2SSdV6lm6bxOt0jnTaZVOm+zbdJ5l2uXzvt8h3Q+FDql87GhSzqfGrul87mpRzpfmnulU9zSJ52S1n7plLYNSKesfVA65R1D0qnoHJZOZdeIdKq6R6VT3TMmnZrecekY+iakY+yflE7twJR0TIPT0jEPzUjHMjwrHevInHRso/PSsY8tSMcxvigd58SSdFyTy9JxT61IxzO9Kh3vzJp0fLPr0vHPbUgnML8pneDClnRCi9vSCS/tSKdueVc69St70oms7ksnunYgndj6oXTiG1+lk9j8Jp3k1nfppLZ/SCe981M6md1f0snu/ZZObv+PdPIHf6VTOPwn/T/zbe3/96qaXAAAAABJRU5ErkJggg==")) diff --git a/scripts/fixtures/rss-flow-server.py b/scripts/fixtures/rss-flow-server.py new file mode 100644 index 0000000..68d269b --- /dev/null +++ b/scripts/fixtures/rss-flow-server.py @@ -0,0 +1,44 @@ +"""Local, synthetic RSS responses for the LegadoRSSFlowUITests scheme.""" + +import http.server +import json +import urllib.parse + +BASE_URL = "http://127.0.0.1:18763" + + +class Fixture(http.server.BaseHTTPRequestHandler): + def do_GET(self): + parsed = urllib.parse.urlsplit(self.path) + query = urllib.parse.parse_qs(parsed.query) + key = query.get("key", [""])[0] + page = query.get("page", ["1"])[0] + if parsed.path == "/source.json": + payload = { + "sourceUrl": BASE_URL, + "sourceName": "RSS 全流程测试", + "sortUrl": "全部::" + BASE_URL + "/feed?page={{page}}", + "searchUrl": BASE_URL + "/feed?key={{key}}&page={{page}}", + "ruleArticles": "$.items", + "ruleTitle": "$.title", + "ruleLink": "$.link", + "ruleDescription": "$.description", + "ruleNextPage": "PAGE", + } + else: + title = f"搜索结果 {key} 第{page}页" if key else "首页文章" + payload = {"items": [{ + "title": title, + "link": BASE_URL + "/article/" + key + "/" + page, + "description": "

这是用于全流程测试的自造订阅正文。

", + }]} + body = json.dumps(payload, ensure_ascii=False).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +if __name__ == "__main__": + http.server.ThreadingHTTPServer(("127.0.0.1", 18763), Fixture).serve_forever() diff --git a/scripts/prepare-source-import-fixture.sh b/scripts/prepare-source-import-fixture.sh index ef3989d..be4069e 100644 --- a/scripts/prepare-source-import-fixture.sh +++ b/scripts/prepare-source-import-fixture.sh @@ -40,6 +40,7 @@ xcrun --sdk iphonesimulator swiftc -parse-as-library -swift-version 6 \ -module-cache-path "$legado_fixture_repo/DerivedData/ModuleCache.noindex" \ "$legado_fixture_repo/scripts/fixtures/SourceImportFixture.swift" \ -o "$legado_fixture_app/SourceImportFixtures" +python3 "$legado_fixture_repo/scripts/fixtures/make-epub.py" "$legado_fixture_app/EPUB-Book.epub" xcrun simctl install "$legado_fixture_device" "$legado_fixture_app" xcrun simctl launch --terminate-running-process "$legado_fixture_device" com.epiboly.legado.source-import-fixtures -printf 'JSON and TXT import fixtures are ready in Files > On My iPhone > 书源导入测试.\n' +printf 'JSON, TXT and EPUB import fixtures are ready in Files > On My iPhone > 书源导入测试.\n'