1. Configurable chapter window size (onDemandChapterWindowSize: 3-15) - Parameterized window radius in RDEPUBChapterRuntimeStore - Updated RDEPUBChapterWindowCoordinator to use configurable radius - RDEPUBChapterWindowSnapshot.from() accepts chapter array instead of fixed prev/next - Even numbers round up to odd (4→5), min 3, max 15 2. Configurable metadata parsing concurrency (metadataParsingConcurrency) - Default equals CPU core count - Parallel execution via OperationQueue in paginateMetadataOnly - Each worker creates independent builder instance - NSLock protects result aggregation 3. Per-chapter and total wall-clock timing instrumentation - Separated render vs I/O timing per chapter - Summary log with wallClockMs, renderTotalMs, writeTotalMs, avgRenderMs - Timing stored in RDEPUBReaderContext for test access 4. UI automation test infrastructure - Added --demo-window-size, --demo-concurrency, --demo-clear-cache launch args - DemoReaderState exposes windowSize, parseMs, parseConcurrency - ConfigurableWindowTests: 5 test cases for window size 3/5/15 - ConcurrentParsingTests: 4 test cases for concurrency 2/4 - MetadataParseBenchmarkTests: serial vs parallel benchmark 5. Bug fixes - Fixed page snap-back during background parsing (isUserInteracting check) - Reduced BookPageMap refresh frequency from 16 to 32 chapters - Moved waitForReadingInteractionToSettle outside operation loop 6. Design doc: dual-layer PageMap (estimated + precise mixed)
558 lines
20 KiB
Swift
558 lines
20 KiB
Swift
import UIKit
|
||
|
||
/// EPUB 阅读器运行时总协调器。
|
||
/// 统一持有并分发给加载、分页、定位、搜索、工具栏、批注、视口监测等子协调器,
|
||
/// 作为阅读器控制器的门面(Facade),简化外部调用。
|
||
final class RDEPUBReaderRuntime {
|
||
private unowned let context: RDEPUBReaderContext
|
||
|
||
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
|
||
lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
|
||
lazy var chapterLoader: RDEPUBChapterLoader = {
|
||
let loader = RDEPUBChapterLoader(context: context)
|
||
loader.setSummaryDiskCache(summaryDiskCache)
|
||
return loader
|
||
}()
|
||
lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
|
||
|
||
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
|
||
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
|
||
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
|
||
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
|
||
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
|
||
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
|
||
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
|
||
|
||
init(context: RDEPUBReaderContext) {
|
||
self.context = context
|
||
}
|
||
|
||
/// 创建顶部工具栏视图
|
||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||
chromeCoordinator.makeTopToolView()
|
||
}
|
||
|
||
/// 创建底部工具栏视图
|
||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||
chromeCoordinator.makeBottomToolView()
|
||
}
|
||
|
||
/// 若尚未加载则启动首次加载流程
|
||
func startInitialLoadIfNeeded() {
|
||
loadCoordinator.startInitialLoadIfNeeded()
|
||
}
|
||
|
||
/// 重新加载当前书籍,清空解析器、分页、批注等状态后从头初始化
|
||
func reloadBook() {
|
||
guard let readerView = context.readerView else { return }
|
||
context.didStartInitialLoad = false
|
||
context.parser = nil
|
||
context.publication = nil
|
||
context.clearActiveSnapshot()
|
||
context.readingSession = nil
|
||
context.textBook = nil
|
||
context.bookPageMap = nil
|
||
context.activeBookmarks = []
|
||
context.activeHighlights = []
|
||
context.searchState = nil
|
||
clearOnDemandPageModeState()
|
||
viewportMonitor.resetForReload()
|
||
annotationCoordinator.updateCurrentSelection(nil)
|
||
readerView.reloadData()
|
||
startInitialLoadIfNeeded()
|
||
}
|
||
|
||
/// 跳转到指定阅读位置
|
||
/// - Parameters:
|
||
/// - location: 目标位置
|
||
/// - animated: 是否动画过渡
|
||
/// - Returns: 跳转是否成功
|
||
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||
}
|
||
|
||
/// 跳转到指定页码
|
||
/// - Parameters:
|
||
/// - pageNumber: 目标页码(从 1 开始)
|
||
/// - animated: 是否动画过渡
|
||
/// - Returns: 跳转是否成功
|
||
@discardableResult
|
||
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||
guard let controller = context.controller,
|
||
let readerView = context.readerView,
|
||
pageNumber > 0 else {
|
||
return false
|
||
}
|
||
|
||
if let textBook = context.textBook {
|
||
guard textBook.page(at: pageNumber) != nil else {
|
||
return false
|
||
}
|
||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||
if let location = locationCoordinator.currentVisibleLocation() {
|
||
context.persist(location: location)
|
||
}
|
||
return true
|
||
}
|
||
|
||
if context.bookPageMap != nil {
|
||
guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else {
|
||
return false
|
||
}
|
||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||
if let location = locationCoordinator.currentVisibleLocation() {
|
||
context.persist(location: location)
|
||
}
|
||
return true
|
||
}
|
||
|
||
guard context.activePages.indices.contains(pageNumber - 1) else {
|
||
return false
|
||
}
|
||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||
if let location = locationCoordinator.currentVisibleLocation() {
|
||
context.persist(location: location)
|
||
}
|
||
return true
|
||
}
|
||
|
||
/// 清除当前选区
|
||
func clearSelection() {
|
||
annotationCoordinator.updateCurrentSelection(nil)
|
||
}
|
||
|
||
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||
annotationCoordinator.bookmark(withID: id)
|
||
}
|
||
|
||
func highlight(withID id: String) -> RDEPUBHighlight? {
|
||
annotationCoordinator.highlight(withID: id)
|
||
}
|
||
|
||
@discardableResult
|
||
func addHighlight(
|
||
from selection: RDEPUBSelection? = nil,
|
||
color: String = "#F8E16C",
|
||
note: String? = nil
|
||
) -> RDEPUBHighlight? {
|
||
annotationCoordinator.addHighlight(from: selection, color: color, note: note)
|
||
}
|
||
|
||
@discardableResult
|
||
func addAnnotation(
|
||
from selection: RDEPUBSelection? = nil,
|
||
style: RDEPUBHighlightStyle,
|
||
color: String = "#F8E16C",
|
||
note: String? = nil
|
||
) -> RDEPUBHighlight? {
|
||
annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note)
|
||
}
|
||
|
||
@discardableResult
|
||
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||
annotationCoordinator.upsertHighlight(highlight)
|
||
}
|
||
|
||
@discardableResult
|
||
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||
annotationCoordinator.removeHighlight(id: id)
|
||
}
|
||
|
||
@discardableResult
|
||
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||
annotationCoordinator.updateHighlightNote(id: id, note: note)
|
||
}
|
||
|
||
@discardableResult
|
||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||
annotationCoordinator.go(toHighlightID: id, animated: animated)
|
||
}
|
||
|
||
func removeAllHighlights() {
|
||
annotationCoordinator.removeAllHighlights()
|
||
}
|
||
|
||
@discardableResult
|
||
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||
annotationCoordinator.addBookmark(note: note)
|
||
}
|
||
|
||
@discardableResult
|
||
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||
annotationCoordinator.toggleBookmark(note: note)
|
||
}
|
||
|
||
@discardableResult
|
||
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||
annotationCoordinator.removeBookmark(id: id)
|
||
}
|
||
|
||
@discardableResult
|
||
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||
annotationCoordinator.go(toBookmarkID: id, animated: animated)
|
||
}
|
||
|
||
func updateBookmarkChrome() {
|
||
annotationCoordinator.updateBookmarkChrome()
|
||
}
|
||
|
||
func presentBookmarksManager() {
|
||
annotationCoordinator.presentBookmarksManager()
|
||
}
|
||
|
||
func presentHighlightsManager() {
|
||
annotationCoordinator.presentHighlightsManager()
|
||
}
|
||
|
||
func presentAnnotationCreation() {
|
||
annotationCoordinator.presentAnnotationCreation()
|
||
}
|
||
|
||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
||
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
|
||
}
|
||
|
||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
|
||
}
|
||
|
||
/// 按关键词搜索全文
|
||
func search(keyword: String) {
|
||
searchCoordinator.search(keyword: keyword)
|
||
}
|
||
|
||
/// 跳转到下一个搜索匹配项
|
||
@discardableResult
|
||
func searchNext() -> Bool {
|
||
searchCoordinator.searchNext()
|
||
}
|
||
|
||
/// 跳转到上一个搜索匹配项
|
||
@discardableResult
|
||
func searchPrevious() -> Bool {
|
||
searchCoordinator.searchPrevious()
|
||
}
|
||
|
||
/// 清除搜索状态
|
||
func clearSearch() {
|
||
searchCoordinator.clearSearch()
|
||
}
|
||
|
||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||
searchCoordinator.searchPresentation(for: page)
|
||
}
|
||
|
||
/// 更新阅读器工具栏显示状态
|
||
func updateReaderChrome() {
|
||
chromeCoordinator.updateReaderChrome()
|
||
}
|
||
|
||
/// 弹出阅读设置面板
|
||
func presentSettings() {
|
||
chromeCoordinator.presentSettings()
|
||
}
|
||
|
||
/// 弹出目录面板
|
||
func presentTableOfContents() {
|
||
chromeCoordinator.presentTableOfContents()
|
||
}
|
||
|
||
/// 处理返回操作
|
||
func handleBackAction() {
|
||
chromeCoordinator.handleBackAction()
|
||
}
|
||
|
||
/// 启动 Publication 加载流程
|
||
func loadPublication() {
|
||
loadCoordinator.loadPublication()
|
||
}
|
||
|
||
/// 将已解析的 Publication 应用到控制器
|
||
func applyParsedPublication(
|
||
parser: RDEPUBParser,
|
||
publication: RDEPUBPublication,
|
||
bookIdentifier: String,
|
||
restoreLocation: RDEPUBLocation?,
|
||
bookmarks: [RDEPUBBookmark],
|
||
highlights: [RDEPUBHighlight]
|
||
) {
|
||
loadCoordinator.applyParsedPublication(
|
||
parser: parser,
|
||
publication: publication,
|
||
bookIdentifier: bookIdentifier,
|
||
restoreLocation: restoreLocation,
|
||
bookmarks: bookmarks,
|
||
highlights: highlights
|
||
)
|
||
}
|
||
|
||
/// 对 Publication 执行分页计算
|
||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
|
||
}
|
||
|
||
/// 应用外部 TextBook 并恢复阅读位置
|
||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||
}
|
||
|
||
/// 应用分页快照并恢复阅读位置
|
||
func applyPaginationSnapshot(
|
||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||
restoreLocation: RDEPUBLocation?
|
||
) {
|
||
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||
}
|
||
|
||
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
|
||
context.textBook = nil
|
||
context.bookPageMap = bookPageMap
|
||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||
}
|
||
|
||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||
guard let readerView = context.readerView,
|
||
let controller = context.controller else { return }
|
||
|
||
let currentPage = max(readerView.currentPage, 0)
|
||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||
context.textBook = nil
|
||
context.bookPageMap = bookPageMap
|
||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||
|
||
// 仅刷新总页数,不重建页面内容(避免后台元数据解析期间刷新掉用户选区)
|
||
readerView.reloadPageCountOnly()
|
||
|
||
// 用户正在选区或正在滑动翻页时跳过页面跳转,避免打断交互
|
||
let cv = readerView.collectionView
|
||
let isUserInteracting = cv.isTracking || cv.isDragging || cv.isDecelerating
|
||
if context.currentSelection == nil, !isUserInteracting, bookPageMap.totalPages > 0 {
|
||
let maxValidPage = max(bookPageMap.totalPages - 1, 0)
|
||
if currentPage > maxValidPage {
|
||
readerView.transitionToPage(pageNum: maxValidPage, animated: false)
|
||
}
|
||
}
|
||
if let currentLocation {
|
||
locationCoordinator.persist(location: currentLocation)
|
||
} else if let resolvedLocation = controller.resolvedTextLocation(forPageNumber: currentPage + 1) {
|
||
locationCoordinator.persist(location: resolvedLocation)
|
||
}
|
||
}
|
||
|
||
/// 完成分页流程并恢复阅读位置
|
||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||
}
|
||
|
||
/// 重新分页并保持当前阅读位置不变
|
||
func repaginatePreservingCurrentLocation() {
|
||
paginationCoordinator.repaginatePreservingCurrentLocation()
|
||
}
|
||
|
||
/// 刷新当前可见内容,保持阅读位置不变
|
||
func refreshVisibleContentPreservingLocation() {
|
||
paginationCoordinator.refreshVisibleContentPreservingLocation()
|
||
}
|
||
|
||
/// 重建外部 TextBook 数据
|
||
func rebuildExternalTextBook() {
|
||
paginationCoordinator.rebuildExternalTextBook()
|
||
}
|
||
|
||
/// 恢复到指定阅读位置
|
||
@discardableResult
|
||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||
}
|
||
|
||
/// 获取当前可见页面的阅读位置
|
||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||
locationCoordinator.currentVisibleLocation()
|
||
}
|
||
|
||
/// 获取当前视口签名快照
|
||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||
viewportMonitor.currentViewportSignature()
|
||
}
|
||
|
||
/// 视口变化时检查是否需要重新分页
|
||
func handleViewportChangeIfNeeded(
|
||
reason: RDEPUBViewportChangeReason,
|
||
viewportSignature: RDEPUBViewportSignature? = nil
|
||
) {
|
||
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
|
||
}
|
||
|
||
@discardableResult
|
||
func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> Bool {
|
||
guard let bookPageMap = context.bookPageMap,
|
||
let publication = context.publication else {
|
||
return false
|
||
}
|
||
let absolutePageIndex = pageNumber - 1
|
||
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
|
||
return false
|
||
}
|
||
|
||
chapterRuntimeStore.setCurrentChapter(
|
||
spineIndex: spineIndex,
|
||
totalSpineCount: publication.spine.count,
|
||
windowRadius: context.configuration.chapterWindowRadius
|
||
)
|
||
RDEPUBBackgroundTrace.log(
|
||
"Runtime",
|
||
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
|
||
)
|
||
|
||
if chapterRuntimeStore.chapterData(for: spineIndex) == nil {
|
||
do {
|
||
_ = try chapterLoader.loadChapterSynchronouslyForMigration(
|
||
spineIndex: spineIndex,
|
||
store: chapterRuntimeStore
|
||
)
|
||
} catch {
|
||
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||
return false
|
||
}
|
||
}
|
||
|
||
for evictable in chapterRuntimeStore.evictableSpineIndices() {
|
||
chapterRuntimeStore.evict(spineIndex: evictable)
|
||
}
|
||
|
||
for adjacentSpineIndex in chapterRuntimeStore.windowSpineIndices where adjacentSpineIndex != spineIndex {
|
||
guard chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil else { continue }
|
||
chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex)
|
||
RDEPUBBackgroundTrace.log(
|
||
"Runtime",
|
||
"schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)"
|
||
)
|
||
chapterLoader.loadChapter(
|
||
spineIndex: adjacentSpineIndex,
|
||
store: chapterRuntimeStore,
|
||
priority: .prefetch
|
||
) { _ in }
|
||
}
|
||
return true
|
||
}
|
||
|
||
func extendPartialBookPageMapIfNeeded(currentPageNumber: Int, minimumTrailingPages: Int = 2, batchChapterCount: Int = 3) {
|
||
guard let publication = context.publication,
|
||
let currentMap = context.bookPageMap,
|
||
let readerView = context.readerView else {
|
||
return
|
||
}
|
||
|
||
let buildableSpineIndices = publication.spine.indices.filter {
|
||
let item = publication.spine[$0]
|
||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||
}
|
||
guard currentMap.totalChapters < buildableSpineIndices.count else {
|
||
return
|
||
}
|
||
guard currentMap.totalPages - currentPageNumber <= minimumTrailingPages else {
|
||
return
|
||
}
|
||
|
||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
|
||
let nextSpineIndices = buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount)
|
||
guard !nextSpineIndices.isEmpty else {
|
||
return
|
||
}
|
||
RDEPUBBackgroundTrace.log(
|
||
"Runtime",
|
||
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(Array(nextSpineIndices))"
|
||
)
|
||
|
||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||
for spineIndex in nextSpineIndices {
|
||
do {
|
||
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
|
||
spineIndex: spineIndex,
|
||
store: chapterRuntimeStore
|
||
)
|
||
appendedEntries.append(
|
||
RDEPUBBookPageMapEntry(
|
||
spineIndex: chapter.spineIndex,
|
||
href: chapter.href,
|
||
title: chapter.title,
|
||
pageCount: chapter.pages.count,
|
||
absolutePageStart: 0,
|
||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||
)
|
||
)
|
||
} catch {
|
||
RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)")
|
||
}
|
||
}
|
||
|
||
guard !appendedEntries.isEmpty else {
|
||
return
|
||
}
|
||
|
||
let combinedEntries = (currentMap.entries.map {
|
||
RDEPUBBookPageMapEntry(
|
||
spineIndex: $0.spineIndex,
|
||
href: $0.href,
|
||
title: $0.title,
|
||
pageCount: $0.pageCount,
|
||
absolutePageStart: 0,
|
||
fragmentOffsets: $0.fragmentOffsets
|
||
)
|
||
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
|
||
|
||
var absolutePageStart = 0
|
||
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
|
||
let normalized = RDEPUBBookPageMapEntry(
|
||
spineIndex: entry.spineIndex,
|
||
href: entry.href,
|
||
title: entry.title,
|
||
pageCount: entry.pageCount,
|
||
absolutePageStart: absolutePageStart,
|
||
fragmentOffsets: entry.fragmentOffsets
|
||
)
|
||
absolutePageStart += entry.pageCount
|
||
return normalized
|
||
}
|
||
|
||
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
|
||
RDEPUBBackgroundTrace.log(
|
||
"Runtime",
|
||
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||
)
|
||
context.bookPageMap = newMap
|
||
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
|
||
readerView.reloadData()
|
||
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
|
||
}
|
||
|
||
func clearOnDemandPageModeState() {
|
||
chapterRuntimeStore.invalidateAllForSettingsChange()
|
||
context.bookPageMap = nil
|
||
}
|
||
|
||
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||
let pages = bookPageMap.entries.flatMap { entry in
|
||
(0..<entry.pageCount).map { localPageIndex in
|
||
EPUBPage(
|
||
spineIndex: entry.spineIndex,
|
||
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
|
||
pageIndexInChapter: localPageIndex,
|
||
totalPagesInChapter: entry.pageCount,
|
||
chapterTitle: entry.title,
|
||
fixedSpread: nil
|
||
)
|
||
}
|
||
}
|
||
let chapters = bookPageMap.entries.map { entry in
|
||
EPUBChapterInfo(
|
||
spineIndex: entry.spineIndex,
|
||
title: entry.title,
|
||
pageCount: entry.pageCount
|
||
)
|
||
}
|
||
return (pages, chapters)
|
||
}
|
||
}
|