refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- 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
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBBuildDiagnosticsReporter {
|
||||
|
||||
func phase7SemanticSummary(
|
||||
title: String?,
|
||||
diagnostics: [RDEPUBTextChapterPaginationDiagnostic]
|
||||
) -> String? {
|
||||
|
||||
guard !diagnostics.isEmpty else { return nil }
|
||||
|
||||
let blockKinds = uniqueValues(from: diagnostics.flatMap(\.blockKinds))
|
||||
let semanticHints = uniqueValues(from: diagnostics.flatMap(\.semanticHints))
|
||||
let attachmentPlacements = uniqueValues(from: diagnostics.flatMap(\.attachmentPlacements))
|
||||
|
||||
let note = diagnostics
|
||||
.flatMap(\.sampleNotes)
|
||||
.first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") })
|
||||
|
||||
var parts = [
|
||||
title,
|
||||
"章节 \(diagnostics.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: " · ")
|
||||
}
|
||||
|
||||
func chapterDiagnostic(
|
||||
href: String,
|
||||
title: String,
|
||||
pages: [RDEPUBTextPage]
|
||||
) -> RDEPUBTextChapterPaginationDiagnostic {
|
||||
RDEPUBTextChapterPaginationDiagnostic(
|
||||
href: href,
|
||||
title: title,
|
||||
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))
|
||||
)
|
||||
}
|
||||
|
||||
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterTailNormalizer {
|
||||
|
||||
func normalize(
|
||||
_ frames: [RDEPUBTextLayoutFrame],
|
||||
content: NSAttributedString,
|
||||
href: String
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
|
||||
guard frames.count > 1 else { return frames }
|
||||
|
||||
var normalized = frames
|
||||
|
||||
var compacted: [RDEPUBTextLayoutFrame] = []
|
||||
compacted.reserveCapacity(normalized.count)
|
||||
for frame in normalized {
|
||||
if shouldDropWhitespaceOnlyFrame(frame, in: content) {
|
||||
let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
|
||||
|
||||
if var previous = compacted.popLast() {
|
||||
previous.diagnostics.append(note)
|
||||
compacted.append(previous)
|
||||
} else {
|
||||
}
|
||||
continue
|
||||
}
|
||||
compacted.append(frame)
|
||||
}
|
||||
normalized = compacted
|
||||
|
||||
while let lastFrame = normalized.last,
|
||||
shouldDropWhitespaceOnlyFrame(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 {
|
||||
}
|
||||
}
|
||||
|
||||
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 shouldDropWhitespaceOnlyFrame(
|
||||
_ 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
|
||||
}
|
||||
|
||||
guard !frameHasAvoidPageBreakInside(trailingFrame, in: content) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
|
||||
return previousVisibleCount >= max(visibleCount * 8, 12)
|
||||
}
|
||||
|
||||
private func frameHasAvoidPageBreakInside(
|
||||
_ frame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
) -> Bool {
|
||||
if frame.semanticHints.contains(.avoidPageBreakInside) {
|
||||
return true
|
||||
}
|
||||
|
||||
guard content.length > 0,
|
||||
let safeRange = clampedRange(frame.contentRange, in: content),
|
||||
safeRange.length > 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
var found = false
|
||||
content.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, stop in
|
||||
guard let rawValue = value as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
if hints.contains(.avoidPageBreakInside) {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
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 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 clampedRange(_ range: NSRange, in content: NSAttributedString) -> NSRange? {
|
||||
guard range.location >= 0, range.length >= 0, range.location < content.length else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: range.location, length: min(range.length, content.length - range.location))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBPaginationCacheCoordinator {
|
||||
|
||||
private let cache: RDEPUBTextBookCache?
|
||||
|
||||
private let layoutConfig: RDEPUBTextLayoutConfig
|
||||
|
||||
init(cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig) {
|
||||
self.cache = cache
|
||||
self.layoutConfig = layoutConfig
|
||||
}
|
||||
|
||||
func cacheKey(
|
||||
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: layoutConfig.edgeInsets,
|
||||
pageSize: pageSize,
|
||||
layoutConfigSignature: "\(layoutConfig.cacheSignature)|font=\(style.font.fontName)"
|
||||
)
|
||||
}
|
||||
|
||||
func load(key: String?) -> [String: RDEPUBTextChapterPaginationCache]? {
|
||||
key.flatMap { cache?.load(key: $0) }
|
||||
}
|
||||
|
||||
func save(chapters: [RDEPUBTextChapter], key: String?) {
|
||||
guard let key else { return }
|
||||
|
||||
let paginationCache = chapters.map { chapter in
|
||||
RDEPUBTextChapterPaginationCache(
|
||||
href: chapter.href,
|
||||
pageRanges: chapter.pages.map(\.contentRange),
|
||||
breakReasons: chapter.pages.map(\.metadata.breakReason),
|
||||
|
||||
semanticHints: Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
|
||||
)
|
||||
}
|
||||
cache?.save(paginationCache, key: key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
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)…"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public struct RDEPUBTextChapterPaginationCache: Equatable {
|
||||
|
||||
public var href: String
|
||||
|
||||
public var pageRanges: [NSRange]
|
||||
|
||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
pageRanges: [NSRange],
|
||||
breakReasons: [RDEPUBTextPageBreakReason],
|
||||
semanticHints: [RDEPUBTextSemanticHint]
|
||||
) {
|
||||
self.href = href
|
||||
self.pageRanges = pageRanges
|
||||
self.breakReasons = breakReasons
|
||||
self.semanticHints = semanticHints
|
||||
}
|
||||
}
|
||||
|
||||
final class PaginationCacheArchive: NSObject, NSSecureCoding {
|
||||
|
||||
static var supportsSecureCoding: Bool { true }
|
||||
|
||||
let chapters: [ChapterPaginationArchive]
|
||||
|
||||
init(chapters: [ChapterPaginationArchive]) {
|
||||
self.chapters = chapters
|
||||
}
|
||||
|
||||
func encode(with coder: NSCoder) {
|
||||
coder.encode(chapters, forKey: "chapters")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
guard let chapters = coder.decodeObject(of: [NSArray.self, ChapterPaginationArchive.self], forKey: "chapters") as? [ChapterPaginationArchive] else { return nil }
|
||||
self.chapters = chapters
|
||||
}
|
||||
}
|
||||
|
||||
final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
|
||||
static var supportsSecureCoding: Bool { true }
|
||||
|
||||
let href: String
|
||||
|
||||
let rangeLocations: [NSNumber]
|
||||
|
||||
let rangeLengths: [NSNumber]
|
||||
|
||||
let breakReasons: [String]
|
||||
|
||||
let semanticHints: [String]
|
||||
|
||||
init(from cache: RDEPUBTextChapterPaginationCache) {
|
||||
self.href = cache.href
|
||||
self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
|
||||
self.rangeLengths = cache.pageRanges.map { NSNumber(value: $0.length) }
|
||||
self.breakReasons = cache.breakReasons.map(\.rawValue)
|
||||
self.semanticHints = cache.semanticHints.map(\.rawValue)
|
||||
}
|
||||
|
||||
func encode(with coder: NSCoder) {
|
||||
coder.encode(href, forKey: "href")
|
||||
coder.encode(rangeLocations, forKey: "rangeLocations")
|
||||
coder.encode(rangeLengths, forKey: "rangeLengths")
|
||||
coder.encode(breakReasons, forKey: "breakReasons")
|
||||
coder.encode(semanticHints, forKey: "semanticHints")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
guard let href = coder.decodeObject(of: NSString.self, forKey: "href") as String?,
|
||||
let rangeLocations = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLocations") as? [NSNumber],
|
||||
let rangeLengths = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLengths") as? [NSNumber],
|
||||
let breakReasons = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "breakReasons") as? [String],
|
||||
let semanticHints = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "semanticHints") as? [String] else {
|
||||
return nil
|
||||
}
|
||||
self.href = href
|
||||
self.rangeLocations = rangeLocations
|
||||
self.rangeLengths = rangeLengths
|
||||
self.breakReasons = breakReasons
|
||||
self.semanticHints = semanticHints
|
||||
}
|
||||
|
||||
func toCache() -> RDEPUBTextChapterPaginationCache {
|
||||
let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
|
||||
NSRange(location: loc.intValue, length: len.intValue)
|
||||
}
|
||||
return RDEPUBTextChapterPaginationCache(
|
||||
href: href,
|
||||
pageRanges: pageRanges,
|
||||
breakReasons: breakReasons.compactMap(RDEPUBTextPageBreakReason.init(rawValue:)),
|
||||
semanticHints: semanticHints.compactMap(RDEPUBTextSemanticHint.init(rawValue:))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public final class RDEPUBTextBookCache {
|
||||
|
||||
public var schemaVersion: Int = 14
|
||||
|
||||
private let queue = DispatchQueue(label: "com.RDEpubReader.textbookcache", qos: .utility)
|
||||
|
||||
private let cacheDirectory: URL
|
||||
|
||||
public init(subdirectory: String = "RDEPUBTextBookCache") {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
?? FileManager.default.temporaryDirectory.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
self.cacheDirectory = baseURL
|
||||
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
public func cacheKey(
|
||||
bookID: String,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
contentInsets: UIEdgeInsets,
|
||||
pageSize: CGSize,
|
||||
layoutConfigSignature: String = RDEPUBTextLayoutConfig.default.cacheSignature
|
||||
) -> String {
|
||||
let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_\(layoutConfigSignature)_v\(schemaVersion)"
|
||||
let digest = SHA256.hash(data: Data(raw.utf8))
|
||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return hex + ".cache"
|
||||
}
|
||||
|
||||
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
#if DEBUG
|
||||
// print("[Cache] load MISS key=\(key)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard let archive = try NSKeyedUnarchiver.unarchivedObject(
|
||||
ofClass: PaginationCacheArchive.self,
|
||||
from: data
|
||||
) else {
|
||||
#if DEBUG
|
||||
// print("[Cache] load MISS key=\(key) (unarchive returned nil)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
var result: [String: RDEPUBTextChapterPaginationCache] = [:]
|
||||
for chapter in archive.chapters {
|
||||
result[chapter.href] = chapter.toCache()
|
||||
}
|
||||
#if DEBUG
|
||||
// print("[Cache] load HIT key=\(key) chapters=\(result.count)")
|
||||
#endif
|
||||
return result
|
||||
} catch {
|
||||
#if DEBUG
|
||||
// print("[Cache] load MISS key=\(key) error=\(error)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
do {
|
||||
let archives = chapters.map { ChapterPaginationArchive(from: $0) }
|
||||
let bookArchive = PaginationCacheArchive(chapters: archives)
|
||||
let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
#if DEBUG
|
||||
print("[Cache] save key=\(key) chapters=\(chapters.count)")
|
||||
#endif
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[Cache] save FAILED key=\(key) error=\(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func invalidateAll() {
|
||||
queue.sync {
|
||||
let fileManager = FileManager.default
|
||||
guard let contents = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else {
|
||||
return
|
||||
}
|
||||
for file in contents {
|
||||
try? fileManager.removeItem(at: file)
|
||||
}
|
||||
#if DEBUG
|
||||
print("[Cache] invalidateAll")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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 RDEPUBTextChapterBuildResult {
|
||||
|
||||
public var chapter: RDEPUBTextChapter
|
||||
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic
|
||||
|
||||
public var performanceSample: RDEPUBTextPerformanceSample
|
||||
|
||||
public var cacheHit: Bool
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/// Page substring derived on demand from `chapterContent` + `contentRange`.
|
||||
/// Not stored: keeping a per-page substring alive roughly doubles the
|
||||
/// chapter's resident text memory across the chapter cache window.
|
||||
public var content: NSAttributedString {
|
||||
let bounds = NSRange(location: 0, length: chapterContent.length)
|
||||
let clamped = NSIntersectionRange(contentRange, bounds)
|
||||
guard clamped.length > 0 else { return NSAttributedString() }
|
||||
return chapterContent.attributedSubstring(from: clamped)
|
||||
}
|
||||
|
||||
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 cfiMap: RDEPUBCFIMap?
|
||||
|
||||
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
|
||||
|
||||
public var pages: [RDEPUBTextPage]
|
||||
}
|
||||
|
||||
public struct RDEPUBTextBook {
|
||||
|
||||
public var chapters: [RDEPUBTextChapter]
|
||||
|
||||
public var pages: [RDEPUBTextPage]
|
||||
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
public var positionConverter: RDEPUBTextPositionConverter {
|
||||
RDEPUBTextPositionConverter(book: self)
|
||||
}
|
||||
|
||||
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(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) 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 chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
|
||||
guard let page = page(at: pageNumber) else { return nil }
|
||||
return chapterData(forSpineIndex: page.spineIndex)
|
||||
}
|
||||
|
||||
public func chapterData(
|
||||
for location: RDEPUBLocation,
|
||||
resolver: RDEPUBResourceResolver,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBChapterData? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
return chapterData(for: normalizedLocation.href)
|
||||
}
|
||||
|
||||
public var chapterInfos: [EPUBChapterInfo] {
|
||||
chapters.map { chapter in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: chapter.spineIndex,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 chapterData = chapterData(for: normalizedLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let anchor = normalizedLocation.rangeAnchor?.start,
|
||||
let page = positionConverter.pageNumber(for: anchor) {
|
||||
return page
|
||||
}
|
||||
|
||||
if let anchor = indexTable.anchor(for: normalizedLocation),
|
||||
let page = positionConverter.pageNumber(for: anchor) {
|
||||
return page
|
||||
}
|
||||
|
||||
return chapterData.pageNumber(for: normalizedLocation)
|
||||
}
|
||||
|
||||
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let chapterData = chapterData(forPageNumber: pageNumber),
|
||||
let page = page(at: pageNumber) else {
|
||||
return nil
|
||||
}
|
||||
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol RDEPUBTextBookBuilding {
|
||||
|
||||
func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook
|
||||
}
|
||||
|
||||
struct RDEPUBChapterRenderPipeline {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
|
||||
init(renderer: RDEPUBTextRenderer) {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
func render(_ request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent {
|
||||
try renderer.renderChapter(request: request)
|
||||
}
|
||||
}
|
||||
|
||||
struct RDEPUBChapterPaginationPipeline {
|
||||
private let frameFactory: RDEPUBPageFrameBuilding
|
||||
|
||||
init(frameFactory: RDEPUBPageFrameBuilding = RDEPUBCoreTextPageFrameFactory()) {
|
||||
self.frameFactory = frameFactory
|
||||
}
|
||||
|
||||
func frames(
|
||||
for content: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
config: RDEPUBTextLayoutConfig,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
frameFactory.makeFrames(
|
||||
attributedString: content,
|
||||
pageSize: pageSize,
|
||||
config: config,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBTextBookBuilder: RDEPUBTextBookBuilding {}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBTextPerformanceSample: Equatable {
|
||||
|
||||
public var chapterHref: String
|
||||
|
||||
public var renderDuration: TimeInterval
|
||||
|
||||
public var paginateDuration: TimeInterval
|
||||
|
||||
public var pageCount: Int
|
||||
|
||||
public var attributedStringLength: Int
|
||||
|
||||
public var cacheHit: Bool
|
||||
|
||||
public init(
|
||||
chapterHref: String,
|
||||
renderDuration: TimeInterval,
|
||||
paginateDuration: TimeInterval,
|
||||
pageCount: Int,
|
||||
attributedStringLength: Int,
|
||||
cacheHit: Bool
|
||||
) {
|
||||
self.chapterHref = chapterHref
|
||||
self.renderDuration = renderDuration
|
||||
self.paginateDuration = paginateDuration
|
||||
self.pageCount = pageCount
|
||||
self.attributedStringLength = attributedStringLength
|
||||
self.cacheHit = cacheHit
|
||||
}
|
||||
}
|
||||
|
||||
public final class RDEPUBTextPerformanceSampler {
|
||||
|
||||
public private(set) var samples: [RDEPUBTextPerformanceSample] = []
|
||||
|
||||
public var totalBuildDuration: TimeInterval = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
public func record(_ sample: RDEPUBTextPerformanceSample) {
|
||||
samples.append(sample)
|
||||
#if DEBUG
|
||||
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
|
||||
#endif
|
||||
}
|
||||
|
||||
public func summary() -> String {
|
||||
let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
|
||||
let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
|
||||
let hitCount = samples.filter(\.cacheHit).count
|
||||
return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
samples.removeAll()
|
||||
totalBuildDuration = 0
|
||||
}
|
||||
|
||||
private func formatMS(_ duration: TimeInterval) -> String {
|
||||
String(format: "%.0fms", duration * 1000)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user