feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性

支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。
修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。
更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
Aminark
2026-10-24 00:58:11 +08:00
parent 3322fd71db
commit 67bc212ffd
58 changed files with 2331 additions and 244 deletions
+11 -10
View File
@@ -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<Bool>,
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(
+13 -4
View File
@@ -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)
@@ -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 {
+1 -1
View File
@@ -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 ?? "无法写入数据库")
+6 -2
View File
@@ -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 {
+1 -1
View File
@@ -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)")
}
}
+69 -31
View File
@@ -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<LegadoDatabase, Error>
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 } }
}
}
+34 -5
View File
@@ -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 ?? "") }
}
@@ -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<UIImage>.none }
return UIImage(cgImage: thumbnail)
}.value
}
}
}
+20 -11
View File
@@ -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]? {
+12 -1
View File
@@ -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 直接读取原文件。本章编辑与网络刷新仅对网络书籍开放。")
}
}
@@ -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
@@ -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("作用范围") {
+2 -1
View File
@@ -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()
+1
View File
@@ -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)
}
+18
View File
@@ -22,6 +22,24 @@
<string>public.data</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key><string>EPUB 电子书</string>
<key>CFBundleTypeRole</key><string>Viewer</string>
<key>LSHandlerRank</key><string>Alternate</string>
<key>LSItemContentTypes</key><array><string>org.idpf.epub-container</string></array>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key><string>org.idpf.epub-container</string>
<key>UTTypeDescription</key><string>EPUB 电子书</string>
<key>UTTypeConformsTo</key><array><string>public.data</string></array>
<key>UTTypeTagSpecification</key><dict>
<key>public.filename-extension</key><array><string>epub</string></array>
<key>public.mime-type</key><string>application/epub+zip</string>
</dict>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
+10
View File
@@ -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()
+21
View File
@@ -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.
+4 -2
View File
@@ -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) {