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:
parent
488350d956
commit
c76beed03c
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import UIKit
|
|||||||
/// - 便捷方法(renderStyle、layoutConfig 等)
|
/// - 便捷方法(renderStyle、layoutConfig 等)
|
||||||
/// - 弱引用 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 }
|
||||||
|
|||||||
@ -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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user