- 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
421 lines
17 KiB
Swift
421 lines
17 KiB
Swift
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)…"
|
|
}
|
|
|
|
}
|