605 lines
26 KiB
Swift
605 lines
26 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 chapterContent: NSAttributedString
|
|
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 {
|
|
public var chapters: [RDEPUBTextChapter]
|
|
public var pages: [RDEPUBTextPage]
|
|
public let indexTable: RDEPUBTextIndexTable
|
|
|
|
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
|
|
self.chapters = chapters
|
|
self.pages = pages
|
|
self.indexTable = RDEPUBTextIndexTable(chapters: chapters)
|
|
}
|
|
|
|
public static func == (lhs: RDEPUBTextBook, rhs: RDEPUBTextBook) -> Bool {
|
|
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
|
|
}
|
|
|
|
public func chapterData(for href: String) -> RDEPUBChapterData? {
|
|
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
|
|
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
|
}
|
|
|
|
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
|
|
guard chapters.indices.contains(index) else { return nil }
|
|
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if let anchor = normalizedLocation.rangeAnchor?.start,
|
|
let page = indexTable.pageNumber(for: anchor, in: self) {
|
|
return page + 1
|
|
}
|
|
|
|
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)
|
|
|
|
let startAnchor = indexTable.anchor(forAbsoluteIndex: page.pageStartOffset, in: chapter)
|
|
let endAnchor = indexTable.anchor(forAbsoluteIndex: page.pageEndOffset, in: chapter)
|
|
let rangeAnchor = RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
|
|
|
|
return RDEPUBLocation(
|
|
bookIdentifier: bookIdentifier,
|
|
href: page.href,
|
|
progression: Double(page.pageStartOffset) / Double(totalLength),
|
|
lastProgression: Double(page.pageEndOffset) / Double(totalLength),
|
|
fragment: nil,
|
|
rangeAnchor: rangeAnchor
|
|
)
|
|
}
|
|
}
|
|
|
|
public final class RDEPUBTextBookBuilder {
|
|
private let renderer: RDEPUBTextRenderer
|
|
private let cache: RDEPUBTextBookCache?
|
|
private let sampler: RDEPUBTextPerformanceSampler
|
|
|
|
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) {
|
|
self.renderer = renderer
|
|
self.cache = cache
|
|
self.sampler = RDEPUBTextPerformanceSampler()
|
|
}
|
|
|
|
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 = []
|
|
lastBuildPerformanceSamples = []
|
|
lastBuildCacheStats = (0, 0)
|
|
sampler.reset()
|
|
|
|
let buildStart = CFAbsoluteTimeGetCurrent()
|
|
|
|
// Cache lookup — WXRead style: load per-chapter page ranges
|
|
let bookID = publication.metadata.identifier ?? publication.metadata.title
|
|
let cacheKey = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style)
|
|
let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) }
|
|
|
|
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 renderStart = CFAbsoluteTimeGetCurrent()
|
|
let rendered = try renderer.renderChapter(request: request)
|
|
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
|
|
|
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 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] {
|
|
// Cache hit: use cached page ranges, skip rd_paginatedFrames
|
|
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
|
|
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
|
|
: []
|
|
isCacheHit = false
|
|
}
|
|
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
|
|
|
let normalizedFrames = normalizeTrailingFrames(
|
|
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
|
|
if item.href.lowercased().contains("cover") {
|
|
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
|
}
|
|
|
|
sampler.record(RDEPUBTextPerformanceSample(
|
|
chapterHref: item.href,
|
|
renderDuration: renderDuration,
|
|
paginateDuration: paginateDuration,
|
|
pageCount: effectiveFrames.count,
|
|
attributedStringLength: content.length,
|
|
cacheHit: isCacheHit
|
|
))
|
|
if isCacheHit {
|
|
lastBuildCacheStats.hits += 1
|
|
} else {
|
|
lastBuildCacheStats.misses += 1
|
|
}
|
|
|
|
let chapterAttributedContent = content.copy() as! NSAttributedString
|
|
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,
|
|
chapterContent: chapterAttributedContent,
|
|
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: chapterAttributedContent,
|
|
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)
|
|
}
|
|
|
|
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
|
|
|
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
|
|
|
|
if let cacheKey {
|
|
let paginationCache = chapters.map { chapter in
|
|
let pageRanges = chapter.pages.map(\.contentRange)
|
|
let breakReasons = chapter.pages.map(\.metadata.breakReason)
|
|
let semanticHints = Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
|
|
return RDEPUBTextChapterPaginationCache(
|
|
href: chapter.href,
|
|
pageRanges: pageRanges,
|
|
breakReasons: breakReasons,
|
|
semanticHints: semanticHints
|
|
)
|
|
}
|
|
cache?.save(paginationCache, key: cacheKey)
|
|
}
|
|
|
|
print(sampler.summary())
|
|
lastBuildPerformanceSamples = sampler.samples
|
|
|
|
return book
|
|
}
|
|
|
|
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 normalizeTrailingFrames(
|
|
_ frames: [RDEPUBTextLayoutFrame],
|
|
content: NSAttributedString,
|
|
href: String
|
|
) -> [RDEPUBTextLayoutFrame] {
|
|
guard frames.count > 1 else { return frames }
|
|
|
|
var normalized = frames
|
|
|
|
while let lastFrame = normalized.last,
|
|
shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) {
|
|
normalized.removeLast()
|
|
let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))"
|
|
if var previousFrame = normalized.popLast() {
|
|
previousFrame.diagnostics.append(note)
|
|
normalized.append(previousFrame)
|
|
} else {
|
|
print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))")
|
|
}
|
|
}
|
|
|
|
guard normalized.count > 1,
|
|
let lastFrame = normalized.last,
|
|
let previousFrame = normalized.dropLast().last,
|
|
shouldMergeShortTrailingFrame(lastFrame, previousFrame: previousFrame, in: content) else {
|
|
return normalized
|
|
}
|
|
|
|
let mergedFrame = mergeTrailingFrame(previousFrame, with: lastFrame)
|
|
normalized.removeLast(2)
|
|
normalized.append(mergedFrame)
|
|
return normalized
|
|
}
|
|
|
|
private func shouldDropWhitespaceOnlyTrailingFrame(
|
|
_ frame: RDEPUBTextLayoutFrame,
|
|
in content: NSAttributedString
|
|
) -> Bool {
|
|
guard frame.contentRange.length > 0,
|
|
attachmentCount(in: content, range: frame.contentRange) == 0 else {
|
|
return false
|
|
}
|
|
return visibleCharacterCount(in: content, range: frame.contentRange) == 0
|
|
}
|
|
|
|
private func shouldMergeShortTrailingFrame(
|
|
_ trailingFrame: RDEPUBTextLayoutFrame,
|
|
previousFrame: RDEPUBTextLayoutFrame,
|
|
in content: NSAttributedString
|
|
) -> Bool {
|
|
guard trailingFrame.contentRange.length > 0,
|
|
NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else {
|
|
return false
|
|
}
|
|
|
|
let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange)
|
|
let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange)
|
|
guard visibleCount <= 2,
|
|
trailingFrame.contentRange.length <= 2,
|
|
visibleCount > 0 || trailingAttachmentCount > 0 else {
|
|
return false
|
|
}
|
|
|
|
let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
|
|
return previousVisibleCount >= max(visibleCount * 8, 12)
|
|
}
|
|
|
|
private func mergeTrailingFrame(
|
|
_ previousFrame: RDEPUBTextLayoutFrame,
|
|
with trailingFrame: RDEPUBTextLayoutFrame
|
|
) -> RDEPUBTextLayoutFrame {
|
|
let mergedRange = NSRange(
|
|
location: previousFrame.contentRange.location,
|
|
length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location
|
|
)
|
|
|
|
return RDEPUBTextLayoutFrame(
|
|
contentRange: mergedRange,
|
|
breakReason: trailingFrame.breakReason,
|
|
blockRange: trailingFrame.blockRange ?? previousFrame.blockRange,
|
|
attachmentRanges: uniqueRanges(from: previousFrame.attachmentRanges + trailingFrame.attachmentRanges),
|
|
attachmentKinds: uniqueValues(from: previousFrame.attachmentKinds + trailingFrame.attachmentKinds),
|
|
blockKinds: uniqueValues(from: previousFrame.blockKinds + trailingFrame.blockKinds),
|
|
semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints),
|
|
attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements),
|
|
trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID,
|
|
diagnostics: previousFrame.diagnostics
|
|
+ trailingFrame.diagnostics
|
|
+ ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"]
|
|
)
|
|
}
|
|
|
|
private func visibleCharacterCount(
|
|
in content: NSAttributedString,
|
|
range: NSRange
|
|
) -> Int {
|
|
guard range.length > 0 else { return 0 }
|
|
let string = content.attributedSubstring(from: range).string
|
|
let filteredScalars = string.unicodeScalars.filter { scalar in
|
|
!CharacterSet.whitespacesAndNewlines.contains(scalar)
|
|
&& !CharacterSet.controlCharacters.contains(scalar)
|
|
}
|
|
return filteredScalars.count
|
|
}
|
|
|
|
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
|
values.reduce(into: [T]()) { result, value in
|
|
if !result.contains(value) {
|
|
result.append(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
|
|
ranges.reduce(into: [NSRange]()) { result, value in
|
|
if !result.contains(value) {
|
|
result.append(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
|
|
guard content.length > 0, range.length > 0 else { return 0 }
|
|
var count = 0
|
|
content.enumerateAttribute(.attachment, in: range) { value, _, _ in
|
|
if value != nil {
|
|
count += 1
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
private func makeCacheKey(
|
|
bookID: String,
|
|
pageSize: CGSize,
|
|
style: RDEPUBTextRenderStyle
|
|
) -> String? {
|
|
guard let cache else { return nil }
|
|
return cache.cacheKey(
|
|
bookID: bookID,
|
|
fontSize: style.font.pointSize,
|
|
lineHeightMultiple: style.lineSpacing,
|
|
contentInsets: .zero,
|
|
pageSize: pageSize
|
|
)
|
|
}
|
|
}
|