feat(epub): align text rendering with WXRead

This commit is contained in:
shen
2026-05-24 09:56:32 +08:00
parent 5125b8de51
commit a318c0e3d0
13 changed files with 1556 additions and 135 deletions
@@ -21,6 +21,7 @@ public struct RDEPUBTextPage: Equatable {
public var chapterTitle: String
public var pageIndexInChapter: Int
public var totalPagesInChapter: Int
public var chapterContent: NSAttributedString
public var content: NSAttributedString
public var contentRange: NSRange
public var pageStartOffset: Int
@@ -95,11 +96,18 @@ public struct RDEPUBTextBook: Equatable {
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let cache: RDEPUBTextBookCache?
private let sampler: RDEPUBTextPerformanceSampler
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
public init(renderer: RDEPUBTextRenderer) {
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) {
self.renderer = renderer
self.cache = cache
self.sampler = RDEPUBTextPerformanceSampler()
}
public convenience init() {
@@ -139,6 +147,16 @@ public final class RDEPUBTextBookBuilder {
var flatPages: [RDEPUBTextPage] = []
lastBuildResourceDiagnostics = []
lastBuildPaginationDiagnostics = []
lastBuildPerformanceSamples = []
lastBuildCacheStats = (0, 0)
sampler.reset()
let buildStart = CFAbsoluteTimeGetCurrent()
// Cache lookup WXRead style: load per-chapter page ranges
let bookID = publication.metadata.identifier ?? publication.metadata.title
let cacheKey = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style)
let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) }
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
@@ -155,7 +173,11 @@ public final class RDEPUBTextBookBuilder {
style: style,
resourceResolver: publication.resourceResolver
)
let renderStart = CFAbsoluteTimeGetCurrent()
let rendered = try renderer.renderChapter(request: request)
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -171,7 +193,10 @@ public final class RDEPUBTextBookBuilder {
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(
@@ -191,11 +216,33 @@ public final class RDEPUBTextBookBuilder {
]
)
]
isCacheHit = false
} else if let cached = cachedPagination?[item.href] {
// Cache hit: use cached page ranges, skip rd_paginatedFrames
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
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
: []
isCacheHit = false
}
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
@@ -219,6 +266,21 @@ public final class RDEPUBTextBookBuilder {
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(
@@ -229,6 +291,7 @@ public final class RDEPUBTextBookBuilder {
chapterTitle: chapterTitle,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectiveFrames.count,
chapterContent: chapterAttributedContent,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
@@ -243,7 +306,7 @@ public final class RDEPUBTextBookBuilder {
spineIndex: spineIndex,
href: item.href,
title: chapterTitle,
attributedContent: content.copy() as! NSAttributedString,
attributedContent: chapterAttributedContent,
fragmentOffsets: rendered.fragmentOffsets,
pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages
@@ -270,7 +333,29 @@ public final class RDEPUBTextBookBuilder {
flatPages.append(contentsOf: pages)
}
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
if let cacheKey {
let paginationCache = chapters.map { chapter in
let pageRanges = chapter.pages.map(\.contentRange)
let breakReasons = chapter.pages.map(\.metadata.breakReason)
let semanticHints = Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
return RDEPUBTextChapterPaginationCache(
href: chapter.href,
pageRanges: pageRanges,
breakReasons: breakReasons,
semanticHints: semanticHints
)
}
cache?.save(paginationCache, key: cacheKey)
}
print(sampler.summary())
lastBuildPerformanceSamples = sampler.samples
return book
}
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
@@ -345,4 +430,19 @@ public final class RDEPUBTextBookBuilder {
}
}
}
private func makeCacheKey(
bookID: String,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> String? {
guard let cache else { return nil }
return cache.cacheKey(
bookID: bookID,
fontSize: style.font.pointSize,
lineHeightMultiple: style.lineSpacing,
contentInsets: .zero,
pageSize: pageSize
)
}
}