refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
import UIKit
|
||||
|
||||
public final class RDEPUBTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
private let cache: RDEPUBTextBookCache?
|
||||
private let layoutConfig: RDEPUBTextLayoutConfig
|
||||
private let sampler: RDEPUBTextPerformanceSampler
|
||||
private let renderPipeline: RDEPUBChapterRenderPipeline
|
||||
private let paginationPipeline: RDEPUBChapterPaginationPipeline
|
||||
private let tailNormalizer: RDEPUBChapterTailNormalizer
|
||||
private let cacheCoordinator: RDEPUBPaginationCacheCoordinator
|
||||
private let diagnosticsReporter: RDEPUBBuildDiagnosticsReporter
|
||||
|
||||
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,
|
||||
cache: RDEPUBTextBookCache? = nil,
|
||||
layoutConfig: RDEPUBTextLayoutConfig = .default
|
||||
) {
|
||||
self.renderer = renderer
|
||||
self.cache = cache
|
||||
self.layoutConfig = layoutConfig
|
||||
self.sampler = RDEPUBTextPerformanceSampler()
|
||||
self.renderPipeline = RDEPUBChapterRenderPipeline(renderer: renderer)
|
||||
self.paginationPipeline = RDEPUBChapterPaginationPipeline()
|
||||
self.tailNormalizer = RDEPUBChapterTailNormalizer()
|
||||
self.cacheCoordinator = RDEPUBPaginationCacheCoordinator(cache: cache, layoutConfig: layoutConfig)
|
||||
self.diagnosticsReporter = RDEPUBBuildDiagnosticsReporter()
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
||||
}
|
||||
|
||||
private var isPaginationDebugEnabled: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
|
||||
}
|
||||
|
||||
public func phase7SemanticSummary(title: String? = nil) -> String? {
|
||||
diagnosticsReporter.phase7SemanticSummary(
|
||||
title: title,
|
||||
diagnostics: lastBuildPaginationDiagnostics
|
||||
)
|
||||
}
|
||||
|
||||
public func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
if isPaginationDebugEnabled {
|
||||
print("[PaginationDebug] build pageSize=\(NSCoder.string(for: pageSize)) layoutInsets=\(NSCoder.string(for: layoutConfig.edgeInsets))")
|
||||
}
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
var flatPages: [RDEPUBTextPage] = []
|
||||
lastBuildResourceDiagnostics = []
|
||||
lastBuildPaginationDiagnostics = []
|
||||
lastBuildPerformanceSamples = []
|
||||
lastBuildCacheStats = (0, 0)
|
||||
sampler.reset()
|
||||
|
||||
let buildStart = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
let bookID = publication.metadata.identifier ?? publication.metadata.title
|
||||
let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
|
||||
let cachedPagination = cacheCoordinator.load(key: cacheKey)
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
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)
|
||||
print("[PaginationDebug] absPage=\(page.absolutePageIndex + 1) localPage=\(page.pageIndexInChapter + 1) range=\(NSStringFromRange(page.contentRange)) break=\(page.metadata.breakReason.rawValue) preview=\(preview)")
|
||||
for note in page.metadata.diagnostics.prefix(4) {
|
||||
print("[PaginationDebug] note=\(note)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
|
||||
|
||||
cacheCoordinator.save(chapters: chapters, key: cacheKey)
|
||||
|
||||
#if DEBUG
|
||||
print(sampler.summary())
|
||||
#endif
|
||||
lastBuildPerformanceSamples = sampler.samples
|
||||
|
||||
return book
|
||||
}
|
||||
|
||||
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,
|
||||
spineIndex: spineIndex,
|
||||
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 shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
}
|
||||
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
|
||||
|
||||
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,
|
||||
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,
|
||||
cfiMap: RDEPUBCFITextNodeMapBuilder.makeMap(
|
||||
href: item.href,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterAttributedContent.string,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
|
||||
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
|
||||
tocItem.href.components(separatedBy: "#").first == item.href
|
||||
})?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedTitle.isEmpty ? item.href : trimmedTitle
|
||||
}
|
||||
|
||||
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
var hasAttachment = false
|
||||
if content.length > 0 {
|
||||
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
|
||||
guard value != nil else { return }
|
||||
hasAttachment = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
if text.isEmpty && !hasAttachment && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func attachmentCount(in content: NSAttributedString) -> Int {
|
||||
guard content.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, _ in
|
||||
if value != nil {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
|
||||
guard content.length > 0 else { return [] }
|
||||
var ranges: [NSRange] = []
|
||||
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
|
||||
if value != nil {
|
||||
ranges.append(range)
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
private func isAttachmentOnlyCoverChapter(
|
||||
item: RDEPUBSpineItem,
|
||||
content: NSAttributedString,
|
||||
plainText: String
|
||||
) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
guard lowercasedHref.contains("cover") else { return false }
|
||||
let trimmed = plainText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return attachmentCount(in: content) > 0 && trimmed.count <= 1
|
||||
}
|
||||
|
||||
private func debugPreview(for content: NSAttributedString, limit: Int) -> String {
|
||||
|
||||
let collapsed = content.string
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
.replacingOccurrences(of: "\r", with: " ")
|
||||
.replacingOccurrences(of: "\t", with: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !collapsed.isEmpty else { return "<empty>" }
|
||||
if collapsed.count <= limit {
|
||||
return collapsed
|
||||
}
|
||||
let head = collapsed.prefix(limit)
|
||||
return "\(head)…"
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user