feat(reader): 实现大书快速进入与增量分页路径

- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建
  与 staged 合并策略,避免整书一次性构建的主线程阻塞
- TextBookBuilder: 重构为支持单章构建与增量合并模式
- ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存
- PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能
- ReaderContext/ReaderRuntime: 适配增量分页状态管理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-02 21:16:57 +08:00
co-authored by Claude Opus 4.7
parent 488350d956
commit c76beed03c
15 changed files with 984 additions and 268 deletions
@@ -94,173 +94,20 @@ public final class RDEPUBTextBookBuilder {
let cachedPagination = cacheCoordinator.load(key: cacheKey)
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
continue
}
//
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
let request = RDEPUBTextTypesetterPipeline().makeRequest(
from: RDEPUBTypesettingInput(
href: item.href,
title: chapterTitle,
rawHTML: rawHTML,
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
style: style,
resourceResolver: publication.resourceResolver,
contentLanguageCode: publication.metadata.language,
pageSize: pageSize,
layoutConfig: layoutConfig
)
).request
// HTML NSAttributedString
let renderStart = CFAbsoluteTimeGetCurrent()
let rendered = try renderPipeline.render(request)
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
}
// /
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] skipped href=\(item.href)")
}
continue
}
let chapterIndex = chapters.count
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
//
let paginateStart = CFAbsoluteTimeGetCurrent()
let layoutFrames: [RDEPUBTextLayoutFrame]
let isCacheHit: Bool
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
//
layoutFrames = [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: attachmentRanges(in: content),
attachmentKinds: [],
blockKinds: [],
semanticHints: [],
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"cover fallback: single attachment page",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
isCacheHit = false
} else if let cached = cachedPagination?[item.href] {
// 使 CoreText
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
return RDEPUBTextLayoutFrame(
contentRange: range,
breakReason: breakReason,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
blockKinds: [],
semanticHints: cached.semanticHints,
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"]
)
}
isCacheHit = true
} else {
// CoreText
layoutFrames = content.length > 0
? paginationPipeline.frames(
for: content,
pageSize: pageSize,
config: layoutConfig,
fragmentOffsets: rendered.fragmentOffsets
)
: []
isCacheHit = false
}
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
//
let normalizedFrames = tailNormalizer.normalize(
layoutFrames,
content: content,
href: item.href
)
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
blockKinds: [],
semanticHints: [],
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
: normalizedFrames
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
}
//
sampler.record(RDEPUBTextPerformanceSample(
chapterHref: item.href,
renderDuration: renderDuration,
paginateDuration: paginateDuration,
pageCount: effectiveFrames.count,
attributedStringLength: content.length,
cacheHit: isCacheHit
))
if isCacheHit {
lastBuildCacheStats.hits += 1
} else {
lastBuildCacheStats.misses += 1
}
//
let chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
return RDEPUBTextPage(
absolutePageIndex: flatPages.count + localPageIndex,
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
chapterTitle: chapterTitle,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectiveFrames.count,
chapterContent: chapterAttributedContent,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + max(range.length - 1, 0),
metadata: frame.metadata
)
}
guard let result = try buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style,
chapterIndex: chapters.count,
absolutePageStartIndex: flatPages.count,
cachedPagination: cachedPagination
) else { continue }
if isPaginationDebugEnabled,
item.href.contains("Chapter_3.xhtml") {
let pages = result.chapter.pages
print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
for page in pages {
let preview = debugPreview(for: page.content, limit: 36)
@@ -271,26 +118,16 @@ public final class RDEPUBTextBookBuilder {
}
}
chapters.append(
RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
title: chapterTitle,
attributedContent: chapterAttributedContent,
fragmentOffsets: rendered.fragmentOffsets,
pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages
)
)
lastBuildPaginationDiagnostics.append(
diagnosticsReporter.chapterDiagnostic(
href: item.href,
title: chapterTitle,
pages: pages
)
)
flatPages.append(contentsOf: pages)
chapters.append(result.chapter)
flatPages.append(contentsOf: result.chapter.pages)
lastBuildResourceDiagnostics.append(contentsOf: result.resourceDiagnostics)
lastBuildPaginationDiagnostics.append(result.paginationDiagnostic)
sampler.record(result.performanceSample)
if result.cacheHit {
lastBuildCacheStats.hits += 1
} else {
lastBuildCacheStats.misses += 1
}
}
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
@@ -306,6 +143,220 @@ public final class RDEPUBTextBookBuilder {
return book
}
/// spine
public func buildChapter(
parser: RDEPUBParser,
publication: RDEPUBPublication,
spineIndex: Int,
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
chapterIndex: Int = 0,
absolutePageStartIndex: Int = 0
) throws -> RDEPUBTextChapterBuildResult? {
let bookID = publication.metadata.identifier ?? publication.metadata.title
let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
let cachedPagination = cacheCoordinator.load(key: cacheKey)
return try buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style,
chapterIndex: chapterIndex,
absolutePageStartIndex: absolutePageStartIndex,
cachedPagination: cachedPagination
)
}
private func buildChapter(
parser: RDEPUBParser,
publication: RDEPUBPublication,
spineIndex: Int,
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
chapterIndex: Int,
absolutePageStartIndex: Int,
cachedPagination: [String: RDEPUBTextChapterPaginationCache]?
) throws -> RDEPUBTextChapterBuildResult? {
guard publication.spine.indices.contains(spineIndex) else { return nil }
let item = publication.spine[spineIndex]
guard item.linear,
item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
return nil
}
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
let request = RDEPUBTextTypesetterPipeline().makeRequest(
from: RDEPUBTypesettingInput(
href: item.href,
title: chapterTitle,
rawHTML: rawHTML,
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
style: style,
resourceResolver: publication.resourceResolver,
contentLanguageCode: publication.metadata.language,
pageSize: pageSize,
layoutConfig: layoutConfig
)
).request
let renderStart = CFAbsoluteTimeGetCurrent()
let rendered = try renderPipeline.render(request)
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
}
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] skipped href=\(item.href)")
}
return nil
}
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
let paginateStart = CFAbsoluteTimeGetCurrent()
let layoutFrames: [RDEPUBTextLayoutFrame]
let isCacheHit: Bool
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
layoutFrames = [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: attachmentRanges(in: content),
attachmentKinds: [],
blockKinds: [],
semanticHints: [],
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"cover fallback: single attachment page",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
isCacheHit = false
} else if let cached = cachedPagination?[item.href] {
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
return RDEPUBTextLayoutFrame(
contentRange: range,
breakReason: breakReason,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
blockKinds: [],
semanticHints: cached.semanticHints,
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: [
"page break: \(breakReason.rawValue)",
"page range: \(NSStringFromRange(range))",
"source: cache hit"
]
)
}
isCacheHit = true
} else {
layoutFrames = content.length > 0
? paginationPipeline.frames(
for: content,
pageSize: pageSize,
config: layoutConfig,
fragmentOffsets: rendered.fragmentOffsets
)
: []
isCacheHit = false
}
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
let normalizedFrames = tailNormalizer.normalize(
layoutFrames,
content: content,
href: item.href
)
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
blockKinds: [],
semanticHints: [],
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
: normalizedFrames
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
}
let chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
return RDEPUBTextPage(
absolutePageIndex: absolutePageStartIndex + localPageIndex,
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
chapterTitle: chapterTitle,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectiveFrames.count,
chapterContent: chapterAttributedContent,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + max(range.length - 1, 0),
metadata: frame.metadata
)
}
let chapter = RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
title: chapterTitle,
attributedContent: chapterAttributedContent,
fragmentOffsets: rendered.fragmentOffsets,
pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages
)
let performanceSample = RDEPUBTextPerformanceSample(
chapterHref: item.href,
renderDuration: renderDuration,
paginateDuration: paginateDuration,
pageCount: effectiveFrames.count,
attributedStringLength: content.length,
cacheHit: isCacheHit
)
let diagnostic = diagnosticsReporter.chapterDiagnostic(
href: item.href,
title: chapterTitle,
pages: pages
)
return RDEPUBTextChapterBuildResult(
chapter: chapter,
resourceDiagnostics: rendered.resourceDiagnostics,
paginationDiagnostic: diagnostic,
performanceSample: performanceSample,
cacheHit: isCacheHit
)
}
// MARK: -
/// 退 spine item title href