mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 21:01:22 +08:00
feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性
支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。 修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。 更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]? {
|
||||
|
||||
@@ -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 直接读取原文件。本章编辑与网络刷新仅对网络书籍开放。")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user