ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift

349 lines
15 KiB
Swift

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<T: Equatable>(from values: [T]) -> [T] {
values.reduce(into: [T]()) { result, value in
if !result.contains(value) {
result.append(value)
}
}
}
}