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
parent 488350d956
commit c76beed03c
15 changed files with 984 additions and 268 deletions

View File

@ -30,16 +30,27 @@ extension RDEPUBParser {
return true return true
} }
let interactivePattern = #"<(script|iframe|video|audio|canvas|svg|form)\b|\bon(load|click|touchstart|touchend|mouseover)=|hype_generated_script|swiper|webview"#
for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) { for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) {
guard let html = htmlString(forRelativePath: item.href) else { guard let html = htmlString(forRelativePath: item.href) else {
continue continue
} }
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil { if containsInteractiveMarkup(html) {
return true return true
} }
} }
return false return false
} }
/// HTML Web
/// EPUB SVG `<svg>` interactive
private func containsInteractiveMarkup(_ html: String) -> Bool {
let interactivePattern = #"<(script|iframe|video|audio|canvas|object|embed)\b|\bon(load|click|touchstart|touchend|mouseover|submit|change|input)=|hype_generated_script|swiper|webview"#
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
return true
}
let dynamicSVGPattern = #"<svg\b[^>]*>[\s\S]*?(<(script|foreignObject|animate|animateMotion|animateTransform|set)\b|\bon(load|click|touchstart|touchend|mouseover)=)"#
return html.range(of: dynamicSVGPattern, options: [.regularExpression, .caseInsensitive]) != nil
}
} }

View File

@ -94,173 +94,20 @@ public final class RDEPUBTextBookBuilder {
let cachedPagination = cacheCoordinator.load(key: cacheKey) let cachedPagination = cacheCoordinator.load(key: cacheKey)
for (spineIndex, item) in publication.spine.enumerated() where item.linear { for (spineIndex, item) in publication.spine.enumerated() where item.linear {
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"), guard let result = try buildChapter(
let rawHTML = parser.htmlString(forRelativePath: item.href) else { parser: parser,
continue publication: publication,
} spineIndex: spineIndex,
pageSize: pageSize,
// style: style,
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents) chapterIndex: chapters.count,
let request = RDEPUBTextTypesetterPipeline().makeRequest( absolutePageStartIndex: flatPages.count,
from: RDEPUBTypesettingInput( cachedPagination: cachedPagination
href: item.href, ) else { continue }
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
)
}
if isPaginationDebugEnabled, if isPaginationDebugEnabled,
item.href.contains("Chapter_3.xhtml") { item.href.contains("Chapter_3.xhtml") {
let pages = result.chapter.pages
print("[PaginationDebug] href=\(item.href) pages=\(pages.count)") print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
for page in pages { for page in pages {
let preview = debugPreview(for: page.content, limit: 36) let preview = debugPreview(for: page.content, limit: 36)
@ -271,26 +118,16 @@ public final class RDEPUBTextBookBuilder {
} }
} }
chapters.append( chapters.append(result.chapter)
RDEPUBTextChapter( flatPages.append(contentsOf: result.chapter.pages)
chapterIndex: chapterIndex, lastBuildResourceDiagnostics.append(contentsOf: result.resourceDiagnostics)
spineIndex: spineIndex, lastBuildPaginationDiagnostics.append(result.paginationDiagnostic)
href: item.href, sampler.record(result.performanceSample)
title: chapterTitle, if result.cacheHit {
attributedContent: chapterAttributedContent, lastBuildCacheStats.hits += 1
fragmentOffsets: rendered.fragmentOffsets, } else {
pageBreakReasons: pages.map(\.metadata.breakReason), lastBuildCacheStats.misses += 1
pages: pages }
)
)
lastBuildPaginationDiagnostics.append(
diagnosticsReporter.chapterDiagnostic(
href: item.href,
title: chapterTitle,
pages: pages
)
)
flatPages.append(contentsOf: pages)
} }
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages) let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
@ -306,6 +143,220 @@ public final class RDEPUBTextBookBuilder {
return book 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: - // MARK: -
/// 退 spine item title href /// 退 spine item title href

View File

@ -21,6 +21,17 @@ public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
public var sampleNotes: [String] public var sampleNotes: [String]
} }
// MARK: -
///
public struct RDEPUBTextChapterBuildResult {
public var chapter: RDEPUBTextChapter
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic
public var performanceSample: RDEPUBTextPerformanceSample
public var cacheHit: Bool
}
// MARK: - // MARK: -
/// ///
@ -180,4 +191,3 @@ public struct RDEPUBTextBook {
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier) return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
} }
} }

View File

@ -20,9 +20,8 @@ struct RDEPUBChapterPageCounter {
private let pageSize: CGSize private let pageSize: CGSize
private let config: RDEPUBTextLayoutConfig private let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy private let pageBreakPolicy: RDEPUBPageBreakPolicy
/// CoreText
private let framesetter: CTFramesetter private let framesetter: CTFramesetter
/// DTCoreText /// DTCoreText
private let dtLayoutRect: CGRect private let dtLayoutRect: CGRect
@ -59,27 +58,26 @@ struct RDEPUBChapterPageCounter {
var frames: [RDEPUBTextLayoutFrame] = [] var frames: [RDEPUBTextLayoutFrame] = []
var location = 0 var location = 0
let resolvedSize = config.resolvedFrameSize(fallback: pageSize) let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right let contentRect = config.contentRect(fallback: resolvedSize)
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
guard usableWidth > 0, usableHeight > 0 else { guard contentRect.width > 0, contentRect.height > 0 else {
return [] return []
} }
while location < attributedString.length { while location < attributedString.length {
let framePath = CGMutablePath() let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath(
let pageRect = CGRect( pageSize: resolvedSize,
x: config.edgeInsets.left, config: config
y: config.edgeInsets.bottom,
width: usableWidth,
height: usableHeight
) )
framePath.addRect(pageRect) let frame = CTFramesetterCreateFrame(
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil) framesetter,
let proposedRange = proposedRangeUsingWXReadPageCount( CFRangeMake(location, 0),
framePath,
nil
)
let proposedRange = proposedVisibleRange(
from: frame, from: frame,
start: location, start: location,
usableHeight: usableHeight,
totalLength: attributedString.length totalLength: attributedString.length
) )
guard proposedRange.length > 0 else { guard proposedRange.length > 0 else {
@ -87,12 +85,18 @@ struct RDEPUBChapterPageCounter {
} }
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange) let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame) let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
proposed: keepWithNextAdjusted,
lineRanges: lineRanges
)
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
let adjusted = pageBreakPolicy.adjustedRange( let adjusted = pageBreakPolicy.adjustedRange(
from: avoidAdjusted, from: widowOrphanAdjusted,
totalLength: attributedString.length, totalLength: attributedString.length,
lineRanges: lineRanges, lineRanges: effectiveLineRanges,
factory: factory factory: factory
) )
let trailingFragmentID = factory.nearestTrailingFragmentID( let trailingFragmentID = factory.nearestTrailingFragmentID(
@ -157,10 +161,15 @@ struct RDEPUBChapterPageCounter {
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange) let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted) let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame) let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
proposed: lineAdjusted,
lineRanges: lineRanges
)
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
let adjusted = pageBreakPolicy.adjustedRange( let adjusted = pageBreakPolicy.adjustedRange(
from: lineAdjusted, from: widowOrphanAdjusted,
totalLength: attributedString.length, totalLength: attributedString.length,
lineRanges: lineRanges, lineRanges: effectiveLineRanges,
factory: factory factory: factory
) )
let verifiedRange: NSRange let verifiedRange: NSRange
@ -231,40 +240,29 @@ struct RDEPUBChapterPageCounter {
// MARK: - WXRead // MARK: - WXRead
/// WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` /// CoreText frame path
private func proposedRangeUsingWXReadPageCount( private func proposedVisibleRange(
from frame: CTFrame, from frame: CTFrame,
start location: Int, start location: Int,
usableHeight: CGFloat,
totalLength: Int totalLength: Int
) -> NSRange { ) -> NSRange {
let lines = CTFrameGetLines(frame) as! [CTLine] let visibleRange = CTFrameGetVisibleStringRange(frame)
guard !lines.isEmpty else { guard visibleRange.length > 0 else {
return NSRange(location: location, length: 0) return NSRange(location: location, length: 0)
} }
var origins = [CGPoint](repeating: .zero, count: lines.count) let resolvedLocation = max(location, visibleRange.location)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) let visibleEnd = visibleRange.location + visibleRange.length
let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation)
var pageCharCount = 0 guard length > 0 else {
for (index, line) in lines.enumerated() { return NSRange(location: resolvedLocation, length: 0)
let lineRange = CTLineGetStringRange(line)
let lineY = origins[index].y
var ascent: CGFloat = 0
var descent: CGFloat = 0
CTLineGetTypographicBounds(line, &ascent, &descent, nil)
if lineY - ascent > usableHeight {
break
}
pageCharCount += lineRange.length
} }
return NSRange(location: resolvedLocation, length: length)
}
if pageCharCount == 0 { private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] {
pageCharCount = 1 lineRanges.filter { lineRange in
lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range)
} }
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
} }
} }

View File

@ -183,6 +183,38 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return NSRange(location: proposed.location, length: adjustedLength) return NSRange(location: proposed.location, length: adjustedLength)
} }
/// avoidWidows / avoidOrphans
func trimmedRangeForWidowAndOrphanControl(
proposed: NSRange,
lineRanges: [NSRange]
) -> NSRange {
guard !lineRanges.isEmpty else { return proposed }
guard config.avoidWidows || config.avoidOrphans else { return proposed }
var adjusted = proposed
var visibleLines = lineRanges
if config.avoidWidows,
let widowAdjusted = trimmedRangeAvoidingWidow(
proposed: adjusted,
lineRanges: visibleLines
),
widowAdjusted != adjusted {
adjusted = widowAdjusted
visibleLines = Array(visibleLines.dropLast())
}
if config.avoidOrphans,
let orphanAdjusted = trimmedRangeAvoidingOrphan(
proposed: adjusted,
lineRanges: visibleLines
) {
adjusted = orphanAdjusted
}
return adjusted
}
// MARK: - // MARK: -
/// CTFrame /// CTFrame
@ -263,6 +295,101 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
} }
// MARK: - Widow / Orphan
private func trimmedRangeAvoidingWidow(
proposed: NSRange,
lineRanges: [NSRange]
) -> NSRange? {
guard lineRanges.count >= 2,
let lastLine = lineRanges.last else {
return nil
}
let paragraphRange = paragraphRange(containing: lastLine.location)
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
let paragraphContinuesOnNextPage = NSMaxRange(proposed) < NSMaxRange(paragraphRange)
guard trailingLines == 1, paragraphContinuesOnNextPage else {
return nil
}
let keptLine = lineRanges[lineRanges.count - 2]
let adjustedLength = NSMaxRange(keptLine) - proposed.location
guard adjustedLength > 0 else { return nil }
return NSRange(location: proposed.location, length: adjustedLength)
}
private func trimmedRangeAvoidingOrphan(
proposed: NSRange,
lineRanges: [NSRange]
) -> NSRange? {
guard lineRanges.count >= 2,
let lastLine = lineRanges.last else {
return nil
}
let paragraphRange = paragraphRange(containing: lastLine.location)
let pageEnd = NSMaxRange(proposed)
let paragraphEnd = NSMaxRange(paragraphRange)
guard pageEnd < paragraphEnd else { return nil }
let remainingLineCount = estimatedRemainingLineCount(
in: paragraphRange,
startingAt: pageEnd
)
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
guard remainingLineCount == 1,
trailingLines >= 2 else {
return nil
}
let keptLine = lineRanges[lineRanges.count - 2]
let adjustedLength = NSMaxRange(keptLine) - proposed.location
guard adjustedLength > 0 else { return nil }
return NSRange(location: proposed.location, length: adjustedLength)
}
private func trailingLineCount(
in lineRanges: [NSRange],
paragraphRange: NSRange
) -> Int {
var count = 0
for lineRange in lineRanges.reversed() {
if NSIntersectionRange(lineRange, paragraphRange).length > 0 {
count += 1
} else {
break
}
}
return count
}
private func estimatedRemainingLineCount(
in paragraphRange: NSRange,
startingAt location: Int
) -> Int {
let paragraphEnd = NSMaxRange(paragraphRange)
var currentLocation = max(location, paragraphRange.location)
guard currentLocation < paragraphEnd else { return 0 }
let width = max(config.columnRects(fallback: pageSize).first?.width ?? config.contentRect(fallback: pageSize).width, 1)
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
var lineCount = 0
while currentLocation < paragraphEnd {
let suggestedCount = CTTypesetterSuggestLineBreak(typesetter, currentLocation, Double(width))
let lineLength = max(suggestedCount, 1)
currentLocation += lineLength
lineCount += 1
if lineCount > 2 {
break
}
}
return lineCount
}
/// ///
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] { func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else { guard let safeRange = clampedRange(range), safeRange.length > 0 else {

View File

@ -41,7 +41,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
let attributedString = NSMutableAttributedString(attributedString: rendered) let attributedString = NSMutableAttributedString(attributedString: rendered)
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString) RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString) let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style) RDEPUBTextRendererSupport.normalizeReadingAttributes(
in: attributedString,
style: request.style,
layoutConfig: request.layoutConfig ?? .default
)
return RDEPUBRenderedChapterContent( return RDEPUBRenderedChapterContent(
attributedString: attributedString, attributedString: attributedString,
fragmentOffsets: fragmentOffsets, fragmentOffsets: fragmentOffsets,
@ -76,7 +80,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style) let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString) RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString) let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style) RDEPUBTextRendererSupport.normalizeReadingAttributes(
in: attributedString,
style: request.style,
layoutConfig: request.layoutConfig ?? .default
)
return RDEPUBRenderedChapterContent( return RDEPUBRenderedChapterContent(
attributedString: attributedString, attributedString: attributedString,
fragmentOffsets: fragmentOffsets, fragmentOffsets: fragmentOffsets,

View File

@ -104,7 +104,11 @@ enum RDEPUBTextRendererSupport {
// MARK: - RDEPUBDTCoreTextRenderer // MARK: - RDEPUBDTCoreTextRenderer
/// ///
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) { static func normalizeReadingAttributes(
in attributedString: NSMutableAttributedString,
style: RDEPUBTextRenderStyle,
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
let fullRange = NSRange(location: 0, length: attributedString.length) let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0 var blockIndex = 0
let sourceText = attributedString.string as NSString let sourceText = attributedString.string as NSString
@ -114,6 +118,7 @@ enum RDEPUBTextRendererSupport {
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing) let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
paragraph.lineSpacing = style.lineSpacing paragraph.lineSpacing = style.lineSpacing
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2) paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
var updatedAttributes = attributes var updatedAttributes = attributes
updatedAttributes[.font] = normalizedFont updatedAttributes[.font] = normalizedFont

View File

@ -30,12 +30,15 @@ final class RDEPUBReaderChapterListController: UITableViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.accessibilityIdentifier = "epub.reader.toc.panel"
tableView.accessibilityIdentifier = "epub.reader.toc.table"
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
tableView.tableFooterView = UIView(frame: .zero) tableView.tableFooterView = UIView(frame: .zero)
tableView.backgroundColor = theme.contentBackgroundColor tableView.backgroundColor = theme.contentBackgroundColor
navigationController?.navigationBar.tintColor = theme.toolControlTextColor navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.toc.navbar"
} }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
@ -62,4 +65,4 @@ final class RDEPUBReaderChapterListController: UITableViewController {
guard let currentItem else { return false } guard let currentItem else { return false }
return currentItem.href == item.href return currentItem.href == item.href
} }
} }

View File

@ -91,6 +91,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
/// ///
public func pageNum(readerView: RDReaderView, pageNum: Int) { public func pageNum(readerView: RDReaderView, pageNum: Int) {
readerContext.markUserNavigationActivity()
updateCurrentSelection(nil) updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum) reconcileTextPaginationSizeIfNeeded(for: pageNum)

View File

@ -247,6 +247,8 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
tableView.tableFooterView = UIView(frame: .zero) tableView.tableFooterView = UIView(frame: .zero)
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
applyTheme() applyTheme()
@ -286,6 +288,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
navigationController?.navigationBar.tintColor = theme.toolControlTextColor navigationController?.navigationBar.tintColor = theme.toolControlTextColor
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
} }
private func updateEmptyState() { private func updateEmptyState() {

View File

@ -25,6 +25,12 @@ import UIKit
/// navigationController?.pushViewController(controller, animated: true) /// navigationController?.pushViewController(controller, animated: true)
/// ``` /// ```
public final class RDURLReaderController: UIViewController { public final class RDURLReaderController: UIViewController {
private struct PendingDemoPageRequest {
let pageNumber: Int
let animated: Bool
var attemptCount: Int
}
/// URL /// URL
private let bookURL: URL private let bookURL: URL
/// EPUB /// EPUB
@ -41,6 +47,10 @@ public final class RDURLReaderController: UIViewController {
label.text = "reader=opening" label.text = "reader=opening"
return label return label
}() }()
private var pendingDemoPageRequest: PendingDemoPageRequest?
private var isRetryingPendingDemoPage = false
private let maxPendingDemoPageAttempts = 24
private let pendingDemoPageRetryDelay: TimeInterval = 0.25
/// ///
/// - Parameters: /// - Parameters:
@ -88,9 +98,12 @@ public final class RDURLReaderController: UIViewController {
/// - Returns: /// - Returns:
@discardableResult @discardableResult
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool { public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false let moved = performDemoPageNavigation(pageNumber, animated: animated)
if moved { if moved {
emitDemoState(prefix: "page=\(pageNumber)") pendingDemoPageRequest = nil
isRetryingPendingDemoPage = false
} else {
queuePendingDemoPageNavigation(pageNumber, animated: animated)
} }
return moved return moved
} }
@ -187,6 +200,73 @@ public final class RDURLReaderController: UIViewController {
embeddedController as? RDEPUBReaderController embeddedController as? RDEPUBReaderController
} }
@discardableResult
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
guard let readerController else { return false }
guard pageNumber > 0 else { return false }
let totalPages = readerController.textBook?.pages.count ?? readerController.activePages.count
let numPages = readerController.readerView.numberOfPages()
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): textBook.pages=\(totalPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)")
guard pageNumber <= totalPages else { return false }
readerController.readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): after transition, currentPage=\(readerController.readerView.currentPage)")
emitDemoState(prefix: "page=\(pageNumber)")
return true
}
private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) {
if let pendingDemoPageRequest,
pendingDemoPageRequest.pageNumber == pageNumber,
pendingDemoPageRequest.animated == animated {
return
}
pendingDemoPageRequest = PendingDemoPageRequest(
pageNumber: pageNumber,
animated: animated,
attemptCount: 0
)
retryPendingDemoPageIfNeeded()
}
private func retryPendingDemoPageIfNeeded() {
guard !isRetryingPendingDemoPage else { return }
guard pendingDemoPageRequest != nil else { return }
isRetryingPendingDemoPage = true
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
self?.attemptPendingDemoPageNavigation()
}
}
private func attemptPendingDemoPageNavigation() {
guard var pendingDemoPageRequest else {
isRetryingPendingDemoPage = false
return
}
if performDemoPageNavigation(pendingDemoPageRequest.pageNumber, animated: pendingDemoPageRequest.animated) {
self.pendingDemoPageRequest = nil
isRetryingPendingDemoPage = false
return
}
pendingDemoPageRequest.attemptCount += 1
if pendingDemoPageRequest.attemptCount >= maxPendingDemoPageAttempts {
self.pendingDemoPageRequest = nil
isRetryingPendingDemoPage = false
print("[ReadViewDemo] automation page=\(pendingDemoPageRequest.pageNumber) failed after \(pendingDemoPageRequest.attemptCount) attempts")
return
}
self.pendingDemoPageRequest = pendingDemoPageRequest
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
self?.attemptPendingDemoPageNavigation()
}
}
/// Demo /// Demo
private func logDemoState(prefix: String) { private func logDemoState(prefix: String) {
guard let readerController else { return } guard let readerController else { return }
@ -259,7 +339,13 @@ public final class RDURLReaderController: UIViewController {
} }
extension RDURLReaderController: RDEPUBReaderDelegate { extension RDURLReaderController: RDEPUBReaderDelegate {
public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {
retryPendingDemoPageIfNeeded()
emitDemoState()
}
public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) { public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
retryPendingDemoPageIfNeeded()
emitDemoState() emitDemoState()
} }

View File

@ -23,11 +23,13 @@ final class RDEPUBReaderAssemblyCoordinator {
setupLoadingIndicator(controller.loadingIndicator, in: controller.view) setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
setupErrorLabel(controller.errorLabel, in: controller.view) setupErrorLabel(controller.errorLabel, in: controller.view)
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView) controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
} }
/// ///
func finishExternalTextBookLaunchIfNeeded() { func finishExternalTextBookLaunchIfNeeded() {
guard let runtime = context.runtime, guard let runtime = context.runtime,
let controller = context.controller,
context.isExternalTextBook else { context.isExternalTextBook else {
return return
} }
@ -37,7 +39,14 @@ final class RDEPUBReaderAssemblyCoordinator {
context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? [] context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? [] context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
} }
runtime.finishPagination(restoreLocation: restoreLocation)
if let textBook = controller.textBook {
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
} else {
runtime.finishPagination(restoreLocation: restoreLocation)
}
} }
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) { private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {

View File

@ -9,6 +9,9 @@ import UIKit
/// - 便renderStylelayoutConfig /// - 便renderStylelayoutConfig
/// - controller UIKit /// - controller UIKit
final class RDEPUBReaderContext { final class RDEPUBReaderContext {
private let activityLock = NSLock()
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
// MARK: - // MARK: -
/// UIKit /// UIKit
@ -205,6 +208,22 @@ final class RDEPUBReaderContext {
persistence?.saveLocation(location, for: currentBookIdentifier) persistence?.saveLocation(location, for: currentBookIdentifier)
} }
/// /
func markUserNavigationActivity() {
activityLock.lock()
lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
activityLock.unlock()
}
/// /
func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
activityLock.lock()
let timestamp = lastUserNavigationTimestamp
activityLock.unlock()
guard timestamp > 0 else { return .greatestFiniteMagnitude }
return CFAbsoluteTimeGetCurrent() - timestamp
}
/// href /// href
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
guard let textBook, let publication else { return nil } guard let textBook, let publication else { return nil }

View File

@ -9,6 +9,52 @@ import Foundation
/// - /// -
/// - /// -
final class RDEPUBReaderPaginationCoordinator { final class RDEPUBReaderPaginationCoordinator {
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
private let backgroundChapterPause: TimeInterval = 0.04
private let incrementalMergeChapterThreshold = 20
private let stagedIncrementalApplyDelay: TimeInterval = 1.0
private var stagedIncrementalApplyWorkItem: DispatchWorkItem?
private let stagedBookRequestLock = NSLock()
private var stagedBookRequest: StagedBookRequest?
private struct QuickBuildState {
var quickWindow: [Int]
var chapterStore: IncrementalChapterStore
var book: RDEPUBTextBook
}
private struct StagedBookRequest {
var chapterStore: IncrementalChapterStore
var orderedSpineIndices: [Int]
var restoreLocation: RDEPUBLocation?
var isComplete: Bool
}
private final class IncrementalChapterStore {
private let lock = NSLock()
private var builtChapters: [Int: RDEPUBTextChapter] = [:]
func insert(_ chapter: RDEPUBTextChapter, for spineIndex: Int) {
lock.lock()
builtChapters[spineIndex] = chapter
lock.unlock()
}
func contains(_ spineIndex: Int) -> Bool {
lock.lock()
let contains = builtChapters[spineIndex] != nil
lock.unlock()
return contains
}
func snapshot(orderedBy spineIndices: [Int]) -> [Int: RDEPUBTextChapter] {
lock.lock()
let chapters = builtChapters
lock.unlock()
return chapters
}
}
private unowned let context: RDEPUBReaderContext private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) { init(context: RDEPUBReaderContext) {
@ -29,38 +75,22 @@ final class RDEPUBReaderPaginationCoordinator {
controller.showLoading() controller.showLoading()
let token = UUID() let token = UUID()
context.paginationToken = token context.paginationToken = token
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
if publication.readingProfile == .textReflowable { if publication.readingProfile == .textReflowable {
let pageSize = controller.currentTextPageSize() print("[EPUB][Pagination] path=text-reflowable-fast-entry")
context.lastTextPaginationPageSize = pageSize paginateTextPublication(
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize) parser: parser,
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig) publication: publication,
let renderStyle = controller.currentTextRenderStyle() readingSession: readingSession,
restoreLocation: restoreLocation,
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in token: token
guard let controller else { return } )
do {
let textBook = try builder.build(
parser: parser,
publication: publication,
pageSize: pageSize,
style: renderStyle
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.context.runtime?.applyTextBook(textBook, restoreLocation: restoreLocation)
}
} catch {
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.context.handle(error: error)
}
}
}
return return
} }
if publication.layout == .fixed { if publication.layout == .fixed {
print("[EPUB][Pagination] path=fixed-layout")
let snapshot = readingSession.makePaginationSnapshot( let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count), pageCounts: Array(repeating: 1, count: publication.spine.count),
preferences: controller.currentPreferences(), preferences: controller.currentPreferences(),
@ -71,6 +101,7 @@ final class RDEPUBReaderPaginationCoordinator {
} }
let paginator = context.makePaginator() let paginator = context.makePaginator()
print("[EPUB][Pagination] path=web-paginator")
context.paginator = paginator context.paginator = paginator
paginator.calculate( paginator.calculate(
parser: parser, parser: parser,
@ -91,6 +122,9 @@ final class RDEPUBReaderPaginationCoordinator {
/// ///
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller else { return } guard let controller = context.controller else { return }
stagedIncrementalApplyWorkItem?.cancel()
stagedIncrementalApplyWorkItem = nil
clearStagedBookRequest()
context.textBook = textBook context.textBook = textBook
let snapshot = controller.nativeTextSnapshot(from: textBook) let snapshot = controller.nativeTextSnapshot(from: textBook)
context.replaceActiveSnapshot(snapshot) context.replaceActiveSnapshot(snapshot)
@ -108,7 +142,7 @@ final class RDEPUBReaderPaginationCoordinator {
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation? restoreLocation: RDEPUBLocation?
) { ) {
guard let controller = context.controller else { return } guard context.controller != nil else { return }
context.textBook = nil context.textBook = nil
context.replaceActiveSnapshot(snapshot) context.replaceActiveSnapshot(snapshot)
@ -168,4 +202,351 @@ final class RDEPUBReaderPaginationCoordinator {
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation) context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
} }
} }
/// EPUB TextBook
private func paginateTextPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?,
token: UUID
) {
guard let controller = context.controller else { return }
let pageSize = controller.currentTextPageSize()
context.lastTextPaginationPageSize = pageSize
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let renderStyle = controller.currentTextRenderStyle()
let quickSpineCandidates = prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation
)
DispatchQueue.global(qos: .utility).async { [weak controller] in
guard controller != nil else { return }
var didApplyQuickChapter = false
do {
self.waitForReadingInteractionToSettle()
let allBuildableIndices = self.allBuildableSpineIndices(in: publication)
let quickBuildState = try self.buildQuickTextBook(
builder: builder,
parser: parser,
publication: publication,
pageSize: pageSize,
style: renderStyle,
prioritizedCandidates: quickSpineCandidates
)
if let quickBook = quickBuildState?.book {
DispatchQueue.main.sync {
guard self.context.paginationToken == token else { return }
didApplyQuickChapter = true
self.context.runtime?.applyTextBook(quickBook, restoreLocation: restoreLocation)
}
}
let chapterStore = quickBuildState?.chapterStore ?? IncrementalChapterStore()
var pendingIncrementalCount = 0
let incrementalBuildOrder = self.incrementalBuildOrder(
allBuildableIndices: allBuildableIndices,
quickWindow: quickBuildState?.quickWindow ?? []
)
for spineIndex in incrementalBuildOrder where !chapterStore.contains(spineIndex) {
self.waitForReadingInteractionToSettle()
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: renderStyle
) else {
continue
}
chapterStore.insert(result.chapter, for: spineIndex)
pendingIncrementalCount += 1
Thread.sleep(forTimeInterval: self.backgroundChapterPause)
if pendingIncrementalCount >= self.incrementalMergeChapterThreshold {
pendingIncrementalCount = 0
self.stageBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: allBuildableIndices,
restoreLocation: restoreLocation,
isComplete: false
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.scheduleStagedIncrementalTextBookApplication()
}
}
}
self.stageBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: allBuildableIndices,
restoreLocation: restoreLocation,
isComplete: true
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.scheduleStagedIncrementalTextBookApplication()
}
} catch {
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
if didApplyQuickChapter {
self.context.isRepaginating = false
self.context.hideLoading()
} else {
self.context.handle(error: error)
}
}
}
}
}
private func buildQuickTextBook(
builder: RDEPUBTextBookBuilder,
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
prioritizedCandidates: [Int]
) throws -> QuickBuildState? {
var anchorResult: RDEPUBTextChapterBuildResult?
for spineIndex in prioritizedCandidates {
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
), !result.chapter.pages.isEmpty else {
continue
}
anchorResult = result
break
}
guard let anchorResult else {
return nil
}
let quickWindow = quickWindowSpineIndices(
around: anchorResult.chapter.spineIndex,
in: publication
)
let chapterStore = IncrementalChapterStore()
for spineIndex in quickWindow {
let result: RDEPUBTextChapterBuildResult?
if spineIndex == anchorResult.chapter.spineIndex {
result = anchorResult
} else {
result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
)
}
guard let chapter = result?.chapter,
!chapter.pages.isEmpty else {
continue
}
chapterStore.insert(chapter, for: spineIndex)
}
guard let book = textBook(from: chapterStore.snapshot(orderedBy: quickWindow), orderedBy: quickWindow) else {
return nil
}
print("[EPUB][Pagination] quick-book chapters=\(book.chapters.count) pages=\(book.pages.count) anchor=\(anchorResult.chapter.href)")
return QuickBuildState(
quickWindow: quickWindow,
chapterStore: chapterStore,
book: book
)
}
private func prioritizedBuildableSpineIndices(
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?
) -> [Int] {
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
return publication.spine.indices
.filter { isBuildableTextSpine(at: $0, in: publication) }
.sorted { lhs, rhs in
abs(lhs - preferred) < abs(rhs - preferred)
}
}
private func quickWindowSpineIndices(
around anchorSpineIndex: Int,
in publication: RDEPUBPublication,
maxChapterCount: Int = 3
) -> [Int] {
let buildableIndices = allBuildableSpineIndices(in: publication)
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
return [anchorSpineIndex]
}
var selected: [Int] = [anchorSpineIndex]
var nextPosition = anchorPosition + 1
var previousPosition = anchorPosition - 1
while selected.count < maxChapterCount,
nextPosition < buildableIndices.count || previousPosition >= 0 {
if nextPosition < buildableIndices.count {
selected.append(buildableIndices[nextPosition])
nextPosition += 1
if selected.count == maxChapterCount {
break
}
}
if previousPosition >= 0 {
selected.insert(buildableIndices[previousPosition], at: 0)
previousPosition -= 1
}
}
return selected
}
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
func scheduleStagedIncrementalTextBookApplication() {
stagedIncrementalApplyWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.applyStagedIncrementalTextBookIfPossible()
}
stagedIncrementalApplyWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + stagedIncrementalApplyDelay, execute: workItem)
}
private func applyStagedIncrementalTextBookIfPossible() {
guard context.secondsSinceLastUserNavigation() >= backgroundInteractionCooldown,
!context.isRepaginating,
context.readingSession?.navigatorState == .idle else {
scheduleStagedIncrementalTextBookApplication()
return
}
guard let staged = consumeStagedBookRequest() else {
stagedIncrementalApplyWorkItem = nil
return
}
stagedIncrementalApplyWorkItem = nil
guard let stagedBook = textBook(
from: staged.chapterStore.snapshot(orderedBy: staged.orderedSpineIndices),
orderedBy: staged.orderedSpineIndices
) else {
return
}
let restoreLocation = context.currentVisibleLocation() ?? staged.restoreLocation
let logPrefix = staged.isComplete ? "full-book" : "applied-staged-book"
print("[EPUB][Pagination] \(logPrefix) chapters=\(stagedBook.chapters.count) pages=\(stagedBook.pages.count)")
context.runtime?.applyTextBook(stagedBook, restoreLocation: restoreLocation)
}
private func waitForReadingInteractionToSettle() {
while context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
Thread.sleep(forTimeInterval: 0.08)
}
}
private func incrementalBuildOrder(
allBuildableIndices: [Int],
quickWindow: [Int]
) -> [Int] {
guard let firstWindowIndex = quickWindow.first,
let lastWindowIndex = quickWindow.last else {
return allBuildableIndices
}
let forward = allBuildableIndices.filter { $0 > lastWindowIndex }
let backward = allBuildableIndices.filter { $0 < firstWindowIndex }
return quickWindow + forward + backward
}
private func textBook(
from builtChapters: [Int: RDEPUBTextChapter],
orderedBy spineIndices: [Int]
) -> RDEPUBTextBook? {
var chapters: [RDEPUBTextChapter] = []
var pages: [RDEPUBTextPage] = []
for spineIndex in spineIndices {
guard var chapter = builtChapters[spineIndex],
!chapter.pages.isEmpty else {
continue
}
chapter.chapterIndex = chapters.count
let normalizedPages = chapter.pages.enumerated().map { localPageIndex, page -> RDEPUBTextPage in
var page = page
page.absolutePageIndex = pages.count + localPageIndex
page.chapterIndex = chapters.count
page.pageIndexInChapter = localPageIndex
page.totalPagesInChapter = chapter.pages.count
return page
}
chapter.pages = normalizedPages
chapters.append(chapter)
pages.append(contentsOf: normalizedPages)
}
guard !pages.isEmpty else {
return nil
}
return RDEPUBTextBook(chapters: chapters, pages: pages)
}
private func stageBookRequest(
chapterStore: IncrementalChapterStore,
orderedSpineIndices: [Int],
restoreLocation: RDEPUBLocation?,
isComplete: Bool
) {
stagedBookRequestLock.lock()
stagedBookRequest = StagedBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: orderedSpineIndices,
restoreLocation: restoreLocation,
isComplete: isComplete
)
stagedBookRequestLock.unlock()
}
private func consumeStagedBookRequest() -> StagedBookRequest? {
stagedBookRequestLock.lock()
defer { stagedBookRequestLock.unlock() }
let request = stagedBookRequest
stagedBookRequest = nil
return request
}
private func clearStagedBookRequest() {
stagedBookRequestLock.lock()
stagedBookRequest = nil
stagedBookRequestLock.unlock()
}
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
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"))
}
} }

View File

@ -73,11 +73,15 @@ final class RDEPUBReaderRuntime {
return false return false
} }
if context.textBook != nil { if let textBook = context.textBook {
guard let location = controller.resolvedTextLocation(forPageNumber: pageNumber) else { guard textBook.page(at: pageNumber) != nil else {
return false return false
} }
return locationCoordinator.restoreReadingLocation(location, animated: animated) 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 { guard context.activePages.indices.contains(pageNumber - 1) else {