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:
co-authored by
Claude Opus 4.7
parent
488350d956
commit
c76beed03c
+235
-184
@@ -94,173 +94,20 @@ public final class RDEPUBTextBookBuilder {
|
||||
let cachedPagination = cacheCoordinator.load(key: cacheKey)
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// 从目录表中解析章节标题
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let request = RDEPUBTextTypesetterPipeline().makeRequest(
|
||||
from: RDEPUBTypesettingInput(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
rawHTML: rawHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
contentLanguageCode: publication.metadata.language,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
).request
|
||||
|
||||
// 渲染 HTML → NSAttributedString
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderPipeline.render(request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
|
||||
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||
}
|
||||
// 跳过空白的封面/扉页章节
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
|
||||
// 分页:封面图片章节走特殊路径,缓存命中则跳过分页引擎
|
||||
let paginateStart = CFAbsoluteTimeGetCurrent()
|
||||
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||
let isCacheHit: Bool
|
||||
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||
// 纯图片封面:整个内容作为一页
|
||||
layoutFrames = [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges(in: content),
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"cover fallback: single attachment page",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
isCacheHit = false
|
||||
} else if let cached = cachedPagination?[item.href] {
|
||||
// 缓存命中:直接使用缓存的页范围,跳过 CoreText 分页
|
||||
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: range,
|
||||
breakReason: breakReason,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: cached.semanticHints,
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"]
|
||||
)
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
// 缓存未命中:调用 CoreText 分页引擎
|
||||
layoutFrames = content.length > 0
|
||||
? paginationPipeline.frames(
|
||||
for: content,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig,
|
||||
fragmentOffsets: rendered.fragmentOffsets
|
||||
)
|
||||
: []
|
||||
isCacheHit = false
|
||||
}
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
|
||||
// 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
let normalizedFrames = tailNormalizer.normalize(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
href: item.href
|
||||
)
|
||||
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
: normalizedFrames
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
}
|
||||
|
||||
// 记录性能采样
|
||||
sampler.record(RDEPUBTextPerformanceSample(
|
||||
chapterHref: item.href,
|
||||
renderDuration: renderDuration,
|
||||
paginateDuration: paginateDuration,
|
||||
pageCount: effectiveFrames.count,
|
||||
attributedStringLength: content.length,
|
||||
cacheHit: isCacheHit
|
||||
))
|
||||
if isCacheHit {
|
||||
lastBuildCacheStats.hits += 1
|
||||
} else {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
|
||||
// 构建页面和章节模型
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectiveFrames.count,
|
||||
chapterContent: chapterAttributedContent,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0),
|
||||
metadata: frame.metadata
|
||||
)
|
||||
}
|
||||
guard let result = try buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
chapterIndex: chapters.count,
|
||||
absolutePageStartIndex: flatPages.count,
|
||||
cachedPagination: cachedPagination
|
||||
) else { continue }
|
||||
|
||||
if isPaginationDebugEnabled,
|
||||
item.href.contains("Chapter_3.xhtml") {
|
||||
let pages = result.chapter.pages
|
||||
print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
|
||||
for page in pages {
|
||||
let preview = debugPreview(for: page.content, limit: 36)
|
||||
@@ -271,26 +118,16 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
chapters.append(
|
||||
RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
lastBuildPaginationDiagnostics.append(
|
||||
diagnosticsReporter.chapterDiagnostic(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
flatPages.append(contentsOf: pages)
|
||||
chapters.append(result.chapter)
|
||||
flatPages.append(contentsOf: result.chapter.pages)
|
||||
lastBuildResourceDiagnostics.append(contentsOf: result.resourceDiagnostics)
|
||||
lastBuildPaginationDiagnostics.append(result.paginationDiagnostic)
|
||||
sampler.record(result.performanceSample)
|
||||
if result.cacheHit {
|
||||
lastBuildCacheStats.hits += 1
|
||||
} else {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
}
|
||||
|
||||
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
@@ -306,6 +143,220 @@ public final class RDEPUBTextBookBuilder {
|
||||
return book
|
||||
}
|
||||
|
||||
/// 构建指定 spine 章节,用于大书快速首屏和后台增量补齐。
|
||||
public func buildChapter(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
chapterIndex: Int = 0,
|
||||
absolutePageStartIndex: Int = 0
|
||||
) throws -> RDEPUBTextChapterBuildResult? {
|
||||
let bookID = publication.metadata.identifier ?? publication.metadata.title
|
||||
let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
|
||||
let cachedPagination = cacheCoordinator.load(key: cacheKey)
|
||||
return try buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
chapterIndex: chapterIndex,
|
||||
absolutePageStartIndex: absolutePageStartIndex,
|
||||
cachedPagination: cachedPagination
|
||||
)
|
||||
}
|
||||
|
||||
private func buildChapter(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
chapterIndex: Int,
|
||||
absolutePageStartIndex: Int,
|
||||
cachedPagination: [String: RDEPUBTextChapterPaginationCache]?
|
||||
) throws -> RDEPUBTextChapterBuildResult? {
|
||||
guard publication.spine.indices.contains(spineIndex) else { return nil }
|
||||
let item = publication.spine[spineIndex]
|
||||
guard item.linear,
|
||||
item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let request = RDEPUBTextTypesetterPipeline().makeRequest(
|
||||
from: RDEPUBTypesettingInput(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
rawHTML: rawHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
contentLanguageCode: publication.metadata.language,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
).request
|
||||
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderPipeline.render(request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||
}
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
let paginateStart = CFAbsoluteTimeGetCurrent()
|
||||
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||
let isCacheHit: Bool
|
||||
|
||||
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||
layoutFrames = [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges(in: content),
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"cover fallback: single attachment page",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
isCacheHit = false
|
||||
} else if let cached = cachedPagination?[item.href] {
|
||||
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: range,
|
||||
breakReason: breakReason,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: cached.semanticHints,
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: \(breakReason.rawValue)",
|
||||
"page range: \(NSStringFromRange(range))",
|
||||
"source: cache hit"
|
||||
]
|
||||
)
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
layoutFrames = content.length > 0
|
||||
? paginationPipeline.frames(
|
||||
for: content,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig,
|
||||
fragmentOffsets: rendered.fragmentOffsets
|
||||
)
|
||||
: []
|
||||
isCacheHit = false
|
||||
}
|
||||
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
let normalizedFrames = tailNormalizer.normalize(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
href: item.href
|
||||
)
|
||||
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
: normalizedFrames
|
||||
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
}
|
||||
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: absolutePageStartIndex + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectiveFrames.count,
|
||||
chapterContent: chapterAttributedContent,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0),
|
||||
metadata: frame.metadata
|
||||
)
|
||||
}
|
||||
|
||||
let chapter = RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
||||
pages: pages
|
||||
)
|
||||
let performanceSample = RDEPUBTextPerformanceSample(
|
||||
chapterHref: item.href,
|
||||
renderDuration: renderDuration,
|
||||
paginateDuration: paginateDuration,
|
||||
pageCount: effectiveFrames.count,
|
||||
attributedStringLength: content.length,
|
||||
cacheHit: isCacheHit
|
||||
)
|
||||
let diagnostic = diagnosticsReporter.chapterDiagnostic(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
pages: pages
|
||||
)
|
||||
|
||||
return RDEPUBTextChapterBuildResult(
|
||||
chapter: chapter,
|
||||
resourceDiagnostics: rendered.resourceDiagnostics,
|
||||
paginationDiagnostic: diagnostic,
|
||||
performanceSample: performanceSample,
|
||||
cacheHit: isCacheHit
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 章节标题解析
|
||||
|
||||
/// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href
|
||||
|
||||
@@ -21,6 +21,17 @@ public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
|
||||
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: - 页面数据模型
|
||||
|
||||
/// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。
|
||||
@@ -180,4 +191,3 @@ public struct RDEPUBTextBook {
|
||||
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,8 @@ struct RDEPUBChapterPageCounter {
|
||||
private let pageSize: CGSize
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
private let pageBreakPolicy: RDEPUBPageBreakPolicy
|
||||
|
||||
/// CoreText 帧设置器
|
||||
private let framesetter: CTFramesetter
|
||||
|
||||
/// DTCoreText 路径可直接消费的单矩形布局区域
|
||||
private let dtLayoutRect: CGRect
|
||||
|
||||
@@ -59,27 +58,26 @@ struct RDEPUBChapterPageCounter {
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
|
||||
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right
|
||||
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
|
||||
let contentRect = config.contentRect(fallback: resolvedSize)
|
||||
|
||||
guard usableWidth > 0, usableHeight > 0 else {
|
||||
guard contentRect.width > 0, contentRect.height > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
while location < attributedString.length {
|
||||
let framePath = CGMutablePath()
|
||||
let pageRect = CGRect(
|
||||
x: config.edgeInsets.left,
|
||||
y: config.edgeInsets.bottom,
|
||||
width: usableWidth,
|
||||
height: usableHeight
|
||||
let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath(
|
||||
pageSize: resolvedSize,
|
||||
config: config
|
||||
)
|
||||
framePath.addRect(pageRect)
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil)
|
||||
let proposedRange = proposedRangeUsingWXReadPageCount(
|
||||
let frame = CTFramesetterCreateFrame(
|
||||
framesetter,
|
||||
CFRangeMake(location, 0),
|
||||
framePath,
|
||||
nil
|
||||
)
|
||||
let proposedRange = proposedVisibleRange(
|
||||
from: frame,
|
||||
start: location,
|
||||
usableHeight: usableHeight,
|
||||
totalLength: attributedString.length
|
||||
)
|
||||
guard proposedRange.length > 0 else {
|
||||
@@ -87,12 +85,18 @@ struct RDEPUBChapterPageCounter {
|
||||
}
|
||||
|
||||
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
|
||||
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
|
||||
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: keepWithNextAdjusted,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
|
||||
|
||||
let adjusted = pageBreakPolicy.adjustedRange(
|
||||
from: avoidAdjusted,
|
||||
from: widowOrphanAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges,
|
||||
lineRanges: effectiveLineRanges,
|
||||
factory: factory
|
||||
)
|
||||
let trailingFragmentID = factory.nearestTrailingFragmentID(
|
||||
@@ -157,10 +161,15 @@ struct RDEPUBChapterPageCounter {
|
||||
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
|
||||
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
|
||||
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: lineAdjusted,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
|
||||
let adjusted = pageBreakPolicy.adjustedRange(
|
||||
from: lineAdjusted,
|
||||
from: widowOrphanAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges,
|
||||
lineRanges: effectiveLineRanges,
|
||||
factory: factory
|
||||
)
|
||||
let verifiedRange: NSRange
|
||||
@@ -231,40 +240,29 @@ struct RDEPUBChapterPageCounter {
|
||||
|
||||
// MARK: - WXRead 分页对齐
|
||||
|
||||
/// 逐句对齐 WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` 的分页循环。
|
||||
private func proposedRangeUsingWXReadPageCount(
|
||||
/// 读取 CoreText frame 当前可见的字符范围;多栏 path 下也能返回整页可见区。
|
||||
private func proposedVisibleRange(
|
||||
from frame: CTFrame,
|
||||
start location: Int,
|
||||
usableHeight: CGFloat,
|
||||
totalLength: Int
|
||||
) -> NSRange {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
guard !lines.isEmpty else {
|
||||
let visibleRange = CTFrameGetVisibleStringRange(frame)
|
||||
guard visibleRange.length > 0 else {
|
||||
return NSRange(location: location, length: 0)
|
||||
}
|
||||
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
var pageCharCount = 0
|
||||
for (index, line) in lines.enumerated() {
|
||||
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
|
||||
let resolvedLocation = max(location, visibleRange.location)
|
||||
let visibleEnd = visibleRange.location + visibleRange.length
|
||||
let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation)
|
||||
guard length > 0 else {
|
||||
return NSRange(location: resolvedLocation, length: 0)
|
||||
}
|
||||
return NSRange(location: resolvedLocation, length: length)
|
||||
}
|
||||
|
||||
if pageCharCount == 0 {
|
||||
pageCharCount = 1
|
||||
private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] {
|
||||
lineRanges.filter { lineRange in
|
||||
lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range)
|
||||
}
|
||||
|
||||
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
|
||||
}
|
||||
}
|
||||
|
||||
+127
@@ -183,6 +183,38 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
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: - 行信息提取
|
||||
|
||||
/// 获取 CTFrame 中所有行的字符范围
|
||||
@@ -263,6 +295,101 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
.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] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
|
||||
@@ -41,7 +41,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
let attributedString = NSMutableAttributedString(attributedString: rendered)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: 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(
|
||||
attributedString: attributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
@@ -76,7 +80,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: 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(
|
||||
attributedString: attributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
|
||||
@@ -104,7 +104,11 @@ enum RDEPUBTextRendererSupport {
|
||||
// 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)
|
||||
var blockIndex = 0
|
||||
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)
|
||||
paragraph.lineSpacing = style.lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
||||
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont
|
||||
|
||||
Reference in New Issue
Block a user