import UIKit public struct RDEPUBTextChapterPaginationDiagnostic: Equatable { public var href: String public var title: String public var pageCount: Int public var breakReasons: [RDEPUBTextPageBreakReason] public var attachmentPageCount: Int public var blockAdjustedPageCount: Int public var blockKinds: [RDEPUBTextBlockKind] public var semanticHints: [RDEPUBTextSemanticHint] public var attachmentPlacements: [RDEPUBTextAttachmentPlacement] public var sampleNotes: [String] } public struct RDEPUBTextPage: Equatable { public var absolutePageIndex: Int public var chapterIndex: Int public var spineIndex: Int public var href: String public var chapterTitle: String public var pageIndexInChapter: Int public var totalPagesInChapter: Int public var content: NSAttributedString public var contentRange: NSRange public var pageStartOffset: Int public var pageEndOffset: Int public var metadata: RDEPUBTextPageMetadata } public struct RDEPUBTextChapter: Equatable { public var chapterIndex: Int public var spineIndex: Int public var href: String public var title: String public var attributedContent: NSAttributedString public var fragmentOffsets: [String: Int] public var pageBreakReasons: [RDEPUBTextPageBreakReason] public var pages: [RDEPUBTextPage] } public struct RDEPUBTextBook: Equatable { public var chapters: [RDEPUBTextChapter] public var pages: [RDEPUBTextPage] public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) { self.chapters = chapters self.pages = pages } public func page(at pageNumber: Int) -> RDEPUBTextPage? { guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil } return pages[pageNumber - 1] } public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? { guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier), let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else { return nil } let targetOffset: Int if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] { targetOffset = fragmentOffset } else { let lastOffset = max(chapter.attributedContent.length - 1, 0) targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression)))) } if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) { return page.absolutePageIndex + 1 } return chapter.pages.last.map { $0.absolutePageIndex + 1 } } public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? { guard let page = page(at: pageNumber), let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else { return nil } let totalLength = max(chapter.attributedContent.length - 1, 1) return RDEPUBLocation( bookIdentifier: bookIdentifier, href: page.href, progression: Double(page.pageStartOffset) / Double(totalLength), lastProgression: Double(page.pageEndOffset) / Double(totalLength), fragment: nil ) } } public final class RDEPUBTextBookBuilder { private let renderer: RDEPUBTextRenderer public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = [] public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = [] public init(renderer: RDEPUBTextRenderer) { self.renderer = renderer } public convenience init() { self.init(renderer: RDEPUBDTCoreTextRenderer()) } public func phase7SemanticSummary(title: String? = nil) -> String? { guard !lastBuildPaginationDiagnostics.isEmpty else { return nil } let blockKinds = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.blockKinds)) let semanticHints = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.semanticHints)) let attachmentPlacements = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.attachmentPlacements)) let note = lastBuildPaginationDiagnostics .flatMap(\.sampleNotes) .first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") }) var parts = [ title, "章节 \(lastBuildPaginationDiagnostics.count)", blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]", semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]", attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]" ].compactMap { $0 } if let note { parts.append(note) } return parts.joined(separator: " · ") } public func build( parser: RDEPUBParser, publication: RDEPUBPublication, pageSize: CGSize, style: RDEPUBTextRenderStyle ) throws -> RDEPUBTextBook { var chapters: [RDEPUBTextChapter] = [] var flatPages: [RDEPUBTextPage] = [] lastBuildResourceDiagnostics = [] lastBuildPaginationDiagnostics = [] 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 = RDEPUBTextRendererSupport.makeChapterRenderRequest( href: item.href, title: chapterTitle, rawHTML: rawHTML, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(), style: style, resourceResolver: publication.resourceResolver ) let rendered = try renderer.renderChapter(request: request) 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 layoutFrames: [RDEPUBTextLayoutFrame] 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)))" ] ) ] } else { layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets) : [] } let effectiveFrames = layoutFrames.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)))" ] ) ] : layoutFrames 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 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, content: content.attributedSubstring(from: range), contentRange: range, pageStartOffset: range.location, pageEndOffset: range.location + max(range.length - 1, 0), metadata: frame.metadata ) } chapters.append( RDEPUBTextChapter( chapterIndex: chapterIndex, spineIndex: spineIndex, href: item.href, title: chapterTitle, attributedContent: content.copy() as! NSAttributedString, fragmentOffsets: rendered.fragmentOffsets, pageBreakReasons: pages.map(\.metadata.breakReason), pages: pages ) ) lastBuildPaginationDiagnostics.append( RDEPUBTextChapterPaginationDiagnostic( href: item.href, title: chapterTitle, pageCount: pages.count, breakReasons: pages.map(\.metadata.breakReason), attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count, blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count, blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)), semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)), attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)), sampleNotes: Array( pages .flatMap(\.metadata.diagnostics) .prefix(4) ) ) ) flatPages.append(contentsOf: pages) } return RDEPUBTextBook(chapters: chapters, pages: flatPages) } 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 uniqueValues(from values: [T]) -> [T] { values.reduce(into: [T]()) { result, value in if !result.contains(value) { result.append(value) } } } }