Keep only the evaluateFullPageMapTakeover: fullReplace diagnostic log in RDEPUBPageMapReconciliationCoordinator.swift for future debugging. Simplified RDEPUBBackgroundTrace to just the log method (removed measure and debug gating). Removed all [EPUB][...], [ReadViewDemo], and [EPUB][Pagination] print statements across the project. Also includes earlier bug fixes: - Fix stale currentPageNumber in extendPartial commit - Fix pageCurl rebindVisiblePage during transition - Fix keepCurrentWindow not removing pending update from queue - Fix right-aligned text (巫鸿 bug) via avoidPageBreakInside, tail merger, and continuation paragraph normalization - Add text-indent reset for aligned blocks in CSS compatibility layer Co-Authored-By: Claude <noreply@anthropic.com>
518 lines
22 KiB
Swift
518 lines
22 KiB
Swift
import Foundation
|
|
|
|
final class RDEPUBMetadataParseWorker {
|
|
|
|
private static let maxRetryCount = 3
|
|
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
|
|
|
|
private final class ParseState {
|
|
|
|
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
|
|
|
|
var totalResolvedCount: Int
|
|
|
|
var lastAppliedCount: Int
|
|
|
|
init(
|
|
summariesBySpineIndex: [Int: RDEPUBChapterSummary],
|
|
totalResolvedCount: Int,
|
|
lastAppliedCount: Int
|
|
) {
|
|
self.summariesBySpineIndex = summariesBySpineIndex
|
|
self.totalResolvedCount = totalResolvedCount
|
|
self.lastAppliedCount = lastAppliedCount
|
|
}
|
|
}
|
|
|
|
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
|
|
|
unowned let context: RDEPUBReaderContext
|
|
|
|
let cancellationController: RDEPUBMetadataParseCancellationController
|
|
|
|
let pageMapRefreshInterval: Int
|
|
|
|
private let token: UUID
|
|
|
|
private let parser: RDEPUBParser
|
|
|
|
private let publication: RDEPUBPublication
|
|
|
|
private let pageSize: CGSize
|
|
|
|
private let layoutConfig: RDEPUBTextLayoutConfig
|
|
|
|
private let style: RDEPUBTextRenderStyle
|
|
|
|
private let renderSignature: String
|
|
|
|
private let allBuildableIndices: [Int]
|
|
|
|
private let summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
|
|
|
private let workerCount: Int
|
|
|
|
private let contentHashBySpineIndex: [Int: String]
|
|
|
|
private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
|
|
|
|
init(
|
|
context: RDEPUBReaderContext,
|
|
cancellationController: RDEPUBMetadataParseCancellationController,
|
|
token: UUID,
|
|
parser: RDEPUBParser,
|
|
publication: RDEPUBPublication
|
|
) {
|
|
self.context = context
|
|
self.cancellationController = cancellationController
|
|
self.token = token
|
|
self.parser = parser
|
|
self.publication = publication
|
|
|
|
let pageSize = context.currentTextPageSize()
|
|
self.pageSize = pageSize
|
|
self.layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
|
self.style = context.currentTextRenderStyle()
|
|
self.renderSignature = context.currentRenderSignature()
|
|
self.allBuildableIndices = publication.spine.indices.filter { index in
|
|
guard publication.spine.indices.contains(index) else { return false }
|
|
let item = publication.spine[index]
|
|
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
|
}
|
|
self.summaryDiskCache = context.runtime?.summaryDiskCache
|
|
self.workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
|
self.pageMapRefreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
|
|
|
|
var hashes: [Int: String] = [:]
|
|
for spineIndex in allBuildableIndices {
|
|
guard let href = publication.spine.indices.contains(spineIndex)
|
|
? publication.spine[spineIndex].href : nil,
|
|
let html = parser.htmlString(forRelativePath: href) else {
|
|
hashes[spineIndex] = ""
|
|
continue
|
|
}
|
|
hashes[spineIndex] = html.rd_sha256Hex
|
|
}
|
|
self.contentHashBySpineIndex = hashes
|
|
|
|
let ctx = context
|
|
let spine = publication.spine
|
|
let sig = renderSignature
|
|
self.catalog = allBuildableIndices.map { spineIndex in
|
|
let item = spine[spineIndex]
|
|
return (
|
|
key: ctx.chapterCacheKey(
|
|
forSpineIndex: spineIndex,
|
|
precomputedContentHash: hashes[spineIndex] ?? "",
|
|
renderSignature: sig
|
|
),
|
|
spineIndex: spineIndex,
|
|
href: item.href,
|
|
title: item.title
|
|
)
|
|
}
|
|
}
|
|
|
|
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
|
|
let context = self.context
|
|
let cancellationController = self.cancellationController
|
|
|
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
guard let self else { return }
|
|
defer { self.context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
|
|
guard context.controller != nil,
|
|
!cancellationController.isCancelled,
|
|
context.paginationToken == token else { return }
|
|
|
|
if let restoredPageMap = self.restoreBookPageMapIfPossible() {
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil,
|
|
!cancellationController.isCancelled else { return }
|
|
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
|
|
}
|
|
return
|
|
}
|
|
|
|
let prewarmStart = CFAbsoluteTimeGetCurrent()
|
|
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
|
|
|
|
let restored = self.summaryDiskCache?.readAll(keys: self.catalog)
|
|
let cachedSummaries = restored?.summaries ?? [:]
|
|
let cachedSpineIndices = Set(cachedSummaries.keys)
|
|
let resultLock = NSLock()
|
|
let parseState = ParseState(
|
|
summariesBySpineIndex: cachedSummaries,
|
|
totalResolvedCount: cachedSpineIndices.count,
|
|
lastAppliedCount: cachedSpineIndices.count
|
|
)
|
|
|
|
if !cachedSpineIndices.isEmpty {
|
|
let cachedMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil else { return }
|
|
context.runtime?.refreshBookPageMapInPlace(cachedMap)
|
|
}
|
|
}
|
|
|
|
let prioritizedSpineIndices: [Int]
|
|
if let priorityManager = context.runtime?.backgroundPriorityManager {
|
|
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
|
|
.flatMap { context.normalizedSpineIndex(for: $0) }
|
|
prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder(
|
|
allBuildableIndices: self.allBuildableIndices,
|
|
currentSpineIndex: currentSpineIndex,
|
|
cachedSpineIndices: cachedSpineIndices
|
|
)
|
|
} else {
|
|
prioritizedSpineIndices = self.allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
|
}
|
|
|
|
let uncachedSpineIndices = prioritizedSpineIndices
|
|
|
|
self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
|
|
guard !cancellationController.isCancelled,
|
|
context.controller != nil,
|
|
context.paginationToken == token else {
|
|
return
|
|
}
|
|
|
|
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
|
var totalRenderMs: Double = 0
|
|
var totalWriteMs: Double = 0
|
|
var totalMergeMs: Double = 0
|
|
var completedChapters = 0
|
|
var failedChapters = 0
|
|
let timingLock = NSLock()
|
|
|
|
let queue = OperationQueue()
|
|
queue.name = "com.rdreader.metadata.parse"
|
|
queue.qualityOfService = .utility
|
|
queue.maxConcurrentOperationCount = self.workerCount
|
|
cancellationController.attach(queue: queue)
|
|
|
|
let refreshInterval = self.pageMapRefreshInterval
|
|
|
|
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
|
|
let operation = BlockOperation()
|
|
operation.addExecutionBlock { [weak operation] in
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled,
|
|
operation?.isCancelled != true else {
|
|
return
|
|
}
|
|
do {
|
|
|
|
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled,
|
|
operation?.isCancelled != true else {
|
|
return nil
|
|
}
|
|
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig)
|
|
let renderStart = CFAbsoluteTimeGetCurrent()
|
|
guard let result = try chapterBuilder.buildChapter(
|
|
parser: self.parser,
|
|
publication: self.publication,
|
|
spineIndex: spineIndex,
|
|
pageSize: self.pageSize,
|
|
style: self.style
|
|
) else {
|
|
return nil
|
|
}
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled,
|
|
operation?.isCancelled != true else {
|
|
return nil
|
|
}
|
|
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
|
|
|
|
let chapter = result.chapter
|
|
let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? ""
|
|
let cacheKey = context.chapterCacheKey(
|
|
forSpineIndex: spineIndex,
|
|
precomputedContentHash: precomputedHash,
|
|
renderSignature: self.renderSignature
|
|
)
|
|
let summary = RDEPUBChapterSummary(
|
|
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
|
pageCount: chapter.pages.count,
|
|
fragmentOffsets: chapter.fragmentOffsets,
|
|
cfiMap: chapter.cfiMap,
|
|
renderSignature: cacheKey.renderSignature,
|
|
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
|
chapterContentHash: cacheKey.chapterContentHash,
|
|
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
|
)
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled,
|
|
operation?.isCancelled != true else {
|
|
return nil
|
|
}
|
|
let writeStart = CFAbsoluteTimeGetCurrent()
|
|
self.summaryDiskCache?.write(summary: summary, for: cacheKey)
|
|
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
|
|
|
|
timingLock.lock()
|
|
totalRenderMs += renderElapsed
|
|
totalWriteMs += writeElapsed
|
|
completedChapters += 1
|
|
timingLock.unlock()
|
|
|
|
return summary
|
|
}
|
|
|
|
guard let renderResult else { return }
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled,
|
|
operation?.isCancelled != true else {
|
|
return
|
|
}
|
|
|
|
var snapshot: [Int: RDEPUBChapterSummary]?
|
|
resultLock.lock()
|
|
parseState.summariesBySpineIndex[spineIndex] = renderResult
|
|
parseState.totalResolvedCount += 1
|
|
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
|
|| parseState.totalResolvedCount == self.allBuildableIndices.count {
|
|
parseState.lastAppliedCount = parseState.totalResolvedCount
|
|
snapshot = parseState.summariesBySpineIndex
|
|
}
|
|
resultLock.unlock()
|
|
|
|
if let snapshot {
|
|
let mergeStart = CFAbsoluteTimeGetCurrent()
|
|
let partialMap = self.buildPageMap(summaries: snapshot)
|
|
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
|
|
timingLock.lock()
|
|
totalMergeMs += mergeElapsed
|
|
timingLock.unlock()
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil,
|
|
!cancellationController.isCancelled else { return }
|
|
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
|
}
|
|
}
|
|
} catch {
|
|
guard !cancellationController.isCancelled,
|
|
context.paginationToken == token,
|
|
context.controller != nil,
|
|
operation?.isCancelled != true else {
|
|
return
|
|
}
|
|
timingLock.lock()
|
|
failedChapters += 1
|
|
timingLock.unlock()
|
|
|
|
self.scheduleRetry(
|
|
spineIndex: spineIndex,
|
|
retryCount: 0,
|
|
resultLock: resultLock,
|
|
parseState: parseState,
|
|
cancellationController: cancellationController
|
|
)
|
|
}
|
|
}
|
|
queue.addOperation(operation)
|
|
}
|
|
queue.waitUntilAllOperationsAreFinished()
|
|
if !cancellationController.isCancelled,
|
|
context.paginationToken == token,
|
|
context.controller != nil {
|
|
self.summaryDiskCache?.flushPendingWrites()
|
|
}
|
|
|
|
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
|
|
timingLock.lock()
|
|
let renderTotal = Int(totalRenderMs)
|
|
let writeTotal = Int(totalWriteMs)
|
|
let mergeTotal = Int(totalMergeMs)
|
|
let rendered = completedChapters
|
|
let failed = failedChapters
|
|
timingLock.unlock()
|
|
let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0
|
|
context.lastMetadataParseWallClockMs = wallClockMs
|
|
context.lastMetadataParseConcurrency = self.workerCount
|
|
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled else {
|
|
return
|
|
}
|
|
|
|
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
|
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
|
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
|
|
|
if let coverageStore = context.runtime?.backgroundCoverageStore {
|
|
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
|
|
let lowerSpine = resolvedSpineIndices.min() ?? 0
|
|
let upperSpine = resolvedSpineIndices.max() ?? 0
|
|
let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16
|
|
|
|
let segment = RDEPUBBackgroundCoverageSegment(
|
|
lowerSpineIndex: lowerSpine,
|
|
upperSpineIndex: upperSpine,
|
|
pageMap: pageMap,
|
|
resolvedSpineIndices: resolvedSpineIndices,
|
|
generatedAt: CFAbsoluteTimeGetCurrent(),
|
|
renderSignature: self.renderSignature,
|
|
estimatedMemoryBytes: estimatedBytes
|
|
)
|
|
coverageStore.addSegment(segment)
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil else { return }
|
|
context.runtime?.refreshBookPageMapInPlace(pageMap)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func waitForReadingInteractionToSettle(
|
|
cancellationController: RDEPUBMetadataParseCancellationController? = nil
|
|
) {
|
|
while context.controller != nil,
|
|
cancellationController?.isCancelled != true,
|
|
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
|
Thread.sleep(forTimeInterval: 0.08)
|
|
}
|
|
}
|
|
|
|
private func scheduleRetry(
|
|
spineIndex: Int,
|
|
retryCount: Int,
|
|
resultLock: NSLock,
|
|
parseState: ParseState,
|
|
cancellationController: RDEPUBMetadataParseCancellationController
|
|
) {
|
|
guard retryCount < Self.maxRetryCount else {
|
|
return
|
|
}
|
|
|
|
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
|
|
|
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
guard let self else { return }
|
|
let context = self.context
|
|
guard context.controller != nil,
|
|
context.paginationToken == self.token,
|
|
!cancellationController.isCancelled else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig)
|
|
guard let result = try chapterBuilder.buildChapter(
|
|
parser: self.parser,
|
|
publication: self.publication,
|
|
spineIndex: spineIndex,
|
|
pageSize: self.pageSize,
|
|
style: self.style
|
|
) else {
|
|
return
|
|
}
|
|
guard context.controller != nil,
|
|
context.paginationToken == self.token,
|
|
!cancellationController.isCancelled else {
|
|
return
|
|
}
|
|
|
|
let chapter = result.chapter
|
|
let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? ""
|
|
let cacheKey = context.chapterCacheKey(
|
|
forSpineIndex: spineIndex,
|
|
precomputedContentHash: precomputedHash,
|
|
renderSignature: self.renderSignature
|
|
)
|
|
let summary = RDEPUBChapterSummary(
|
|
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
|
pageCount: chapter.pages.count,
|
|
fragmentOffsets: chapter.fragmentOffsets,
|
|
cfiMap: chapter.cfiMap,
|
|
renderSignature: cacheKey.renderSignature,
|
|
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
|
chapterContentHash: cacheKey.chapterContentHash,
|
|
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
|
)
|
|
guard context.controller != nil,
|
|
context.paginationToken == self.token,
|
|
!cancellationController.isCancelled else {
|
|
return
|
|
}
|
|
self.summaryDiskCache?.write(summary: summary, for: cacheKey)
|
|
|
|
resultLock.lock()
|
|
parseState.summariesBySpineIndex[spineIndex] = summary
|
|
parseState.totalResolvedCount += 1
|
|
let shouldRefresh =
|
|
parseState.totalResolvedCount - parseState.lastAppliedCount >= self.pageMapRefreshInterval
|
|
|| parseState.totalResolvedCount == self.allBuildableIndices.count
|
|
if shouldRefresh {
|
|
parseState.lastAppliedCount = parseState.totalResolvedCount
|
|
}
|
|
resultLock.unlock()
|
|
|
|
if shouldRefresh {
|
|
let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == self.token,
|
|
context.controller != nil,
|
|
!cancellationController.isCancelled else { return }
|
|
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
|
}
|
|
}
|
|
|
|
} catch {
|
|
|
|
self.scheduleRetry(
|
|
spineIndex: spineIndex,
|
|
retryCount: retryCount + 1,
|
|
resultLock: resultLock,
|
|
parseState: parseState,
|
|
cancellationController: cancellationController
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
|
|
guard let summaryDiskCache else { return nil }
|
|
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
|
|
return nil
|
|
}
|
|
let restored = summaryDiskCache.readAll(keys: catalog)
|
|
guard restored.summaries.count == catalog.count else {
|
|
return nil
|
|
}
|
|
return restored.mapBuilder.build()
|
|
}
|
|
|
|
private func buildPageMap(
|
|
summaries: [Int: RDEPUBChapterSummary]
|
|
) -> RDEPUBBookPageMap {
|
|
var builder = RDEPUBBookPageMap.Builder()
|
|
for item in catalog {
|
|
guard let summary = summaries[item.spineIndex] else { continue }
|
|
builder.add(
|
|
spineIndex: item.spineIndex,
|
|
href: item.href,
|
|
title: item.title,
|
|
pageCount: summary.pageCount,
|
|
fragmentOffsets: summary.fragmentOffsets
|
|
)
|
|
}
|
|
return builder.build()
|
|
}
|
|
}
|