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 "" } if collapsed.count <= limit { return collapsed } let head = collapsed.prefix(limit) return "\(head)…" } }