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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
struct RDEPUBChapterPageCounter {
|
||||
|
||||
private let factory: RDEPUBCoreTextPageFrameFactory
|
||||
|
||||
private let attributedString: NSAttributedString
|
||||
|
||||
private let pageSize: CGSize
|
||||
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
|
||||
private let pageBreakPolicy: RDEPUBPageBreakPolicy
|
||||
|
||||
private let framesetter: CTFramesetter
|
||||
|
||||
private let dtLayoutRect: CGRect
|
||||
|
||||
init(factory: RDEPUBCoreTextPageFrameFactory) {
|
||||
self.factory = factory
|
||||
self.attributedString = factory.attributedString
|
||||
self.pageSize = factory.pageSize
|
||||
self.config = factory.config
|
||||
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: factory.attributedString)
|
||||
self.framesetter = CTFramesetterCreateWithAttributedString(factory.attributedString)
|
||||
self.dtLayoutRect = factory.config.contentRect(fallback: factory.pageSize)
|
||||
}
|
||||
|
||||
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets)
|
||||
#else
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
|
||||
let contentRect = config.contentRect(fallback: resolvedSize)
|
||||
|
||||
guard contentRect.width > 0, contentRect.height > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
while location < attributedString.length {
|
||||
let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath(
|
||||
pageSize: resolvedSize,
|
||||
config: config
|
||||
)
|
||||
let frame = CTFramesetterCreateFrame(
|
||||
framesetter,
|
||||
CFRangeMake(location, 0),
|
||||
framePath,
|
||||
nil
|
||||
)
|
||||
let proposedRange = proposedVisibleRange(
|
||||
from: frame,
|
||||
start: location,
|
||||
totalLength: attributedString.length
|
||||
)
|
||||
guard proposedRange.length > 0 else {
|
||||
break
|
||||
}
|
||||
|
||||
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
|
||||
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
|
||||
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: keepWithNextAdjusted,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
|
||||
|
||||
let adjusted = pageBreakPolicy.adjustedRange(
|
||||
from: widowOrphanAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: effectiveLineRanges,
|
||||
factory: factory
|
||||
)
|
||||
let trailingFragmentID = factory.nearestTrailingFragmentID(
|
||||
endingAt: adjusted.range.location + adjusted.range.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
frames.append(
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: adjusted.range,
|
||||
breakReason: adjusted.breakReason,
|
||||
blockRange: adjusted.blockRange,
|
||||
attachmentRanges: adjusted.attachmentRanges,
|
||||
attachmentKinds: adjusted.attachmentKinds,
|
||||
blockKinds: adjusted.blockKinds,
|
||||
semanticHints: adjusted.semanticHints,
|
||||
attachmentPlacements: adjusted.attachmentPlacements,
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: adjusted.diagnostics
|
||||
)
|
||||
)
|
||||
|
||||
let nextLocation = adjusted.range.location + adjusted.range.length
|
||||
guard nextLocation > location else {
|
||||
location += max(proposedRange.length, 1)
|
||||
continue
|
||||
}
|
||||
location = nextLocation
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard config.numberOfColumns == 1 else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let pageRect = dtLayoutRect
|
||||
let isDebug = ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
|
||||
if isDebug {
|
||||
print("[PAGINATION-DEBUG] pageRect=\(pageRect) totalLength=\(attributedString.length)")
|
||||
}
|
||||
|
||||
while location < attributedString.length {
|
||||
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
|
||||
break
|
||||
}
|
||||
|
||||
let visibleRange = layoutFrame.visibleStringRange()
|
||||
guard visibleRange.length > 0 else {
|
||||
break
|
||||
}
|
||||
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
|
||||
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
|
||||
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: lineAdjusted,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
|
||||
if isDebug {
|
||||
let pageCount = frames.count + 1
|
||||
let previewText = (attributedString.string as NSString).substring(with: NSRange(location: location, length: min(20, attributedString.length - location)))
|
||||
let avoidRemoved = proposedRange.length - avoidAdjusted.length
|
||||
let kwNextRemoved = avoidAdjusted.length - lineAdjusted.length
|
||||
let widowRemoved = lineAdjusted.length - widowOrphanAdjusted.length
|
||||
let totalRemoved = proposedRange.length - widowOrphanAdjusted.length
|
||||
print("[PAGINATION-DEBUG] page#\(pageCount) loc=\(location) proposed=\(proposedRange.length) avoid(-\(avoidRemoved)) kwNext(-\(kwNextRemoved)) widowOrphan(-\(widowRemoved)) total(-\(totalRemoved)) lastLine=\"\(previewText)\"")
|
||||
}
|
||||
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
|
||||
let adjusted = pageBreakPolicy.adjustedRange(
|
||||
from: widowOrphanAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: effectiveLineRanges,
|
||||
factory: factory
|
||||
)
|
||||
let verifiedRange: NSRange
|
||||
if adjusted.breakReason == .attachmentBoundary {
|
||||
verifiedRange = verifiedDisplayRange(for: adjusted.range)
|
||||
} else {
|
||||
verifiedRange = adjusted.range
|
||||
}
|
||||
let trailingFragmentID = factory.nearestTrailingFragmentID(
|
||||
endingAt: verifiedRange.location + verifiedRange.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
let diagnostics = verifiedRange == adjusted.range
|
||||
? adjusted.diagnostics
|
||||
: adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"]
|
||||
|
||||
frames.append(
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: verifiedRange,
|
||||
breakReason: adjusted.breakReason,
|
||||
blockRange: factory.blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
|
||||
attachmentRanges: factory.attachmentRanges(in: verifiedRange),
|
||||
attachmentKinds: factory.attachmentKinds(in: verifiedRange),
|
||||
blockKinds: factory.blockKinds(in: verifiedRange),
|
||||
semanticHints: factory.semanticHints(in: verifiedRange),
|
||||
attachmentPlacements: factory.attachmentPlacements(in: verifiedRange),
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: diagnostics
|
||||
)
|
||||
)
|
||||
|
||||
let nextLocation = verifiedRange.location + verifiedRange.length
|
||||
guard nextLocation > location else {
|
||||
location += max(visibleRange.length, 1)
|
||||
continue
|
||||
}
|
||||
location = nextLocation
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
private func verifiedDisplayRange(for range: NSRange) -> NSRange {
|
||||
guard let clampedRange = factory.clampedRange(range),
|
||||
clampedRange.length > 0,
|
||||
!factory.attachmentRanges(in: clampedRange).isEmpty else {
|
||||
return range
|
||||
}
|
||||
|
||||
let pageContent = attributedString.attributedSubstring(from: clampedRange)
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
|
||||
return clampedRange
|
||||
}
|
||||
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
|
||||
return clampedRange
|
||||
}
|
||||
|
||||
let visibleRange = layoutFrame.visibleStringRange()
|
||||
guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
|
||||
return clampedRange
|
||||
}
|
||||
|
||||
return NSRange(location: clampedRange.location, length: visibleRange.length)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func proposedVisibleRange(
|
||||
from frame: CTFrame,
|
||||
start location: Int,
|
||||
totalLength: Int
|
||||
) -> NSRange {
|
||||
let visibleRange = CTFrameGetVisibleStringRange(frame)
|
||||
guard visibleRange.length > 0 else {
|
||||
return NSRange(location: location, length: 0)
|
||||
}
|
||||
|
||||
let resolvedLocation = max(location, visibleRange.location)
|
||||
let visibleEnd = visibleRange.location + visibleRange.length
|
||||
let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation)
|
||||
guard length > 0 else {
|
||||
return NSRange(location: resolvedLocation, length: 0)
|
||||
}
|
||||
return NSRange(location: resolvedLocation, length: length)
|
||||
}
|
||||
|
||||
private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] {
|
||||
lineRanges.filter { lineRange in
|
||||
lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range)
|
||||
}
|
||||
}
|
||||
}
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
|
||||
let attributedString: NSAttributedString
|
||||
|
||||
let pageSize: CGSize
|
||||
|
||||
let config: RDEPUBTextLayoutConfig
|
||||
|
||||
private let pageBreakPolicy: RDEPUBPageBreakPolicy
|
||||
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||||
self.attributedString = attributedString
|
||||
self.pageSize = pageSize
|
||||
self.config = config
|
||||
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString)
|
||||
}
|
||||
|
||||
init() {
|
||||
self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default)
|
||||
}
|
||||
|
||||
func makeFrames(
|
||||
attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
config: RDEPUBTextLayoutConfig,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
|
||||
let counter = RDEPUBChapterPageCounter(factory: factory)
|
||||
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
|
||||
let columnRects = config.columnRects(fallback: pageSize)
|
||||
guard columnRects.count > 1 else {
|
||||
return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
|
||||
}
|
||||
|
||||
let path = CGMutablePath()
|
||||
for rect in columnRects {
|
||||
path.addRect(rect)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func trimmedRangeForAvoidPageBreakInside(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard config.avoidPageBreakInsideEnabled else { return proposed }
|
||||
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
guard !lines.isEmpty else { return proposed }
|
||||
|
||||
let lineRanges = lines.map {
|
||||
let range = CTLineGetStringRange($0)
|
||||
return NSRange(location: range.location, length: range.length)
|
||||
}
|
||||
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
func trimmedRangeForKeepWithNext(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
let lineRanges = lines.map {
|
||||
let range = CTLineGetStringRange($0)
|
||||
return NSRange(location: range.location, length: range.length)
|
||||
}
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
func trimmedRangeForAvoidPageBreakInside(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard config.avoidPageBreakInsideEnabled else { return proposed }
|
||||
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||||
return proposed
|
||||
}
|
||||
let lineRanges = lines.map { $0.stringRange() }
|
||||
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
func trimmedRangeForKeepWithNext(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||||
return proposed
|
||||
}
|
||||
let lineRanges = lines.map { $0.stringRange() }
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
#endif
|
||||
|
||||
func trimmedRangeForAvoidPageBreakInside(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange {
|
||||
guard config.avoidPageBreakInsideEnabled, !lineRanges.isEmpty else { return proposed }
|
||||
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for lineRange in lineRanges.reversed() {
|
||||
if pageBreakPolicy.lineIsInAvoidPageBreakInsideBlock(lineRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lineRanges.count - linesToRemove
|
||||
guard validLineCount > 0 else { return proposed }
|
||||
|
||||
let lastValidLine = lineRanges[validLineCount - 1]
|
||||
let endLocation = lastValidLine.location + lastValidLine.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
|
||||
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
|
||||
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
|
||||
print("[PAGINATION-DEBUG] avoidPageBreakInside removed \(linesToRemove) lines: \"\(removedText)\"")
|
||||
}
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
func trimmedRangeForKeepWithNext(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange {
|
||||
guard !lineRanges.isEmpty else { return proposed }
|
||||
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for lineRange in lineRanges.reversed() {
|
||||
if pageBreakPolicy.lineIsInKeepWithNextBlock(lineRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lineRanges.count - linesToRemove
|
||||
guard validLineCount > 0 else { return proposed }
|
||||
|
||||
let lastValidLine = lineRanges[validLineCount - 1]
|
||||
let endLocation = lastValidLine.location + lastValidLine.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
|
||||
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
|
||||
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
|
||||
print("[PAGINATION-DEBUG] keepWithNext removed \(linesToRemove) lines: \"\(removedText)\"")
|
||||
}
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
func trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange {
|
||||
guard !lineRanges.isEmpty else { return proposed }
|
||||
guard config.avoidWidows || config.avoidOrphans else { return proposed }
|
||||
|
||||
var adjusted = proposed
|
||||
var visibleLines = lineRanges
|
||||
|
||||
if config.avoidWidows,
|
||||
let widowAdjusted = trimmedRangeAvoidingWidow(
|
||||
proposed: adjusted,
|
||||
lineRanges: visibleLines
|
||||
),
|
||||
widowAdjusted != adjusted {
|
||||
adjusted = widowAdjusted
|
||||
visibleLines = Array(visibleLines.dropLast())
|
||||
}
|
||||
|
||||
if config.avoidOrphans,
|
||||
let orphanAdjusted = trimmedRangeAvoidingOrphan(
|
||||
proposed: adjusted,
|
||||
lineRanges: visibleLines
|
||||
) {
|
||||
adjusted = orphanAdjusted
|
||||
}
|
||||
|
||||
return adjusted
|
||||
}
|
||||
|
||||
static func lineRanges(from frame: CTFrame) -> [NSRange] {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
return lines.map {
|
||||
let lineRange = CTLineGetStringRange($0)
|
||||
return NSRange(location: lineRange.location, length: lineRange.length)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
||||
return []
|
||||
}
|
||||
return lines.map { $0.stringRange() }
|
||||
}
|
||||
#endif
|
||||
|
||||
func blockRange(at location: Int) -> NSRange? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
if let encodedRange = attributes[.rdPageBlockRange] as? String {
|
||||
return NSRangeFromString(encodedRange)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageBlockKind] as? String else { return nil }
|
||||
return RDEPUBTextBlockKind(rawValue: rawValue)
|
||||
}
|
||||
|
||||
func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageAttachmentPlacement] as? String else { return nil }
|
||||
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
|
||||
}
|
||||
|
||||
func paragraphRange(containing location: Int) -> NSRange {
|
||||
let source = attributedString.string as NSString
|
||||
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
|
||||
let safeLocation = min(max(location, 0), max(source.length - 1, 0))
|
||||
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
|
||||
}
|
||||
|
||||
func attachmentRanges(in range: NSRange) -> [NSRange] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var results: [NSRange] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
|
||||
guard value != nil else { return }
|
||||
results.append(attributeRange)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
|
||||
guard location >= 0, location < attributedString.length else { return [] }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return [] }
|
||||
return rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
private func trimmedRangeAvoidingWidow(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange? {
|
||||
guard lineRanges.count >= 2,
|
||||
let lastLine = lineRanges.last else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let paragraphRange = paragraphRange(containing: lastLine.location)
|
||||
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
|
||||
let paragraphContinuesOnNextPage = NSMaxRange(proposed) < NSMaxRange(paragraphRange)
|
||||
|
||||
guard trailingLines == 1, paragraphContinuesOnNextPage else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let keptLine = lineRanges[lineRanges.count - 2]
|
||||
let adjustedLength = NSMaxRange(keptLine) - proposed.location
|
||||
guard adjustedLength > 0 else { return nil }
|
||||
|
||||
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
|
||||
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: NSMaxRange(keptLine), length: min(proposed.length - adjustedLength, 40)))
|
||||
print("[PAGINATION-DEBUG] widow control removed 1 line: \"\(removedText)\" paragraphRange=\(NSStringFromRange(paragraphRange))")
|
||||
}
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
private func trimmedRangeAvoidingOrphan(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange? {
|
||||
guard lineRanges.count >= 2,
|
||||
let lastLine = lineRanges.last else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let paragraphRange = paragraphRange(containing: lastLine.location)
|
||||
let pageEnd = NSMaxRange(proposed)
|
||||
let paragraphEnd = NSMaxRange(paragraphRange)
|
||||
guard pageEnd < paragraphEnd else { return nil }
|
||||
|
||||
let remainingLineCount = estimatedRemainingLineCount(
|
||||
in: paragraphRange,
|
||||
startingAt: pageEnd
|
||||
)
|
||||
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
|
||||
guard remainingLineCount == 1,
|
||||
trailingLines >= 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let keptLine = lineRanges[lineRanges.count - 2]
|
||||
let adjustedLength = NSMaxRange(keptLine) - proposed.location
|
||||
guard adjustedLength > 0 else { return nil }
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
private func trailingLineCount(
|
||||
in lineRanges: [NSRange],
|
||||
paragraphRange: NSRange
|
||||
) -> Int {
|
||||
var count = 0
|
||||
for lineRange in lineRanges.reversed() {
|
||||
if NSIntersectionRange(lineRange, paragraphRange).length > 0 {
|
||||
count += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private func estimatedRemainingLineCount(
|
||||
in paragraphRange: NSRange,
|
||||
startingAt location: Int
|
||||
) -> Int {
|
||||
let paragraphEnd = NSMaxRange(paragraphRange)
|
||||
var currentLocation = max(location, paragraphRange.location)
|
||||
guard currentLocation < paragraphEnd else { return 0 }
|
||||
|
||||
let width = max(config.columnRects(fallback: pageSize).first?.width ?? config.contentRect(fallback: pageSize).width, 1)
|
||||
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
|
||||
var lineCount = 0
|
||||
|
||||
while currentLocation < paragraphEnd {
|
||||
let suggestedCount = CTTypesetterSuggestLineBreak(typesetter, currentLocation, Double(width))
|
||||
let lineLength = max(suggestedCount, 1)
|
||||
currentLocation += lineLength
|
||||
lineCount += 1
|
||||
if lineCount > 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return lineCount
|
||||
}
|
||||
|
||||
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var kinds: [RDEPUBTextAttachmentKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
|
||||
!kinds.contains(kind) else {
|
||||
return
|
||||
}
|
||||
kinds.append(kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var kinds: [RDEPUBTextBlockKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||||
!kinds.contains(kind) else {
|
||||
return
|
||||
}
|
||||
kinds.append(kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var hints: [RDEPUBTextSemanticHint] = []
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String else { return }
|
||||
for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
|
||||
hints.append(hint)
|
||||
}
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var placements: [RDEPUBTextAttachmentPlacement] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||||
!placements.contains(placement) else {
|
||||
return
|
||||
}
|
||||
placements.append(placement)
|
||||
}
|
||||
return placements
|
||||
}
|
||||
|
||||
func clampedRange(_ range: NSRange) -> NSRange? {
|
||||
guard range.location >= 0, range.length >= 0 else { return nil }
|
||||
guard attributedString.length > 0 else {
|
||||
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
|
||||
}
|
||||
guard range.location < attributedString.length else { return nil }
|
||||
|
||||
let maxLength = attributedString.length - range.location
|
||||
return NSRange(location: range.location, length: min(range.length, maxLength))
|
||||
}
|
||||
|
||||
func nearestTrailingFragmentID(
|
||||
endingAt location: Int,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> String? {
|
||||
fragmentOffsets
|
||||
.filter { $0.value <= location }
|
||||
.max { lhs, rhs in lhs.value < rhs.value }?
|
||||
.key
|
||||
}
|
||||
|
||||
func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
attachmentRanges: [NSRange],
|
||||
blockRange: NSRange?,
|
||||
blockKinds: [RDEPUBTextBlockKind],
|
||||
semanticHints: [RDEPUBTextSemanticHint],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||||
trigger: String? = nil
|
||||
) -> [String] {
|
||||
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
|
||||
if let blockRange {
|
||||
items.append("block range: \(NSStringFromRange(blockRange))")
|
||||
}
|
||||
if !attachmentRanges.isEmpty {
|
||||
items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
|
||||
}
|
||||
if !blockKinds.isEmpty {
|
||||
items.append("block kinds: \(blockKinds.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if !semanticHints.isEmpty {
|
||||
items.append("semantic hints: \(semanticHints.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if !attachmentPlacements.isEmpty {
|
||||
items.append("attachment placements: \(attachmentPlacements.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if let trigger, !trigger.isEmpty {
|
||||
items.append("semantic trigger: \(trigger)")
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBPageBreakPolicy {
|
||||
|
||||
private let attributedString: NSAttributedString
|
||||
|
||||
init(attributedString: NSAttributedString) {
|
||||
self.attributedString = attributedString
|
||||
}
|
||||
|
||||
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
||||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
||||
return false
|
||||
}
|
||||
var found = false
|
||||
attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
|
||||
guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
|
||||
return
|
||||
}
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] 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
|
||||
}
|
||||
|
||||
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
||||
return false
|
||||
}
|
||||
var found = false
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
|
||||
guard let rawValue = value as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
if hints.contains(.keepWithNext) {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func adjustedRange(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int,
|
||||
lineRanges: [NSRange],
|
||||
factory: RDEPUBCoreTextPageFrameFactory
|
||||
) -> (
|
||||
range: NSRange,
|
||||
breakReason: RDEPUBTextPageBreakReason,
|
||||
blockRange: NSRange?,
|
||||
attachmentRanges: [NSRange],
|
||||
attachmentKinds: [RDEPUBTextAttachmentKind],
|
||||
blockKinds: [RDEPUBTextBlockKind],
|
||||
semanticHints: [RDEPUBTextSemanticHint],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||||
diagnostics: [String]
|
||||
) {
|
||||
let pageEnd = proposedRange.location + proposedRange.length
|
||||
let proposedBlockKinds = factory.blockKinds(in: proposedRange)
|
||||
let proposedSemanticHints = factory.semanticHints(in: proposedRange)
|
||||
let proposedAttachmentPlacements = factory.attachmentPlacements(in: proposedRange)
|
||||
guard pageEnd < totalLength else {
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||||
attachmentRanges: factory.attachmentRanges(in: proposedRange),
|
||||
attachmentKinds: factory.attachmentKinds(in: proposedRange),
|
||||
blockKinds: proposedBlockKinds,
|
||||
semanticHints: proposedSemanticHints,
|
||||
attachmentPlacements: proposedAttachmentPlacements,
|
||||
diagnostics: factory.diagnostics(
|
||||
reason: .chapterEnd,
|
||||
range: proposedRange,
|
||||
attachmentRanges: factory.attachmentRanges(in: proposedRange),
|
||||
blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||||
blockKinds: proposedBlockKinds,
|
||||
semanticHints: proposedSemanticHints,
|
||||
attachmentPlacements: proposedAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let currentBlockRange = factory.blockRange(at: max(proposedRange.location, pageEnd - 1))
|
||||
let currentAttachmentRanges = factory.attachmentRanges(in: proposedRange)
|
||||
let currentAttachmentKinds = factory.attachmentKinds(in: proposedRange)
|
||||
let currentBlockKinds = proposedBlockKinds
|
||||
let currentSemanticHints = proposedSemanticHints
|
||||
let currentAttachmentPlacements = proposedAttachmentPlacements
|
||||
|
||||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||||
after: proposedRange,
|
||||
minimumEnd: proposedRange.location + 1,
|
||||
lineRanges: lineRanges,
|
||||
factory: factory
|
||||
) {
|
||||
let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location)
|
||||
return (
|
||||
range: adjustedRange,
|
||||
breakReason: .semanticBoundary,
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: factory.diagnostics(
|
||||
reason: .semanticBoundary,
|
||||
range: adjustedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
trigger: RDEPUBTextSemanticHint.pageRelate.rawValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .frameLimit,
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: factory.diagnostics(
|
||||
reason: .frameLimit,
|
||||
range: proposedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
minimumEnd: Int,
|
||||
lineRanges: [NSRange],
|
||||
factory: RDEPUBCoreTextPageFrameFactory
|
||||
) -> Int? {
|
||||
let pageStartOfNext = range.location + range.length
|
||||
guard pageStartOfNext > range.location,
|
||||
pageStartOfNext < attributedString.length,
|
||||
lineRanges.count >= 2,
|
||||
factory.semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let boundaryBlockStart = factory.blockRange(at: pageStartOfNext)?.location
|
||||
?? factory.paragraphRange(containing: pageStartOfNext).location
|
||||
guard boundaryBlockStart == pageStartOfNext else { return nil }
|
||||
|
||||
let lastLineStart = lineRanges[lineRanges.count - 1].location
|
||||
guard lastLineStart > range.location, lastLineStart >= minimumEnd else {
|
||||
return nil
|
||||
}
|
||||
return lastLineStart
|
||||
}
|
||||
|
||||
private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
|
||||
return false
|
||||
}
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
guard hints.contains(.avoidPageBreakInside) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let placement = (attributes[.rdPageAttachmentPlacement] as? String)
|
||||
.flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
|
||||
let blockKind = (attributes[.rdPageBlockKind] as? String)
|
||||
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
|
||||
|
||||
// Inline attachments (footnote icons, rare-character images) flow with
|
||||
// the surrounding text, so their avoid hint must not lock the line to
|
||||
// the next page. Placement is checked besides blockKind because an
|
||||
// enclosing paragraph's semantics overwrite blockKind on the
|
||||
// attachment's range, while placement survives.
|
||||
if blockKind == .attachment || placement != nil, placement != .centered {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func clampedProbeRange(for lineRange: NSRange) -> NSRange? {
|
||||
clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1)))
|
||||
}
|
||||
|
||||
private func clampedRange(_ range: NSRange) -> NSRange? {
|
||||
guard range.location >= 0, range.length >= 0 else { return nil }
|
||||
guard attributedString.length > 0 else {
|
||||
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
|
||||
}
|
||||
guard range.location < attributedString.length else { return nil }
|
||||
|
||||
let maxLength = attributedString.length - range.location
|
||||
return NSRange(location: range.location, length: min(range.length, maxLength))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBTextLayoutFrame: Equatable {
|
||||
|
||||
var contentRange: NSRange
|
||||
|
||||
var breakReason: RDEPUBTextPageBreakReason
|
||||
|
||||
var blockRange: NSRange?
|
||||
|
||||
var attachmentRanges: [NSRange]
|
||||
|
||||
var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
|
||||
var blockKinds: [RDEPUBTextBlockKind]
|
||||
|
||||
var semanticHints: [RDEPUBTextSemanticHint]
|
||||
|
||||
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
|
||||
var trailingFragmentID: String?
|
||||
|
||||
var diagnostics: [String]
|
||||
|
||||
var metadata: RDEPUBTextPageMetadata {
|
||||
RDEPUBTextPageMetadata(
|
||||
breakReason: breakReason,
|
||||
blockRange: blockRange,
|
||||
attachmentRanges: attachmentRanges,
|
||||
attachmentKinds: attachmentKinds,
|
||||
blockKinds: blockKinds,
|
||||
semanticHints: semanticHints,
|
||||
attachmentPlacements: attachmentPlacements,
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: diagnostics
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
struct RDEPUBTextLayouter {
|
||||
private let counter: RDEPUBChapterPageCounter
|
||||
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||||
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
|
||||
self.counter = RDEPUBChapterPageCounter(factory: factory)
|
||||
}
|
||||
|
||||
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
|
||||
counter.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBPageBreakDecision {
|
||||
|
||||
var range: NSRange
|
||||
|
||||
var reason: RDEPUBTextPageBreakReason
|
||||
|
||||
var diagnostics: [String]
|
||||
}
|
||||
|
||||
protocol RDEPUBChapterPageCounting {
|
||||
|
||||
func pageRanges(
|
||||
for attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
config: RDEPUBTextLayoutConfig,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> [RDEPUBPageBreakDecision]
|
||||
}
|
||||
|
||||
protocol RDEPUBPageFrameBuilding {
|
||||
|
||||
func makeFrames(
|
||||
attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
config: RDEPUBTextLayoutConfig,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> [RDEPUBTextLayoutFrame]
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
extension NSAttributedString {
|
||||
|
||||
func rd_paginatedFrames(
|
||||
size: CGSize,
|
||||
fragmentOffsets: [String: Int] = [:],
|
||||
config: RDEPUBTextLayoutConfig = .default
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: self, pageSize: size, config: config)
|
||||
let counter = RDEPUBChapterPageCounter(factory: factory)
|
||||
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
func ss_pageRanges(size: CGSize) -> [NSRange] {
|
||||
rd_paginatedFrames(size: size).map(\.contentRange)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import UIKit
|
||||
|
||||
public final class RDEPUBChapterData {
|
||||
|
||||
public let chapter: RDEPUBTextChapter
|
||||
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
|
||||
self.chapter = chapter
|
||||
self.indexTable = indexTable
|
||||
}
|
||||
|
||||
public var chapterIndex: Int { chapter.chapterIndex }
|
||||
|
||||
public var spineIndex: Int { chapter.spineIndex }
|
||||
|
||||
public var href: String { chapter.href }
|
||||
|
||||
public var title: String { chapter.title }
|
||||
|
||||
public var attributedContent: NSAttributedString { chapter.attributedContent }
|
||||
|
||||
public var pages: [RDEPUBTextPage] { chapter.pages }
|
||||
|
||||
public var pageCount: Int { chapter.pages.count }
|
||||
|
||||
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
|
||||
|
||||
public var chapterInfo: EPUBChapterInfo {
|
||||
EPUBChapterInfo(spineIndex: spineIndex, title: title, pageCount: pageCount)
|
||||
}
|
||||
|
||||
public var absolutePageRange: ClosedRange<Int>? {
|
||||
guard let firstPage = pages.first, let lastPage = pages.last else { return nil }
|
||||
return firstPage.absolutePageIndex...lastPage.absolutePageIndex
|
||||
}
|
||||
|
||||
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
|
||||
}
|
||||
|
||||
public func pageNumber(containing absoluteOffset: Int) -> Int? {
|
||||
page(containing: absoluteOffset)?.absolutePageIndex
|
||||
}
|
||||
|
||||
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
|
||||
}
|
||||
|
||||
public func page(atPageNumber pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil }
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
|
||||
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
|
||||
}
|
||||
|
||||
public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
|
||||
indexTable.anchor(forGlobalIndex: index)
|
||||
}
|
||||
|
||||
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
|
||||
let start = anchor(forAbsoluteIndex: absoluteRange.location)
|
||||
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
|
||||
return RDEPUBTextRangeAnchor(start: start, end: end)
|
||||
}
|
||||
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
|
||||
guard page(containing: absoluteRange.location) != nil else { return nil }
|
||||
let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
|
||||
let text = chapter.attributedContent.attributedSubstring(from: absoluteRange).string
|
||||
let rangeInfo = RDEPUBTextOffsetRangeInfo(
|
||||
href: chapter.href,
|
||||
start: absoluteRange.location,
|
||||
end: absoluteRange.location + absoluteRange.length
|
||||
).jsonString()
|
||||
return RDEPUBSelection(
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
text: text,
|
||||
rangeInfo: rangeInfo
|
||||
)
|
||||
}
|
||||
|
||||
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
indexTable.location(
|
||||
for: rangeAnchor(for: absoluteRange),
|
||||
in: chapter,
|
||||
bookIdentifier: bookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
location(for: page.contentRange, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
public func page(for location: RDEPUBLocation) -> RDEPUBTextPage? {
|
||||
guard let range = absoluteRange(for: location) else { return nil }
|
||||
return page(containing: range.location)
|
||||
}
|
||||
|
||||
public func page(for searchMatch: RDEPUBSearchMatch) -> RDEPUBTextPage? {
|
||||
guard let range = absoluteRange(for: searchMatch) else { return nil }
|
||||
return page(containing: range.location)
|
||||
}
|
||||
|
||||
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
return NSRange(location: indexTable.chapterOffset(for: anchor), length: 1)
|
||||
}
|
||||
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let fragment = location.fragment,
|
||||
let offset = fragmentOffsets[fragment] {
|
||||
return NSRange(location: offset, length: 1)
|
||||
}
|
||||
|
||||
let lastOffset = max(attributedContent.length - 1, 0)
|
||||
let offset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
|
||||
return NSRange(location: offset, length: 1)
|
||||
}
|
||||
|
||||
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let endpointRange = chapterRange(fromLocationCFIEndpoints: highlight.location) {
|
||||
return endpointRange
|
||||
}
|
||||
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
|
||||
}
|
||||
|
||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
let location = indexTable.chapterOffset(for: anchor)
|
||||
return NSRange(
|
||||
location: location,
|
||||
length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: location)
|
||||
)
|
||||
}
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
|
||||
}
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func globalRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
return NSRange(location: indexTable.globalIndex(for: anchor), length: 1)
|
||||
}
|
||||
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
guard let chapterRange = absoluteRange(for: location),
|
||||
let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
public func globalRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let endpointRange = globalRange(fromLocationCFIEndpoints: highlight.location) {
|
||||
return endpointRange
|
||||
}
|
||||
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
guard let chapterRange = absoluteRange(for: highlight),
|
||||
let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
public func globalRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
let chapterLocation = indexTable.chapterOffset(for: anchor)
|
||||
return NSRange(
|
||||
location: indexTable.globalIndex(for: anchor),
|
||||
length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: chapterLocation)
|
||||
)
|
||||
}
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
guard let chapterRange = absoluteRange(for: searchMatch),
|
||||
let chapterStart = indexTable.chapterStartOffset(forFileIndex: spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return allHighlights.filter { highlight in
|
||||
guard highlight.location.href == chapter.href else { return false }
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return NSIntersectionRange(indexTable.chapterRange(for: rangeAnchor), page.contentRange).length > 0
|
||||
}
|
||||
if let anchor = highlight.location.rangeAnchor?.start {
|
||||
return pageRange.contains(absoluteOffset(for: anchor))
|
||||
}
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
||||
return false
|
||||
}
|
||||
return NSIntersectionRange(range, page.contentRange).length > 0
|
||||
}
|
||||
}
|
||||
|
||||
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
return matches.filter { match in
|
||||
guard match.href == chapter.href else { return false }
|
||||
if let range = absoluteRange(for: match) {
|
||||
return NSIntersectionRange(range, page.contentRange).length > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func searchResults(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
searchMatches(on: page, from: matches)
|
||||
}
|
||||
|
||||
public func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
guard let page = page(for: location) else { return nil }
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
public func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||
guard let page = page(for: searchMatch) else { return nil }
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
public func contains(
|
||||
tableOfContentsItem item: EPUBTableOfContentsItem,
|
||||
normalizer: (String) -> String?
|
||||
) -> Bool {
|
||||
guard let chapterHref = normalizer(href),
|
||||
let itemHref = normalizer(item.href.components(separatedBy: "#").first ?? item.href) else {
|
||||
return false
|
||||
}
|
||||
return chapterHref == itemHref
|
||||
}
|
||||
|
||||
public func tableOfContentsItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
normalizer: (String) -> String?
|
||||
) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item -> [EPUBTableOfContentsItem] in
|
||||
let descendants = tableOfContentsItems(from: item.children, normalizer: normalizer)
|
||||
return contains(tableOfContentsItem: item, normalizer: normalizer) ? [item] + descendants : descendants
|
||||
}
|
||||
}
|
||||
|
||||
public func primaryTableOfContentsItem(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
normalizer: (String) -> String?
|
||||
) -> EPUBTableOfContentsItem? {
|
||||
tableOfContentsItems(from: items, normalizer: normalizer).first
|
||||
}
|
||||
|
||||
public func applyHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
for highlight in highlights {
|
||||
guard highlight.location.href == chapter.href else { continue }
|
||||
guard let range = absoluteRange(for: highlight) else { continue }
|
||||
let overlap = NSIntersectionRange(range, page.contentRange)
|
||||
guard overlap.length > 0 else { continue }
|
||||
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
|
||||
guard relativeRange.location >= 0,
|
||||
relativeRange.location + relativeRange.length <= content.length else { continue }
|
||||
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
|
||||
case .underline:
|
||||
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
|
||||
let lowerBound = page.pageStartOffset
|
||||
let upperBound = page.pageEndOffset + 1
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
|
||||
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
indexTable.chapterOffset(for: anchor)
|
||||
}
|
||||
|
||||
private func chapterRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
|
||||
guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
|
||||
let startAnchor = indexTable.anchor(for: startCFI),
|
||||
let endAnchor = indexTable.anchor(for: endCFI),
|
||||
startAnchor.fileIndex == endAnchor.fileIndex else {
|
||||
return nil
|
||||
}
|
||||
let start = indexTable.chapterOffset(for: startAnchor)
|
||||
let end = indexTable.chapterOffset(for: endAnchor)
|
||||
return NSRange(location: min(start, end), length: max(abs(end - start), 1))
|
||||
}
|
||||
|
||||
private func globalRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
|
||||
guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
|
||||
let startAnchor = indexTable.anchor(for: startCFI),
|
||||
let endAnchor = indexTable.anchor(for: endCFI),
|
||||
startAnchor.fileIndex == endAnchor.fileIndex else {
|
||||
return nil
|
||||
}
|
||||
let start = indexTable.globalIndex(for: startAnchor)
|
||||
let end = indexTable.globalIndex(for: endAnchor)
|
||||
return NSRange(location: min(start, end), length: max(abs(end - start), 1))
|
||||
}
|
||||
|
||||
private func recoveredSearchRangeLength(
|
||||
for searchMatch: RDEPUBSearchMatch,
|
||||
cfi: RDEPUBCFI,
|
||||
startOffset: Int
|
||||
) -> Int {
|
||||
let exactLength = cfi.textAssertion?.exact?.utf16.count ?? 0
|
||||
let fallbackLength = max(searchMatch.rangeLength, 1)
|
||||
let candidateLength = exactLength > 0 ? exactLength : fallbackLength
|
||||
let remainingLength = max(attributedContent.length - startOffset, 1)
|
||||
return min(max(candidateLength, 1), remainingLength)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
public init() {}
|
||||
|
||||
public static var isAvailable: Bool {
|
||||
#if canImport(DTCoreText)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
public func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
#if canImport(DTCoreText)
|
||||
let chapterContext = request.context
|
||||
guard let data = chapterContext.html.data(using: .utf8) else {
|
||||
throw RDEPUBTextRenderingError.htmlEncodingFailed
|
||||
}
|
||||
|
||||
guard let rendered = makeAttributedString(from: data, request: request) else {
|
||||
return fallbackRenderedContent(request: request)
|
||||
}
|
||||
|
||||
let attributedString = NSMutableAttributedString(attributedString: rendered)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(
|
||||
in: attributedString,
|
||||
style: request.style,
|
||||
layoutConfig: request.layoutConfig ?? .default
|
||||
)
|
||||
return RDEPUBRenderedChapterContent(
|
||||
attributedString: attributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
resourceDiagnostics: chapterContext.resourceDiagnostics
|
||||
)
|
||||
#else
|
||||
return fallbackRenderedContent(request: request)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
let request = RDEPUBTextTypesetterPipeline().makeRequest(
|
||||
from: RDEPUBTypesettingInput(
|
||||
href: "",
|
||||
spineIndex: nil,
|
||||
title: "",
|
||||
rawHTML: html,
|
||||
baseURL: baseURL,
|
||||
style: style,
|
||||
resourceResolver: nil
|
||||
)
|
||||
).request
|
||||
return try renderChapter(request: request)
|
||||
}
|
||||
|
||||
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(
|
||||
in: attributedString,
|
||||
style: request.style,
|
||||
layoutConfig: request.layoutConfig ?? .default
|
||||
)
|
||||
return RDEPUBRenderedChapterContent(
|
||||
attributedString: attributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
resourceDiagnostics: request.context.resourceDiagnostics
|
||||
)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
|
||||
let builder = DTHTMLAttributedStringBuilder(
|
||||
html: data,
|
||||
options: dtOptions(request: request),
|
||||
documentAttributes: nil
|
||||
)
|
||||
builder?.willFlushCallback = { element in
|
||||
guard let element else { return }
|
||||
RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering(
|
||||
element,
|
||||
style: request.style,
|
||||
maxImageSize: resolvedMaxImageSize(for: request)
|
||||
)
|
||||
}
|
||||
return builder?.generatedAttributedString()
|
||||
}
|
||||
|
||||
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
|
||||
let style = request.style
|
||||
let maxImageSize = resolvedMaxImageSize(for: request)
|
||||
var options: [AnyHashable: Any] = [
|
||||
NSTextSizeMultiplierDocumentOption: 1.0,
|
||||
DTDefaultFontFamily: style.font.familyName,
|
||||
DTDefaultFontName: style.font.fontName,
|
||||
DTDefaultFontSize: style.font.pointSize,
|
||||
DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1),
|
||||
DTUseiOS6Attributes: true,
|
||||
DTMaxImageSize: NSValue(cgSize: maxImageSize)
|
||||
]
|
||||
|
||||
if let baseURL = request.context.baseURL {
|
||||
options[NSBaseURLDocumentOption] = baseURL
|
||||
}
|
||||
if let textColor = style.textColor {
|
||||
options[DTDefaultTextColor] = textColor
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
private func resolvedMaxImageSize(for request: RDEPUBTextChapterRenderRequest) -> CGSize {
|
||||
let layoutConfig = request.layoutConfig ?? .default
|
||||
let fallbackPageSize = request.pageSize ?? layoutConfig.fallbackViewportSize
|
||||
let contentRect = layoutConfig.contentRect(fallback: fallbackPageSize)
|
||||
let maxWidth = max(round(contentRect.width), 1)
|
||||
let maxHeight = max(round(contentRect.height * layoutConfig.imageMaxHeightRatio), 1)
|
||||
return CGSize(width: maxWidth, height: maxHeight)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBRowColumnIndex: Codable, Equatable {
|
||||
|
||||
public let row: Int
|
||||
|
||||
public let startOffset: Int
|
||||
|
||||
public let endOffset: Int
|
||||
|
||||
public init(row: Int, startOffset: Int, endOffset: Int) {
|
||||
self.row = row
|
||||
self.startOffset = startOffset
|
||||
self.endOffset = endOffset
|
||||
}
|
||||
|
||||
public func contains(_ offset: Int) -> Bool {
|
||||
offset >= startOffset && offset <= endOffset
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBTextIndexTable {
|
||||
|
||||
public let chapterStartOffsets: [Int]
|
||||
|
||||
public let chapterLengths: [Int]
|
||||
|
||||
public let hrefToChapterIndex: [String: Int]
|
||||
|
||||
public let hrefToFileIndex: [String: Int]
|
||||
|
||||
public let fragmentOffsetsByHref: [String: [String: Int]]
|
||||
|
||||
public let fileIndexToHref: [Int: String]
|
||||
|
||||
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
|
||||
|
||||
public let fileTextMap: [Int: String]
|
||||
|
||||
public let fileCFIMap: [Int: RDEPUBCFIMap]
|
||||
|
||||
public var totalCharacterCount: Int {
|
||||
guard let lastIndex = chapterStartOffsets.indices.last,
|
||||
chapterLengths.indices.contains(lastIndex) else {
|
||||
return 0
|
||||
}
|
||||
return chapterStartOffsets[lastIndex] + chapterLengths[lastIndex]
|
||||
}
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter]) {
|
||||
var offsets: [Int] = []
|
||||
var lengths: [Int] = []
|
||||
var hrefMap: [String: Int] = [:]
|
||||
var hrefToFileMap: [String: Int] = [:]
|
||||
var fragmentMap: [String: [String: Int]] = [:]
|
||||
var fileIndexMap: [Int: String] = [:]
|
||||
var rowColumnMap: [Int: [RDEPUBRowColumnIndex]] = [:]
|
||||
var textMap: [Int: String] = [:]
|
||||
var cfiMap: [Int: RDEPUBCFIMap] = [:]
|
||||
var running = 0
|
||||
|
||||
for (index, chapter) in chapters.enumerated() {
|
||||
hrefMap[chapter.href] = index
|
||||
hrefToFileMap[chapter.href] = chapter.spineIndex
|
||||
offsets.append(running)
|
||||
lengths.append(chapter.attributedContent.length)
|
||||
running += chapter.attributedContent.length
|
||||
fragmentMap[chapter.href] = chapter.fragmentOffsets
|
||||
fileIndexMap[chapter.spineIndex] = chapter.href
|
||||
let chapterText = chapter.attributedContent.string
|
||||
rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapterText)
|
||||
textMap[chapter.spineIndex] = chapterText
|
||||
if let chapterCFIMap = chapter.cfiMap {
|
||||
cfiMap[chapter.spineIndex] = chapterCFIMap
|
||||
}
|
||||
}
|
||||
|
||||
self.chapterStartOffsets = offsets
|
||||
self.chapterLengths = lengths
|
||||
self.hrefToChapterIndex = hrefMap
|
||||
self.hrefToFileIndex = hrefToFileMap
|
||||
self.fragmentOffsetsByHref = fragmentMap
|
||||
self.fileIndexToHref = fileIndexMap
|
||||
self.fileRowColumnMap = rowColumnMap
|
||||
self.fileTextMap = textMap
|
||||
self.fileCFIMap = cfiMap
|
||||
}
|
||||
|
||||
public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
|
||||
let normalizedIndex = clampedOffset(index, in: chapter)
|
||||
let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
|
||||
let row = row(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
|
||||
let column = column(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: chapter.spineIndex,
|
||||
row: row,
|
||||
column: column,
|
||||
chapterOffset: normalizedIndex,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
|
||||
let anchor = anchor(for: cfiRange.start) {
|
||||
return anchor
|
||||
}
|
||||
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor
|
||||
}
|
||||
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let anchor = anchor(for: cfi) {
|
||||
return anchor
|
||||
}
|
||||
|
||||
guard let chapterIndex = hrefToChapterIndex[location.href],
|
||||
let fileIndex = hrefToFileIndex[location.href] else { return nil }
|
||||
let fragments = fragmentOffsetsByHref[location.href] ?? [:]
|
||||
let chapterOffset: Int
|
||||
|
||||
if let fragment = location.fragment, let fragmentOffset = fragments[fragment] {
|
||||
chapterOffset = fragmentOffset
|
||||
} else {
|
||||
let estimatedLength = max(chapterLengths.indices.contains(chapterIndex) ? chapterLengths[chapterIndex] : 0, 1)
|
||||
let lastOffset = max(estimatedLength - 1, 0)
|
||||
chapterOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
|
||||
}
|
||||
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: fileIndex,
|
||||
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
chapterOffset: chapterOffset,
|
||||
fragmentID: location.fragment
|
||||
)
|
||||
}
|
||||
|
||||
public func anchor(for cfi: RDEPUBCFI) -> RDEPUBTextAnchor? {
|
||||
let resolved = RDEPUBCFIResolver.resolve(cfi)
|
||||
let fileIndex: Int
|
||||
if let resolvedFileIndex = resolved.fileIndex {
|
||||
fileIndex = resolvedFileIndex
|
||||
} else if let href = resolved.href, let mappedFileIndex = hrefToFileIndex[href] {
|
||||
fileIndex = mappedFileIndex
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let href = href(for: fileIndex) ?? resolved.href else { return nil }
|
||||
let chapterLength = max(chapterLength(forFileIndex: fileIndex) ?? 0, 1)
|
||||
let lastOffset = max(chapterLength - 1, 0)
|
||||
let recovery = RDEPUBCFIRecoveryEngine.recover(
|
||||
cfi: cfi,
|
||||
cfiMap: fileCFIMap[fileIndex],
|
||||
chapterText: fileTextMap[fileIndex],
|
||||
fragmentOffsets: fragmentOffsetsByHref[href] ?? [:],
|
||||
fallbackOffset: resolved.chapterOffset,
|
||||
lastOffset: lastOffset
|
||||
)
|
||||
let chapterOffset = recovery?.chapterOffset ?? min(max(resolved.chapterOffset ?? 0, 0), lastOffset)
|
||||
let fragmentID = resolved.fragmentID ?? nearestFragmentID(beforeOrAt: chapterOffset, inHref: href)
|
||||
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: fileIndex,
|
||||
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
chapterOffset: chapterOffset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
|
||||
public func rangeAnchor(for cfiRange: RDEPUBCFIRange) -> RDEPUBTextRangeAnchor? {
|
||||
guard let start = anchor(for: cfiRange.start),
|
||||
let end = anchor(for: cfiRange.end),
|
||||
start.fileIndex == end.fileIndex else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBTextRangeAnchor(start: start, end: end)
|
||||
}
|
||||
|
||||
public func cfi(for anchor: RDEPUBTextAnchor) -> RDEPUBCFI? {
|
||||
guard let href = href(for: anchor.fileIndex) else { return nil }
|
||||
let chapterOffset = chapterOffset(for: anchor)
|
||||
if let marker = preciseMarker(fileIndex: anchor.fileIndex, chapterOffset: chapterOffset) {
|
||||
let localOffset = max(chapterOffset - (marker.chapterOffset ?? 0), 0)
|
||||
return RDEPUBCFIGenerator.makeCFI(
|
||||
href: href,
|
||||
fileIndex: anchor.fileIndex,
|
||||
contentPath: marker.cfiPath,
|
||||
characterOffset: localOffset,
|
||||
textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset)
|
||||
)
|
||||
}
|
||||
return RDEPUBCFIGenerator.makeOffsetCFI(
|
||||
href: href,
|
||||
fileIndex: anchor.fileIndex,
|
||||
chapterOffset: chapterOffset,
|
||||
fragmentID: anchor.fragmentID,
|
||||
textAssertion: textAssertion(fileIndex: anchor.fileIndex, offset: chapterOffset)
|
||||
)
|
||||
}
|
||||
|
||||
public func cfiRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> RDEPUBCFIRange? {
|
||||
guard rangeAnchor.start.fileIndex == rangeAnchor.end.fileIndex,
|
||||
let href = href(for: rangeAnchor.start.fileIndex) else {
|
||||
return nil
|
||||
}
|
||||
let startOffset = chapterOffset(for: rangeAnchor.start)
|
||||
let endOffset = chapterOffset(for: rangeAnchor.end)
|
||||
let startCFI = cfi(for: rangeAnchor.start)
|
||||
let endCFI = cfi(for: rangeAnchor.end)
|
||||
if let startCFI, let endCFI {
|
||||
let parent = RDEPUBCFI(
|
||||
packagePath: startCFI.packagePath.commonPrefix(with: endCFI.packagePath),
|
||||
contentPath: startCFI.contentPath.commonPrefix(with: endCFI.contentPath)
|
||||
)
|
||||
return RDEPUBCFIRange(parent: parent, start: startCFI, end: endCFI)
|
||||
}
|
||||
return RDEPUBCFIGenerator.makeOffsetRangeCFI(
|
||||
href: href,
|
||||
fileIndex: rangeAnchor.start.fileIndex,
|
||||
startOffset: startOffset,
|
||||
endOffset: endOffset,
|
||||
fragmentID: rangeAnchor.start.fragmentID,
|
||||
startTextAssertion: textAssertion(
|
||||
fileIndex: rangeAnchor.start.fileIndex,
|
||||
offset: startOffset
|
||||
),
|
||||
endTextAssertion: textAssertion(
|
||||
fileIndex: rangeAnchor.end.fileIndex,
|
||||
offset: endOffset
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
|
||||
guard let fileIndex = fileIndex(forCharacterPosition: index),
|
||||
let href = href(for: fileIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chapterOffset = localOffsetInFile(at: fileIndex, forGlobalPosition: index) ?? 0
|
||||
let fragmentID = nearestFragmentID(beforeOrAt: chapterOffset, inHref: href)
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: fileIndex,
|
||||
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
chapterOffset: chapterOffset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
|
||||
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
|
||||
let resolvedOffset = chapterOffset(for: anchor)
|
||||
return chapter.pages.firstIndex { page in
|
||||
NSLocationInRange(resolvedOffset, page.contentRange)
|
||||
}.map { $0 + basePageIndex }
|
||||
}
|
||||
|
||||
public func href(for fileIndex: Int) -> String? {
|
||||
fileIndexToHref[fileIndex]
|
||||
}
|
||||
|
||||
public func chapterIndex(for href: String) -> Int? {
|
||||
hrefToChapterIndex[href]
|
||||
}
|
||||
|
||||
public func chapterStartOffset(forFileIndex fileIndex: Int) -> Int? {
|
||||
guard let href = href(for: fileIndex),
|
||||
let chapterIndex = hrefToChapterIndex[href],
|
||||
chapterStartOffsets.indices.contains(chapterIndex) else {
|
||||
return nil
|
||||
}
|
||||
return chapterStartOffsets[chapterIndex]
|
||||
}
|
||||
|
||||
public func chapterLength(forFileIndex fileIndex: Int) -> Int? {
|
||||
guard let href = href(for: fileIndex),
|
||||
let chapterIndex = hrefToChapterIndex[href],
|
||||
chapterLengths.indices.contains(chapterIndex) else {
|
||||
return nil
|
||||
}
|
||||
return chapterLengths[chapterIndex]
|
||||
}
|
||||
|
||||
public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
anchor.chapterOffset
|
||||
}
|
||||
|
||||
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
let chapterOffset = chapterOffset(for: anchor)
|
||||
let baseOffset = chapterStartOffset(forFileIndex: anchor.fileIndex) ?? 0
|
||||
let chapterLength = max(chapterLength(forFileIndex: anchor.fileIndex) ?? 0, 0)
|
||||
let lastOffset = max(chapterLength - 1, 0)
|
||||
return baseOffset + min(max(chapterOffset, 0), lastOffset)
|
||||
}
|
||||
|
||||
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
globalIndex(for: anchor)
|
||||
}
|
||||
|
||||
public func chapterRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = chapterOffset(for: rangeAnchor.start)
|
||||
let end = max(start, chapterOffset(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = globalIndex(for: rangeAnchor.start)
|
||||
let end = max(start, globalIndex(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let chapterOffset = self.chapterOffset(for: anchor)
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let progression = Double(min(max(chapterOffset, 0), totalLength)) / Double(totalLength)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: chapter.href,
|
||||
progression: progression,
|
||||
lastProgression: progression,
|
||||
fragment: anchor.fragmentID,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor),
|
||||
cfi: cfi(for: anchor)?.rawValue,
|
||||
lastCFI: cfi(for: anchor)?.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let start = chapterOffset(for: rangeAnchor.start)
|
||||
let end = max(start, chapterOffset(for: rangeAnchor.end))
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let clampedStart = min(max(start, 0), totalLength)
|
||||
let clampedEnd = min(max(end, clampedStart), totalLength)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: chapter.href,
|
||||
progression: Double(clampedStart) / Double(totalLength),
|
||||
lastProgression: Double(clampedEnd) / Double(totalLength),
|
||||
fragment: rangeAnchor.start.fragmentID,
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: cfi(for: rangeAnchor.start)?.rawValue,
|
||||
lastCFI: cfi(for: rangeAnchor.end)?.rawValue,
|
||||
rangeCFI: cfiRange(for: rangeAnchor)?.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
|
||||
return rowIndex
|
||||
}
|
||||
return rows.last?.row ?? 0
|
||||
}
|
||||
|
||||
public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowEntry = rows.first(where: { $0.contains(index) }) {
|
||||
return max(index - rowEntry.startOffset, 0)
|
||||
}
|
||||
guard let lastRow = rows.last else { return 0 }
|
||||
return max(index - lastRow.startOffset, 0)
|
||||
}
|
||||
|
||||
public func chapterOffset(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
|
||||
let normalizedRow = min(max(row, 0), rows.count - 1)
|
||||
let rowEntry = rows[normalizedRow]
|
||||
let maxColumn = max(rowEntry.endOffset - rowEntry.startOffset, 0)
|
||||
return rowEntry.startOffset + min(max(column, 0), maxColumn)
|
||||
}
|
||||
|
||||
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let chapterOffset = chapterOffset(fileIndex: fileIndex, row: row, column: column),
|
||||
let baseOffset = chapterStartOffset(forFileIndex: fileIndex) else {
|
||||
return nil
|
||||
}
|
||||
return baseOffset + chapterOffset
|
||||
}
|
||||
|
||||
public func fileIndex(forCharacterPosition index: Int) -> Int? {
|
||||
guard totalCharacterCount > 0 else { return nil }
|
||||
let normalized = min(max(index, 0), max(totalCharacterCount - 1, 0))
|
||||
for (href, chapterIndex) in hrefToChapterIndex {
|
||||
guard chapterStartOffsets.indices.contains(chapterIndex),
|
||||
chapterLengths.indices.contains(chapterIndex),
|
||||
let fileIndex = hrefToFileIndex[href] else {
|
||||
continue
|
||||
}
|
||||
|
||||
let start = chapterStartOffsets[chapterIndex]
|
||||
let length = chapterLengths[chapterIndex]
|
||||
let endExclusive = start + max(length, 1)
|
||||
if normalized >= start && normalized < endExclusive {
|
||||
return fileIndex
|
||||
}
|
||||
}
|
||||
return fileIndexToHref.keys.sorted().last
|
||||
}
|
||||
|
||||
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
|
||||
guard let start = chapterStartOffset(forFileIndex: fileIndex),
|
||||
let chapterLength = chapterLength(forFileIndex: fileIndex) else {
|
||||
return nil
|
||||
}
|
||||
let normalized = min(max(index, start), start + max(chapterLength - 1, 0))
|
||||
return normalized - start
|
||||
}
|
||||
|
||||
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
return min(max(index, 0), lastOffset)
|
||||
}
|
||||
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
|
||||
var bestID: String?
|
||||
var bestOffset = -1
|
||||
|
||||
for (id, fragOffset) in chapter.fragmentOffsets {
|
||||
if fragOffset <= offset && fragOffset > bestOffset {
|
||||
bestOffset = fragOffset
|
||||
bestID = id
|
||||
}
|
||||
}
|
||||
|
||||
return bestID
|
||||
}
|
||||
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, inHref href: String) -> String? {
|
||||
guard let fragmentOffsets = fragmentOffsetsByHref[href] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var bestID: String?
|
||||
var bestOffset = -1
|
||||
for (id, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
|
||||
bestOffset = fragmentOffset
|
||||
bestID = id
|
||||
}
|
||||
return bestID
|
||||
}
|
||||
|
||||
private func preciseMarker(fileIndex: Int, chapterOffset: Int) -> RDEPUBCFIMarker? {
|
||||
guard let markers = fileCFIMap[fileIndex]?.markers else { return nil }
|
||||
return markers
|
||||
.filter {
|
||||
guard let start = $0.chapterOffset,
|
||||
let length = $0.textNodeLength,
|
||||
length > 0 else {
|
||||
return false
|
||||
}
|
||||
return chapterOffset >= start && chapterOffset < start + length
|
||||
}
|
||||
.max { lhs, rhs in
|
||||
let leftStart = lhs.chapterOffset ?? 0
|
||||
let rightStart = rhs.chapterOffset ?? 0
|
||||
if leftStart != rightStart {
|
||||
return leftStart < rightStart
|
||||
}
|
||||
return (lhs.textNodeLength ?? 0) > (rhs.textNodeLength ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func textAssertion(fileIndex: Int, offset: Int) -> RDEPUBCFITextAssertion? {
|
||||
guard let text = fileTextMap[fileIndex], !text.isEmpty else { return nil }
|
||||
let nsText = text as NSString
|
||||
let length = nsText.length
|
||||
guard length > 0 else { return nil }
|
||||
|
||||
let location = min(max(offset, 0), max(length - 1, 0))
|
||||
let exactLength = min(16, max(length - location, 0))
|
||||
guard exactLength > 0 else { return nil }
|
||||
|
||||
let prefixStart = max(location - 8, 0)
|
||||
let suffixStart = min(location + exactLength, length)
|
||||
return RDEPUBCFITextAssertion(
|
||||
prefix: prefixStart < location
|
||||
? nsText.substring(with: NSRange(location: prefixStart, length: location - prefixStart))
|
||||
: nil,
|
||||
exact: nsText.substring(with: NSRange(location: location, length: exactLength)),
|
||||
suffix: suffixStart < length
|
||||
? nsText.substring(with: NSRange(location: suffixStart, length: min(8, length - suffixStart)))
|
||||
: nil
|
||||
)
|
||||
}
|
||||
|
||||
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
|
||||
let nsText = text as NSString
|
||||
let length = nsText.length
|
||||
guard length > 0 else {
|
||||
return [RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: 0)]
|
||||
}
|
||||
|
||||
var rows: [RDEPUBRowColumnIndex] = []
|
||||
var rowNumber = 0
|
||||
var lineStart = 0
|
||||
|
||||
nsText.enumerateSubstrings(
|
||||
in: NSRange(location: 0, length: length),
|
||||
options: [.byLines, .substringNotRequired]
|
||||
) { _, substringRange, enclosingRange, _ in
|
||||
let startOffset = enclosingRange.location
|
||||
let lineLength = max(substringRange.length, 0)
|
||||
let endOffset = max(startOffset + max(lineLength - 1, 0), startOffset)
|
||||
rows.append(
|
||||
RDEPUBRowColumnIndex(
|
||||
row: rowNumber,
|
||||
startOffset: startOffset,
|
||||
endOffset: min(endOffset, max(length - 1, 0))
|
||||
)
|
||||
)
|
||||
rowNumber += 1
|
||||
lineStart = enclosingRange.location + enclosingRange.length
|
||||
}
|
||||
|
||||
if rows.isEmpty {
|
||||
rows.append(RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: max(length - 1, 0)))
|
||||
} else if lineStart == length, text.hasSuffix("\n") {
|
||||
rows.append(RDEPUBRowColumnIndex(row: rowNumber, startOffset: length, endOffset: length))
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBTextPositionConverter {
|
||||
|
||||
public let book: RDEPUBTextBook
|
||||
|
||||
public init(book: RDEPUBTextBook) {
|
||||
self.book = book
|
||||
}
|
||||
|
||||
public var totalCharacterCount: Int {
|
||||
book.indexTable.totalCharacterCount
|
||||
}
|
||||
|
||||
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
book.indexTable.globalIndex(for: anchor)
|
||||
}
|
||||
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
book.indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
public func fileIndex(forCharacterPosition index: Int) -> Int? {
|
||||
book.indexTable.fileIndex(forCharacterPosition: index)
|
||||
}
|
||||
|
||||
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
|
||||
book.indexTable.localOffsetInFile(at: fileIndex, forGlobalPosition: index)
|
||||
}
|
||||
|
||||
public func anchor(forCharacterPosition index: Int) -> RDEPUBTextAnchor? {
|
||||
book.indexTable.anchor(forGlobalIndex: index)
|
||||
}
|
||||
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
book.indexTable.anchor(for: location)
|
||||
}
|
||||
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor) -> Int? {
|
||||
book.indexTable.pageNumber(for: anchor, in: book).map { $0 + 1 }
|
||||
}
|
||||
|
||||
public func pageNumber(forCharacterPosition index: Int) -> Int? {
|
||||
guard let anchor = anchor(forCharacterPosition: index) else {
|
||||
return nil
|
||||
}
|
||||
return pageNumber(for: anchor)
|
||||
}
|
||||
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else {
|
||||
return nil
|
||||
}
|
||||
return book.indexTable.location(for: anchor, in: chapter, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == rangeAnchor.start.fileIndex }) else {
|
||||
return nil
|
||||
}
|
||||
return book.indexTable.location(for: rangeAnchor, in: chapter, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import UIKit
|
||||
|
||||
public extension NSAttributedString.Key {
|
||||
|
||||
static let rdPageBlockRange = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockRange")
|
||||
|
||||
static let rdPageBlockIndex = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockIndex")
|
||||
|
||||
static let rdPageFragmentID = NSAttributedString.Key("com.RDEpubReader.epub.pageFragmentID")
|
||||
|
||||
static let rdPageAttachmentKind = NSAttributedString.Key("com.RDEpubReader.epub.pageAttachmentKind")
|
||||
|
||||
static let rdPageBlockKind = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockKind")
|
||||
|
||||
static let rdPageSemanticHints = NSAttributedString.Key("com.RDEpubReader.epub.pageSemanticHints")
|
||||
|
||||
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.RDEpubReader.epub.pageAttachmentPlacement")
|
||||
}
|
||||
|
||||
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
|
||||
case paragraph
|
||||
case list
|
||||
case table
|
||||
case code
|
||||
case blockquote
|
||||
case attachment
|
||||
case generic
|
||||
}
|
||||
|
||||
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
|
||||
case avoidPageBreakInside
|
||||
case keepWithNext
|
||||
case pageBreakBefore
|
||||
case pageBreakAfter
|
||||
case pageRelate
|
||||
}
|
||||
|
||||
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
|
||||
case inline
|
||||
case baseline
|
||||
case centered
|
||||
}
|
||||
|
||||
public struct RDEPUBTextRenderStyle {
|
||||
|
||||
public var font: UIFont
|
||||
|
||||
public var lineSpacing: CGFloat
|
||||
|
||||
public var textColor: UIColor?
|
||||
|
||||
public var backgroundColor: UIColor?
|
||||
|
||||
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
|
||||
self.font = font
|
||||
self.lineSpacing = lineSpacing
|
||||
self.textColor = textColor
|
||||
self.backgroundColor = backgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
|
||||
public var frameWidth: CGFloat
|
||||
|
||||
public var frameHeight: CGFloat
|
||||
|
||||
public var edgeInsets: UIEdgeInsets
|
||||
|
||||
public var numberOfColumns: Int
|
||||
|
||||
public var columnGap: CGFloat
|
||||
|
||||
public var avoidOrphans: Bool
|
||||
|
||||
public var avoidWidows: Bool
|
||||
|
||||
public var avoidPageBreakInsideEnabled: Bool
|
||||
|
||||
public var hyphenation: Bool
|
||||
|
||||
public var imageMaxHeightRatio: CGFloat
|
||||
|
||||
public var fallbackViewportSize: CGSize
|
||||
|
||||
public init(
|
||||
frameWidth: CGFloat = 0,
|
||||
frameHeight: CGFloat = 0,
|
||||
edgeInsets: UIEdgeInsets = .zero,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
avoidOrphans: Bool = true,
|
||||
avoidWidows: Bool = true,
|
||||
avoidPageBreakInsideEnabled: Bool = true,
|
||||
hyphenation: Bool = true,
|
||||
imageMaxHeightRatio: CGFloat = 0.85,
|
||||
fallbackViewportSize: CGSize = CGSize(width: 375, height: 667)
|
||||
) {
|
||||
self.frameWidth = frameWidth
|
||||
self.frameHeight = frameHeight
|
||||
self.edgeInsets = edgeInsets
|
||||
self.numberOfColumns = max(1, numberOfColumns)
|
||||
self.columnGap = max(0, columnGap)
|
||||
self.avoidOrphans = avoidOrphans
|
||||
self.avoidWidows = avoidWidows
|
||||
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
|
||||
self.hyphenation = hyphenation
|
||||
self.imageMaxHeightRatio = imageMaxHeightRatio
|
||||
self.fallbackViewportSize = fallbackViewportSize
|
||||
}
|
||||
|
||||
public static let `default` = RDEPUBTextLayoutConfig()
|
||||
|
||||
public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
|
||||
CGSize(
|
||||
width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
|
||||
height: max(frameHeight > 0 ? frameHeight : pageSize.height, 1)
|
||||
)
|
||||
}
|
||||
|
||||
public func contentRect(fallback pageSize: CGSize) -> CGRect {
|
||||
let size = resolvedFrameSize(fallback: pageSize)
|
||||
return CGRect(origin: .zero, size: size).inset(by: edgeInsets)
|
||||
}
|
||||
|
||||
public func columnRects(fallback pageSize: CGSize) -> [CGRect] {
|
||||
let rect = contentRect(fallback: pageSize)
|
||||
let columns = max(1, numberOfColumns)
|
||||
guard columns > 1 else { return [rect] }
|
||||
|
||||
let totalGap = CGFloat(columns - 1) * columnGap
|
||||
let columnWidth = max((rect.width - totalGap) / CGFloat(columns), 1)
|
||||
|
||||
return (0..<columns).map { index in
|
||||
let originX = rect.minX + CGFloat(index) * (columnWidth + columnGap)
|
||||
return CGRect(x: originX, y: rect.minY, width: columnWidth, height: rect.height)
|
||||
}
|
||||
}
|
||||
|
||||
public var cacheSignature: String {
|
||||
[
|
||||
String(format: "%.3f", frameWidth),
|
||||
String(format: "%.3f", frameHeight),
|
||||
String(format: "%.3f", edgeInsets.top),
|
||||
String(format: "%.3f", edgeInsets.left),
|
||||
String(format: "%.3f", edgeInsets.bottom),
|
||||
String(format: "%.3f", edgeInsets.right),
|
||||
String(numberOfColumns),
|
||||
String(format: "%.3f", columnGap),
|
||||
avoidOrphans ? "1" : "0",
|
||||
avoidWidows ? "1" : "0",
|
||||
avoidPageBreakInsideEnabled ? "1" : "0",
|
||||
hyphenation ? "1" : "0",
|
||||
String(format: "%.3f", imageMaxHeightRatio),
|
||||
String(format: "%.3f", fallbackViewportSize.width),
|
||||
String(format: "%.3f", fallbackViewportSize.height)
|
||||
].joined(separator: "|")
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
|
||||
case `default`
|
||||
case replace
|
||||
case dark
|
||||
case epub
|
||||
case user
|
||||
}
|
||||
|
||||
public struct RDEPUBTextStyleSheetLayer: Equatable {
|
||||
public var kind: RDEPUBTextStyleSheetLayerKind
|
||||
public var css: String
|
||||
|
||||
public init(kind: RDEPUBTextStyleSheetLayerKind, css: String) {
|
||||
self.kind = kind
|
||||
self.css = css
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
public var layers: [RDEPUBTextStyleSheetLayer]
|
||||
|
||||
public init(layers: [RDEPUBTextStyleSheetLayer]) {
|
||||
self.layers = layers
|
||||
}
|
||||
|
||||
public var combinedCSS: String {
|
||||
layers
|
||||
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.map { layer in
|
||||
"/* \(layer.kind.rawValue) */\n\(layer.css)"
|
||||
}
|
||||
.joined(separator: "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
|
||||
case stylesheet
|
||||
case image
|
||||
}
|
||||
|
||||
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
||||
public var kind: RDEPUBTextResourceReferenceKind
|
||||
public var chapterHref: String
|
||||
public var originalReference: String
|
||||
public var normalizedHref: String?
|
||||
public var resolvedFileURL: URL?
|
||||
public var existsOnDisk: Bool
|
||||
|
||||
public init(
|
||||
kind: RDEPUBTextResourceReferenceKind,
|
||||
chapterHref: String,
|
||||
originalReference: String,
|
||||
normalizedHref: String?,
|
||||
resolvedFileURL: URL?,
|
||||
existsOnDisk: Bool
|
||||
) {
|
||||
self.kind = kind
|
||||
self.chapterHref = chapterHref
|
||||
self.originalReference = originalReference
|
||||
self.normalizedHref = normalizedHref
|
||||
self.resolvedFileURL = resolvedFileURL
|
||||
self.existsOnDisk = existsOnDisk
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBTextChapterContext: Equatable {
|
||||
public var href: String
|
||||
public var title: String
|
||||
public var html: String
|
||||
public var baseURL: URL?
|
||||
public var stylesheet: RDEPUBTextStyleSheetPackage
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
public var styleCompatibilityReport: RDEPUBCSSCompatibilityReport
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
title: String,
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
stylesheet: RDEPUBTextStyleSheetPackage,
|
||||
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic],
|
||||
styleCompatibilityReport: RDEPUBCSSCompatibilityReport = RDEPUBCSSCompatibilityReport()
|
||||
) {
|
||||
self.href = href
|
||||
self.title = title
|
||||
self.html = html
|
||||
self.baseURL = baseURL
|
||||
self.stylesheet = stylesheet
|
||||
self.resourceDiagnostics = resourceDiagnostics
|
||||
self.styleCompatibilityReport = styleCompatibilityReport
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBTextChapterRenderRequest {
|
||||
public var context: RDEPUBTextChapterContext
|
||||
public var style: RDEPUBTextRenderStyle
|
||||
|
||||
public var pageSize: CGSize?
|
||||
|
||||
public var layoutConfig: RDEPUBTextLayoutConfig?
|
||||
|
||||
public init(
|
||||
context: RDEPUBTextChapterContext,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
pageSize: CGSize? = nil,
|
||||
layoutConfig: RDEPUBTextLayoutConfig? = nil
|
||||
) {
|
||||
self.context = context
|
||||
self.style = style
|
||||
self.pageSize = pageSize
|
||||
self.layoutConfig = layoutConfig
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBRenderedChapterContent {
|
||||
|
||||
public var attributedString: NSAttributedString
|
||||
|
||||
public var fragmentOffsets: [String: Int]
|
||||
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
public init(
|
||||
attributedString: NSAttributedString,
|
||||
fragmentOffsets: [String: Int],
|
||||
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
|
||||
) {
|
||||
self.attributedString = attributedString
|
||||
self.fragmentOffsets = fragmentOffsets
|
||||
self.resourceDiagnostics = resourceDiagnostics
|
||||
}
|
||||
}
|
||||
|
||||
public protocol RDEPUBTextRenderer {
|
||||
|
||||
func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
|
||||
func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
}
|
||||
|
||||
public extension RDEPUBTextRenderer {
|
||||
func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
let context = RDEPUBTextChapterContext(
|
||||
href: "",
|
||||
title: "",
|
||||
html: html,
|
||||
baseURL: baseURL,
|
||||
stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
|
||||
resourceDiagnostics: []
|
||||
)
|
||||
return try renderChapter(request: RDEPUBTextChapterRenderRequest(context: context, style: style))
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBTextRenderingError: LocalizedError {
|
||||
case htmlEncodingFailed
|
||||
case htmlImportFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .htmlEncodingFailed:
|
||||
return "HTML 编码失败"
|
||||
case .htmlImportFailed:
|
||||
return "HTML 富文本导入失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
private let textBook: RDEPUBTextBook
|
||||
private let publication: RDEPUBPublication
|
||||
|
||||
init(textBook: RDEPUBTextBook, publication: RDEPUBPublication) {
|
||||
self.textBook = textBook
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
for chapter in textBook.chapters {
|
||||
guard let chapterData = textBook.chapterData(for: chapter.href) else { continue }
|
||||
let source = chapter.attributedContent.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else {
|
||||
break
|
||||
}
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length,
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
|
||||
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||||
let range = NSRange(location: start, length: max(end - start, 0))
|
||||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func searchWithoutPublication(textBook: RDEPUBTextBook, keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else { return [] }
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
for chapter in textBook.chapters {
|
||||
let chapterData = RDEPUBChapterData(
|
||||
chapter: chapter,
|
||||
indexTable: RDEPUBTextIndexTable(chapters: [chapter])
|
||||
)
|
||||
let source = chapter.attributedContent.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else { continue }
|
||||
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else { break }
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
|
||||
let cfi = chapterData.indexTable.cfi(for: rangeAnchor.start)
|
||||
let cfiRange = chapterData.indexTable.cfiRange(for: rangeAnchor)
|
||||
let previewRadius = 12
|
||||
let previewStart = max(foundRange.location - previewRadius, 0)
|
||||
let previewEnd = min(foundRange.location + foundRange.length + previewRadius, fullLength)
|
||||
let previewRange = NSRange(location: previewStart, length: max(previewEnd - previewStart, 0))
|
||||
let previewText = source.substring(with: previewRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: chapter.href,
|
||||
progression: progression,
|
||||
previewText: previewText,
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length,
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: cfi?.rawValue ?? cfiRange?.start.rawValue,
|
||||
rangeCFI: cfiRange?.rawValue
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength { break }
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import UIKit
|
||||
|
||||
public final class RDEpubPlainTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
private let layoutConfig: RDEPUBTextLayoutConfig
|
||||
|
||||
public init(
|
||||
renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer(),
|
||||
layoutConfig: RDEPUBTextLayoutConfig = .default
|
||||
) {
|
||||
self.renderer = renderer
|
||||
self.layoutConfig = layoutConfig
|
||||
}
|
||||
|
||||
public func build(
|
||||
textFileURL: URL,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
let rawText = rd_decodeTextFile(url: textFileURL)
|
||||
let chapterSpecs = splitChapters(from: rawText)
|
||||
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
var flatPages: [RDEPUBTextPage] = []
|
||||
|
||||
for (index, spec) in chapterSpecs.enumerated() {
|
||||
let html = wrapTextAsHTML(spec.content)
|
||||
let request = RDEPUBTextChapterRenderRequest(
|
||||
context: RDEPUBTextChapterContext(
|
||||
href: "chapter_\(index).xhtml",
|
||||
title: spec.title ?? "第 \(index + 1) 章",
|
||||
html: html,
|
||||
baseURL: nil,
|
||||
stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
|
||||
resourceDiagnostics: []
|
||||
),
|
||||
style: style,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, config: layoutConfig) : []
|
||||
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
|
||||
|
||||
let href = "chapter_\(index).xhtml"
|
||||
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: index,
|
||||
spineIndex: index,
|
||||
href: href,
|
||||
chapterTitle: spec.title ?? "第 \(index + 1) 章",
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectiveFrames.count,
|
||||
chapterContent: chapterAttributedContent,
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0),
|
||||
metadata: frame.metadata
|
||||
)
|
||||
}
|
||||
|
||||
chapters.append(
|
||||
RDEPUBTextChapter(
|
||||
chapterIndex: index,
|
||||
spineIndex: index,
|
||||
href: href,
|
||||
title: spec.title ?? "第 \(index + 1) 章",
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: [:],
|
||||
cfiMap: nil,
|
||||
pageBreakReasons: pages.map { $0.metadata.breakReason },
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
}
|
||||
|
||||
private struct ChapterSpec {
|
||||
let title: String?
|
||||
let content: String
|
||||
}
|
||||
|
||||
private func splitChapters(from text: String) -> [ChapterSpec] {
|
||||
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
|
||||
return [ChapterSpec(title: nil, content: text)]
|
||||
}
|
||||
|
||||
let nsText = text as NSString
|
||||
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
|
||||
guard !matches.isEmpty else {
|
||||
return [ChapterSpec(title: nil, content: text)]
|
||||
}
|
||||
|
||||
var specs: [ChapterSpec] = []
|
||||
for (i, match) in matches.enumerated() {
|
||||
let titleRange = match.range(at: 1)
|
||||
let title = nsText.substring(with: titleRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let contentStart = titleRange.location + titleRange.length
|
||||
let contentEnd = (i + 1 < matches.count) ? matches[i + 1].range.location : nsText.length
|
||||
let contentRange = NSRange(location: contentStart, length: contentEnd - contentStart)
|
||||
let content = nsText.substring(with: contentRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !content.isEmpty {
|
||||
specs.append(ChapterSpec(title: title, content: content))
|
||||
}
|
||||
}
|
||||
|
||||
if specs.isEmpty {
|
||||
return [ChapterSpec(title: nil, content: text)]
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
private func wrapTextAsHTML(_ text: String) -> String {
|
||||
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
|
||||
return "<html><body>\(body)</body></html>"
|
||||
}
|
||||
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
|
||||
return content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBCSSCompatibilityLayer {
|
||||
|
||||
var policy = RDEPUBStyleCompatibilityPolicy()
|
||||
|
||||
func sanitize(_ css: String) -> RDEPUBCSSCompatibilityResult {
|
||||
var rewritten = css
|
||||
var normalized: [String] = []
|
||||
var unsupported: [String] = []
|
||||
|
||||
if policy.normalizeLineHeight {
|
||||
let result = clampNumericProperty(
|
||||
in: rewritten,
|
||||
property: "line-height",
|
||||
minValue: 0.9,
|
||||
maxValue: 2.4
|
||||
)
|
||||
rewritten = result.css
|
||||
normalized += result.normalizedRules
|
||||
}
|
||||
|
||||
if policy.normalizeTextIndent {
|
||||
let result = clampLengthProperty(
|
||||
in: rewritten,
|
||||
property: "text-indent",
|
||||
maxAbsolutePX: 64
|
||||
)
|
||||
rewritten = result.css
|
||||
normalized += result.normalizedRules
|
||||
}
|
||||
|
||||
let alignmentIndentResult = addTextIndentResetForAlignedBlocks(in: rewritten)
|
||||
rewritten = alignmentIndentResult.css
|
||||
normalized += alignmentIndentResult.normalizedRules
|
||||
|
||||
if !policy.allowPublisherMargins {
|
||||
let result = clampLengthProperty(
|
||||
in: rewritten,
|
||||
property: "margin",
|
||||
maxAbsolutePX: 64
|
||||
)
|
||||
rewritten = result.css
|
||||
normalized += result.normalizedRules
|
||||
}
|
||||
|
||||
if policy.fallbackUnsupportedWritingModes,
|
||||
rewritten.range(of: "writing-mode", options: [.caseInsensitive]) != nil {
|
||||
unsupported.append("writing-mode")
|
||||
rewritten += "\n\nhtml, body { writing-mode: horizontal-tb !important; }"
|
||||
normalized.append("writing-mode")
|
||||
}
|
||||
|
||||
if policy.clampImagesToViewport {
|
||||
rewritten += """
|
||||
|
||||
img, svg, table, video, canvas {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
table {
|
||||
overflow-wrap: anywhere;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
"""
|
||||
normalized.append("media-size-clamp")
|
||||
}
|
||||
|
||||
return RDEPUBCSSCompatibilityResult(
|
||||
css: rewritten,
|
||||
report: RDEPUBCSSCompatibilityReport(
|
||||
unsupportedRules: unsupported,
|
||||
normalizedRules: normalized,
|
||||
fontFailures: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func clampNumericProperty(
|
||||
in css: String,
|
||||
property: String,
|
||||
minValue: Double,
|
||||
maxValue: Double
|
||||
) -> (css: String, normalizedRules: [String]) {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*([0-9]*\.?[0-9]+)\s*;"#
|
||||
) else {
|
||||
return (css, [])
|
||||
}
|
||||
|
||||
let nsCSS = css as NSString
|
||||
var rewritten = css
|
||||
var normalized: [String] = []
|
||||
|
||||
for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
|
||||
guard match.numberOfRanges > 1 else { continue }
|
||||
let rawValue = nsCSS.substring(with: match.range(at: 1))
|
||||
|
||||
guard let value = Double(rawValue), value < minValue || value > maxValue else { continue }
|
||||
|
||||
let clamped = min(max(value, minValue), maxValue)
|
||||
if let range = Range(match.range, in: rewritten) {
|
||||
rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.3f", clamped));")
|
||||
normalized.append(property)
|
||||
}
|
||||
}
|
||||
return (rewritten, normalized)
|
||||
}
|
||||
|
||||
private func clampLengthProperty(
|
||||
in css: String,
|
||||
property: String,
|
||||
maxAbsolutePX: Double
|
||||
) -> (css: String, normalizedRules: [String]) {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*(-?[0-9]*\.?[0-9]+)px\s*;"#
|
||||
) else {
|
||||
return (css, [])
|
||||
}
|
||||
|
||||
let nsCSS = css as NSString
|
||||
var rewritten = css
|
||||
var normalized: [String] = []
|
||||
|
||||
for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
|
||||
guard match.numberOfRanges > 1 else { continue }
|
||||
let rawValue = nsCSS.substring(with: match.range(at: 1))
|
||||
|
||||
guard let value = Double(rawValue), abs(value) > maxAbsolutePX else { continue }
|
||||
|
||||
let clamped = value < 0 ? -maxAbsolutePX : maxAbsolutePX
|
||||
if let range = Range(match.range, in: rewritten) {
|
||||
rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.0f", clamped))px;")
|
||||
normalized.append(property)
|
||||
}
|
||||
}
|
||||
return (rewritten, normalized)
|
||||
}
|
||||
|
||||
private func addTextIndentResetForAlignedBlocks(
|
||||
in css: String
|
||||
) -> (css: String, normalizedRules: [String]) {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(?is)([^{}]+)\{([^{}]*)\}"#
|
||||
) else {
|
||||
return (css, [])
|
||||
}
|
||||
|
||||
let nsCSS = css as NSString
|
||||
var rewritten = css
|
||||
var normalized: [String] = []
|
||||
|
||||
for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
|
||||
guard match.numberOfRanges > 2 else { continue }
|
||||
let selector = nsCSS.substring(with: match.range(at: 1))
|
||||
let declarations = nsCSS.substring(with: match.range(at: 2))
|
||||
|
||||
guard declaresRightOrCenterAlignment(declarations),
|
||||
!declaresProperty("text-indent", in: declarations) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let replacement = "\(selector){\(declarations)\n text-indent: 0 !important;\n}"
|
||||
if let range = Range(match.range, in: rewritten) {
|
||||
rewritten.replaceSubrange(range, with: replacement)
|
||||
normalized.append("aligned-text-indent-reset")
|
||||
}
|
||||
}
|
||||
|
||||
return (rewritten, normalized)
|
||||
}
|
||||
|
||||
private func declaresRightOrCenterAlignment(_ declarations: String) -> Bool {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(?i)\btext-align\s*:\s*(right|center)\b"#
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
let range = NSRange(location: 0, length: (declarations as NSString).length)
|
||||
return regex.firstMatch(in: declarations, range: range) != nil
|
||||
}
|
||||
|
||||
private func declaresProperty(_ property: String, in declarations: String) -> Bool {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:"#
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
let range = NSRange(location: 0, length: (declarations as NSString).length)
|
||||
return regex.firstMatch(in: declarations, range: range) != nil
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBFontFallbackResolver {
|
||||
|
||||
static func fallbackChain(requestedFamily: String?, embeddedFamily: String?) -> RDEPUBFontFallbackChain {
|
||||
|
||||
let preferredCJK = ["PingFang SC", "Heiti SC", "Songti SC"]
|
||||
|
||||
let preferredLatin = ["Times New Roman", "Georgia", "Helvetica Neue"]
|
||||
return RDEPUBFontFallbackChain(
|
||||
requestedFamily: requestedFamily,
|
||||
embeddedFamily: embeddedFamily,
|
||||
systemFallbacks: preferredCJK + preferredLatin,
|
||||
finalFallback: UIFont.systemFont(ofSize: UIFont.systemFontSize).familyName
|
||||
)
|
||||
}
|
||||
|
||||
static func resolveFont(sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
|
||||
RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: baseFont)
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBStyleCompatibilityPolicy: Equatable {
|
||||
|
||||
public var allowPublisherFonts: Bool
|
||||
|
||||
public var allowPublisherMargins: Bool
|
||||
|
||||
public var normalizeLineHeight: Bool
|
||||
|
||||
public var normalizeTextIndent: Bool
|
||||
|
||||
public var clampImagesToViewport: Bool
|
||||
|
||||
public var fallbackUnsupportedWritingModes: Bool
|
||||
|
||||
public init(
|
||||
allowPublisherFonts: Bool = true,
|
||||
allowPublisherMargins: Bool = true,
|
||||
normalizeLineHeight: Bool = true,
|
||||
normalizeTextIndent: Bool = true,
|
||||
clampImagesToViewport: Bool = true,
|
||||
fallbackUnsupportedWritingModes: Bool = true
|
||||
) {
|
||||
self.allowPublisherFonts = allowPublisherFonts
|
||||
self.allowPublisherMargins = allowPublisherMargins
|
||||
self.normalizeLineHeight = normalizeLineHeight
|
||||
self.normalizeTextIndent = normalizeTextIndent
|
||||
self.clampImagesToViewport = clampImagesToViewport
|
||||
self.fallbackUnsupportedWritingModes = fallbackUnsupportedWritingModes
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBFontFallbackChain: Codable, Equatable {
|
||||
|
||||
public var requestedFamily: String?
|
||||
|
||||
public var embeddedFamily: String?
|
||||
|
||||
public var systemFallbacks: [String]
|
||||
|
||||
public var finalFallback: String
|
||||
|
||||
public init(
|
||||
requestedFamily: String? = nil,
|
||||
embeddedFamily: String? = nil,
|
||||
systemFallbacks: [String] = ["PingFang SC", "Heiti SC", "Times New Roman"],
|
||||
finalFallback: String = ".AppleSystemUIFont"
|
||||
) {
|
||||
self.requestedFamily = requestedFamily
|
||||
self.embeddedFamily = embeddedFamily
|
||||
self.systemFallbacks = systemFallbacks
|
||||
self.finalFallback = finalFallback
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBEmbeddedFontDescriptor: Codable, Equatable {
|
||||
|
||||
public var family: String?
|
||||
|
||||
public var href: String
|
||||
|
||||
public var format: String?
|
||||
|
||||
public var weight: Int?
|
||||
|
||||
public var style: String?
|
||||
}
|
||||
|
||||
public struct RDEPUBFontRegistrationResult: Codable, Equatable {
|
||||
|
||||
public var descriptor: RDEPUBEmbeddedFontDescriptor
|
||||
|
||||
public var fileURL: URL?
|
||||
|
||||
public var didRegister: Bool
|
||||
|
||||
public var errorDescription: String?
|
||||
}
|
||||
|
||||
public struct RDEPUBCSSCompatibilityReport: Codable, Equatable {
|
||||
|
||||
public var unsupportedRules: [String]
|
||||
|
||||
public var normalizedRules: [String]
|
||||
|
||||
public var fontFailures: [String]
|
||||
|
||||
public init(unsupportedRules: [String] = [], normalizedRules: [String] = [], fontFailures: [String] = []) {
|
||||
self.unsupportedRules = unsupportedRules
|
||||
self.normalizedRules = normalizedRules
|
||||
self.fontFailures = fontFailures
|
||||
}
|
||||
}
|
||||
|
||||
struct RDEPUBCSSCompatibilityResult {
|
||||
|
||||
var css: String
|
||||
|
||||
var report: RDEPUBCSSCompatibilityReport
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
struct RDEPUBAttachmentNormalizer {
|
||||
|
||||
private static var didLogFootnoteAttachment = false
|
||||
|
||||
private static var didLogCoverAttachment = false
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
func normalize(
|
||||
_ attachment: DTTextAttachment,
|
||||
fontPointSize: CGFloat,
|
||||
maxImageSize: CGSize
|
||||
) {
|
||||
Self.normalizeAttachmentLayoutForWXRead(
|
||||
attachment,
|
||||
fontPointSize: fontPointSize,
|
||||
maxImageSize: maxImageSize
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
static func normalizeAttachmentLayoutForWXRead(
|
||||
_ attachment: DTTextAttachment,
|
||||
fontPointSize: CGFloat,
|
||||
maxImageSize: CGSize? = nil
|
||||
) {
|
||||
|
||||
let pointSize = max(fontPointSize, 1)
|
||||
|
||||
let originalSize = attachment.originalSize
|
||||
|
||||
if isFootnoteAttachment(attachment) {
|
||||
|
||||
let targetWidth = max(round(pointSize), 1)
|
||||
|
||||
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
|
||||
|
||||
let targetHeight = max(round(targetWidth / max(aspectRatio, 0.1)), 1)
|
||||
|
||||
attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
|
||||
|
||||
attachment.verticalAlignment = .baseline
|
||||
|
||||
if !didLogFootnoteAttachment {
|
||||
didLogFootnoteAttachment = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isCoverAttachment(attachment) {
|
||||
|
||||
let maxSize = maxImageSize ?? defaultMaxImageSize(fontPointSize: pointSize)
|
||||
if originalSize.width > 0, originalSize.height > 0 {
|
||||
|
||||
let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
|
||||
attachment.displaySize = CGSize(
|
||||
width: round(originalSize.width * scale),
|
||||
height: round(originalSize.height * scale)
|
||||
)
|
||||
} else {
|
||||
|
||||
attachment.displaySize = maxSize
|
||||
}
|
||||
|
||||
attachment.verticalAlignment = .baseline
|
||||
|
||||
if !didLogCoverAttachment {
|
||||
didLogCoverAttachment = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var resolvedSize = attachment.displaySize
|
||||
|
||||
if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
|
||||
resolvedSize = originalSize
|
||||
}
|
||||
|
||||
if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
|
||||
resolvedSize = CGSize(width: pointSize, height: pointSize)
|
||||
}
|
||||
|
||||
if let maxImageSize,
|
||||
resolvedSize.width > 0,
|
||||
resolvedSize.height > 0,
|
||||
(resolvedSize.width > maxImageSize.width || resolvedSize.height > maxImageSize.height) {
|
||||
|
||||
let scale = min(maxImageSize.width / resolvedSize.width, maxImageSize.height / resolvedSize.height)
|
||||
resolvedSize = CGSize(
|
||||
width: round(resolvedSize.width * scale),
|
||||
height: round(resolvedSize.height * scale)
|
||||
)
|
||||
}
|
||||
|
||||
attachment.displaySize = CGSize(width: round(resolvedSize.width), height: round(resolvedSize.height))
|
||||
|
||||
attachment.verticalAlignment = .center
|
||||
}
|
||||
|
||||
static func prepareHTMLElementForReaderRendering(
|
||||
_ element: DTHTMLElement,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
maxImageSize: CGSize? = nil
|
||||
) {
|
||||
|
||||
guard let attachment = element.textAttachment else { return }
|
||||
|
||||
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
|
||||
|
||||
let fallbackSize = CGSize(
|
||||
width: defaultMaxImageSize(fontPointSize: pointSize).width,
|
||||
height: defaultMaxImageSize(fontPointSize: pointSize).height
|
||||
)
|
||||
|
||||
normalizeAttachmentLayoutForWXRead(
|
||||
attachment,
|
||||
fontPointSize: pointSize,
|
||||
maxImageSize: maxImageSize ?? fallbackSize
|
||||
)
|
||||
|
||||
if isFootnoteAttachment(attachment) {
|
||||
element.displayStyle = .inline
|
||||
} else if isCoverAttachment(attachment) {
|
||||
element.displayStyle = .block
|
||||
}
|
||||
}
|
||||
|
||||
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||
|
||||
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||
if lowercasedClasses.contains("qqreader-footnote") {
|
||||
return true
|
||||
}
|
||||
let altText = attachment.attributes["alt"] as? String
|
||||
return hasFootnoteAltText(altText) && isFootnoteSizedImage(attachment.originalSize)
|
||||
}
|
||||
|
||||
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||
|
||||
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||
|
||||
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
|
||||
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
|
||||
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg"
|
||||
}
|
||||
|
||||
private static func defaultMaxImageSize(fontPointSize: CGFloat) -> CGSize {
|
||||
|
||||
let referenceViewport = CGSize(width: 375, height: 667)
|
||||
|
||||
let horizontalInset = max(round(fontPointSize), 16)
|
||||
|
||||
let verticalInset = max(round(fontPointSize * 1.5), 28)
|
||||
|
||||
return CGSize(
|
||||
width: max(round(referenceViewport.width - horizontalInset * 2), 1),
|
||||
height: max(round((referenceViewport.height - verticalInset * 2) * 0.85), 1)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
static func normalizeAttachmentDisplayIfNeeded(
|
||||
in attributes: inout [NSAttributedString.Key: Any],
|
||||
font: UIFont
|
||||
) {
|
||||
|
||||
guard let attachment = attributes[.attachment] else { return }
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
if let textAttachment = attachment as? DTTextAttachment {
|
||||
normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize)
|
||||
attributes[.attachment] = textAttachment
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
if let textAttachment = attachment as? NSTextAttachment, textAttachment.bounds.height <= 0 {
|
||||
|
||||
let targetHeight = max(round(font.pointSize * 0.86), 1)
|
||||
textAttachment.bounds = CGRect(x: 0, y: 0, width: targetHeight, height: targetHeight)
|
||||
attributes[.attachment] = textAttachment
|
||||
}
|
||||
}
|
||||
|
||||
static func attachmentKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentKind? {
|
||||
// Check for footnote attachments first — they should show tooltip, not image viewer
|
||||
if isFootnoteAttachmentInAttributes(attributes) {
|
||||
return .footnote
|
||||
}
|
||||
|
||||
if let attachment = attributes[.attachment] as? NSTextAttachment {
|
||||
|
||||
if attachment.image != nil || attachment.fileType?.lowercased().contains("image") == true {
|
||||
return .image
|
||||
}
|
||||
return .generic
|
||||
}
|
||||
|
||||
for value in attributes.values {
|
||||
let typeName = String(describing: type(of: value)).lowercased()
|
||||
|
||||
if typeName.contains("attachment") {
|
||||
return typeName.contains("image") ? .image : .generic
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isFootnoteAttachmentInAttributes(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
|
||||
#if canImport(DTCoreText)
|
||||
if let textAttachment = attributes[.attachment] as? DTTextAttachment {
|
||||
return isFootnoteAttachment(textAttachment)
|
||||
}
|
||||
#endif
|
||||
guard let fileAttachment = attributes[.attachment] as? NSTextAttachment else {
|
||||
return false
|
||||
}
|
||||
let label = fileAttachment.accessibilityLabel
|
||||
let lowercasedLabel = (label ?? "").lowercased()
|
||||
if lowercasedLabel.contains("qqreader-footnote") {
|
||||
return true
|
||||
}
|
||||
let imageSize = fileAttachment.image?.size ?? fileAttachment.bounds.size
|
||||
return hasFootnoteAltText(label) && isFootnoteSizedImage(imageSize)
|
||||
}
|
||||
|
||||
// Footnote images without the qqreader-footnote class are recognized by their
|
||||
// alt text carrying the note body. Short alts ("logo", "图1") are ordinary
|
||||
// accessibility descriptions, and note markers are small inline icons, so both
|
||||
// conditions must hold before an image is shrunk to footnote size.
|
||||
private static let minimumFootnoteAltTextLength = 8
|
||||
|
||||
private static let maximumFootnoteImageDimension: CGFloat = 50
|
||||
|
||||
private static func hasFootnoteAltText(_ text: String?) -> Bool {
|
||||
guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||
return false
|
||||
}
|
||||
return trimmed.count >= minimumFootnoteAltTextLength
|
||||
}
|
||||
|
||||
private static func isFootnoteSizedImage(_ size: CGSize) -> Bool {
|
||||
guard size.width > 0, size.height > 0 else { return false }
|
||||
return size.width <= maximumFootnoteImageDimension
|
||||
&& size.height <= maximumFootnoteImageDimension
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBCFIMarkerInjector: RDEPUBTypesettingStage {
|
||||
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"<([A-Za-z][A-Za-z0-9:_-]*)([^>]*\s(?:id|xml:id)\s*=\s*['"]([^'"]+)['"][^>]*)>"#,
|
||||
options: [.caseInsensitive]
|
||||
) else {
|
||||
return html
|
||||
}
|
||||
|
||||
let nsHTML = html as NSString
|
||||
|
||||
let matches = regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
|
||||
guard !matches.isEmpty else { return html }
|
||||
|
||||
var rewritten = html
|
||||
for match in matches.reversed() {
|
||||
|
||||
guard match.numberOfRanges > 3,
|
||||
let fullRange = Range(match.range(at: 0), in: rewritten),
|
||||
let tagNameRange = Range(match.range(at: 1), in: html),
|
||||
let attributesRange = Range(match.range(at: 2), in: html),
|
||||
let fragmentRange = Range(match.range(at: 3), in: html) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let tagName = String(html[tagNameRange])
|
||||
let attributes = String(html[attributesRange])
|
||||
let fragmentID = String(html[fragmentRange])
|
||||
|
||||
guard attributes.range(of: "data-rd-cfi-marker", options: [.caseInsensitive]) == nil else {
|
||||
continue
|
||||
}
|
||||
|
||||
let marker = RDEPUBCFIGenerator.makeOffsetCFI(
|
||||
href: context.href,
|
||||
fileIndex: context.spineIndex ?? 0,
|
||||
chapterOffset: 0,
|
||||
fragmentID: fragmentID
|
||||
).rawValue
|
||||
|
||||
let replacement = "<\(tagName)\(attributes) data-rd-cfi-marker=\"\(Self.escapeAttribute(marker))\">"
|
||||
rewritten.replaceSubrange(fullRange, with: replacement)
|
||||
}
|
||||
|
||||
return rewritten
|
||||
}
|
||||
|
||||
private static func escapeAttribute(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
|
||||
/// 支持加密图片的 DTCoreText 图片附件。
|
||||
///
|
||||
/// DTCoreText 解析 `<img>` 时会直接读磁盘文件来取图并推算尺寸;
|
||||
/// 对加密图片该读取会解码失败,表现为 `image == nil` 且 `contentURL` 被保留。
|
||||
/// 本子类在 super 初始化完成后接管这种失败场景:
|
||||
/// 经 `RDEPUBResourceAccessRegistry` 反查 provider 解密数据并调用 `setImage`
|
||||
/// (setter 会自动补齐 originalSize / displaySize,后续 willFlush 阶段的
|
||||
/// 附件规范化与绘制均按明文图片的既有路径工作)。
|
||||
///
|
||||
/// 通过 `DTTextAttachment.registerClass(_:forTagName:)` 全局注册,
|
||||
/// 未启用 provider 的书行为与父类完全一致。
|
||||
final class RDEPUBDecryptingImageAttachment: DTImageTextAttachment {
|
||||
|
||||
private static var didRegisterTagClass = false
|
||||
|
||||
private static let registerLock = NSLock()
|
||||
|
||||
/// 将本类注册为 `<img>` 标签的附件类(进程内一次性,线程安全)
|
||||
static func registerTagClassIfNeeded() {
|
||||
registerLock.lock()
|
||||
defer { registerLock.unlock() }
|
||||
guard !didRegisterTagClass else { return }
|
||||
DTTextAttachment.registerClass(RDEPUBDecryptingImageAttachment.self, forTagName: "img")
|
||||
didRegisterTagClass = true
|
||||
}
|
||||
|
||||
override init!(element: DTHTMLElement!, options: [AnyHashable: Any]! = [:]) {
|
||||
super.init(element: element, options: options)
|
||||
decryptImageIfNeeded()
|
||||
}
|
||||
|
||||
/// DTCoreText / UIKit 在部分 HTML 图片路径会改用 NSTextAttachment 的
|
||||
/// `init(data:ofType:)` 创建附件。若子类未实现,Foundation 会直接触发
|
||||
/// “Use of unimplemented initializer” 致命错误。
|
||||
override init(data contentData: Data?, ofType uti: String?) {
|
||||
super.init(data: contentData, ofType: uti)
|
||||
if image == nil, let contentData {
|
||||
image = UIImage(data: contentData)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
private func decryptImageIfNeeded() {
|
||||
// super 已成功取到图(明文图片 / data: 内联图)时不干预
|
||||
guard image == nil,
|
||||
let contentURL,
|
||||
contentURL.isFileURL,
|
||||
let data = RDEPUBResourceAccessRegistry.providedResourceData(at: contentURL.standardizedFileURL),
|
||||
let decrypted = UIImage(data: data) else {
|
||||
return
|
||||
}
|
||||
image = decrypted
|
||||
// 对齐 DTCoreText 本地图片加载成功的行为:置空 contentURL,
|
||||
// 避免下游误当作待加载的远程 / 磁盘图片再次直读密文
|
||||
self.contentURL = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,160 @@
|
||||
import UIKit
|
||||
|
||||
import CoreText
|
||||
|
||||
struct RDEPUBFontNormalizer {
|
||||
|
||||
private static var registeredFontPaths = Set<String>()
|
||||
|
||||
@discardableResult
|
||||
func registerEmbeddedFonts(
|
||||
html: String,
|
||||
inlinedCSS: String,
|
||||
input: RDEPUBTypesettingInput
|
||||
) -> [RDEPUBFontRegistrationResult] {
|
||||
|
||||
Self.registerEmbeddedFonts(
|
||||
in: inlinedCSS + "\n" + Self.inlineStyleCSS(in: html),
|
||||
chapterHref: input.href,
|
||||
resourceResolver: input.resourceResolver
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func registerEmbeddedFonts(
|
||||
in css: String,
|
||||
chapterHref: String,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) -> [RDEPUBFontRegistrationResult] {
|
||||
|
||||
guard let resourceResolver,
|
||||
|
||||
let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]),
|
||||
|
||||
let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
|
||||
return []
|
||||
}
|
||||
|
||||
var results: [RDEPUBFontRegistrationResult] = []
|
||||
let nsCSS = css as NSString
|
||||
|
||||
for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) {
|
||||
guard faceMatch.numberOfRanges > 1 else { continue }
|
||||
|
||||
let block = nsCSS.substring(with: faceMatch.range(at: 1))
|
||||
let nsBlock = block as NSString
|
||||
|
||||
let family = declarationValue(named: "font-family", in: block)?
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||
|
||||
let weight = declarationValue(named: "font-weight", in: block).flatMap(Int.init)
|
||||
|
||||
let style = declarationValue(named: "font-style", in: block)
|
||||
|
||||
for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) {
|
||||
guard urlMatch.numberOfRanges > 1 else { continue }
|
||||
|
||||
let rawReference = nsBlock.substring(with: urlMatch.range(at: 1))
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t"))
|
||||
|
||||
let descriptor = RDEPUBEmbeddedFontDescriptor(
|
||||
family: family,
|
||||
href: rawReference,
|
||||
format: nil,
|
||||
weight: weight,
|
||||
style: style
|
||||
)
|
||||
|
||||
guard !rawReference.isEmpty,
|
||||
!rawReference.hasPrefix("data:"),
|
||||
!rawReference.hasPrefix("http:"),
|
||||
!rawReference.hasPrefix("https:"),
|
||||
|
||||
let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else {
|
||||
|
||||
results.append(
|
||||
RDEPUBFontRegistrationResult(
|
||||
descriptor: descriptor,
|
||||
fileURL: nil,
|
||||
didRegister: false,
|
||||
errorDescription: "Font URL could not be resolved"
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
let didRegister = registerFontIfNeeded(at: fileURL)
|
||||
results.append(
|
||||
RDEPUBFontRegistrationResult(
|
||||
descriptor: descriptor,
|
||||
fileURL: fileURL,
|
||||
didRegister: didRegister,
|
||||
errorDescription: didRegister ? nil : "Font was already registered or registration failed"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func registerFontIfNeeded(at fileURL: URL) -> Bool {
|
||||
|
||||
let standardizedPath = fileURL.standardizedFileURL.path
|
||||
|
||||
guard !registeredFontPaths.contains(standardizedPath) else { return true }
|
||||
|
||||
let registered = CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
|
||||
if registered {
|
||||
|
||||
registeredFontPaths.insert(standardizedPath)
|
||||
}
|
||||
return registered
|
||||
}
|
||||
|
||||
static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
|
||||
guard let sourceFont else {
|
||||
return baseFont
|
||||
}
|
||||
|
||||
let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
|
||||
|
||||
if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
|
||||
return UIFont(descriptor: descriptor, size: baseFont.pointSize)
|
||||
}
|
||||
|
||||
return baseFont
|
||||
}
|
||||
|
||||
static func inlineStyleCSS(in html: String) -> String {
|
||||
|
||||
guard let regex = try? NSRegularExpression(pattern: #"<style\b[^>]*>([\s\S]*?)</style>"#, options: [.caseInsensitive]) else {
|
||||
return ""
|
||||
}
|
||||
let nsHTML = html as NSString
|
||||
|
||||
return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
|
||||
.compactMap { match in
|
||||
guard match.numberOfRanges > 1 else { return nil }
|
||||
return nsHTML.substring(with: match.range(at: 1))
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func declarationValue(named name: String, in block: String) -> String? {
|
||||
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: name) + #"\s*:\s*([^;]+)"#
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let nsBlock = block as NSString
|
||||
|
||||
guard let match = regex.firstMatch(in: block, range: NSRange(location: 0, length: nsBlock.length)),
|
||||
match.numberOfRanges > 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nsBlock.substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
|
||||
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.injectFragmentMarkers(into: html)
|
||||
}
|
||||
|
||||
func extractOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
Self.extractFragmentOffsets(from: attributedString)
|
||||
}
|
||||
|
||||
static func injectFragmentMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
return regex.stringByReplacingMatches(
|
||||
in: html,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: html.utf16.count),
|
||||
withTemplate: "${id=$2}$1"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
|
||||
let markerPattern = #"\$\{id=([^}]+)\}"#
|
||||
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
|
||||
let mutableString = NSMutableString(string: attributedString.string)
|
||||
|
||||
var fragmentOffsets: [String: Int] = [:]
|
||||
|
||||
var searchRange = NSRange(location: 0, length: mutableString.length)
|
||||
|
||||
var offsetAdjustment = 0
|
||||
|
||||
|
||||
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
|
||||
|
||||
let fullMatch = mutableString.substring(with: match.range) as NSString
|
||||
let fragmentID = fullMatch
|
||||
.replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length))
|
||||
.replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression)
|
||||
|
||||
let adjustedLocation = max(0, match.range.location + offsetAdjustment)
|
||||
|
||||
fragmentOffsets[fragmentID] = adjustedLocation
|
||||
|
||||
attributedString.deleteCharacters(in: match.range)
|
||||
mutableString.deleteCharacters(in: match.range)
|
||||
|
||||
offsetAdjustment -= match.range.length
|
||||
|
||||
searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location)
|
||||
}
|
||||
|
||||
return fragmentOffsets
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.normalizeHTML(html)
|
||||
}
|
||||
|
||||
static func normalizeHTML(_ html: String) -> String {
|
||||
var cleanedHTML = html
|
||||
|
||||
let replacements: [(pattern: String, template: String)] = [
|
||||
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
|
||||
(#"\r"#, "\n"),
|
||||
(#"\n+"#, "\n")
|
||||
]
|
||||
|
||||
for replacement in replacements {
|
||||
if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
|
||||
cleanedHTML = regex.stringByReplacingMatches(
|
||||
in: cleanedHTML,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: cleanedHTML.utf16.count),
|
||||
withTemplate: replacement.template
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
cleanedHTML = normalizeAttachmentHTMLMarkers(in: cleanedHTML)
|
||||
cleanedHTML = normalizeBodyLeadingSpacing(in: cleanedHTML)
|
||||
return cleanedHTML
|
||||
}
|
||||
|
||||
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
||||
var normalized = html
|
||||
|
||||
if let bodyPicContainerRegex = try? NSRegularExpression(
|
||||
pattern: #"<div\b([^>]*class\s*=\s*["'][^"']*\b(?:qrbodyPic|bodyPic)\b[^"']*["'][^>]*)>([\s\S]*?)</div>"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
normalized = replaceMatches(
|
||||
using: bodyPicContainerRegex,
|
||||
in: normalized
|
||||
) { tag in
|
||||
|
||||
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]) else {
|
||||
return tag
|
||||
}
|
||||
|
||||
|
||||
return replaceMatches(
|
||||
using: imageTagRegex,
|
||||
in: tag
|
||||
) { imageTag in
|
||||
mergeHTMLAttributes(
|
||||
into: imageTag,
|
||||
requiredClass: "bodyPic",
|
||||
styleFragments: [
|
||||
"wr-vertical-center-style:2",
|
||||
"max-width:100%",
|
||||
"height:auto",
|
||||
"display:block",
|
||||
"margin-left:auto",
|
||||
"margin-right:auto"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let footnoteRegex = try? NSRegularExpression(
|
||||
pattern: #"<img\b([^>]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
normalized = replaceMatches(
|
||||
using: footnoteRegex,
|
||||
in: normalized
|
||||
) { tag in
|
||||
|
||||
mergeHTMLAttributes(
|
||||
into: tag,
|
||||
requiredClass: nil,
|
||||
styleFragments: [
|
||||
"width:1em",
|
||||
"height:1em",
|
||||
"vertical-align:middle",
|
||||
"display:inline-block"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let coverRegex = try? NSRegularExpression(
|
||||
pattern: #"<h1\b([^>]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(<img\b[^>]*>)\s*</h1>"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
normalized = replaceMatches(
|
||||
using: coverRegex,
|
||||
in: normalized
|
||||
) { tag in
|
||||
|
||||
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]),
|
||||
let imageMatch = imageTagRegex.firstMatch(
|
||||
in: tag,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: (tag as NSString).length)
|
||||
),
|
||||
let imageRange = Range(imageMatch.range, in: tag) else {
|
||||
return tag
|
||||
}
|
||||
|
||||
let imageTag = String(tag[imageRange])
|
||||
let normalizedImageTag = mergeHTMLAttributes(
|
||||
into: imageTag,
|
||||
requiredClass: "rd-front-cover-image",
|
||||
styleFragments: [
|
||||
"display:block",
|
||||
"width:100%",
|
||||
"height:auto",
|
||||
"margin-left:auto",
|
||||
"margin-right:auto"
|
||||
]
|
||||
)
|
||||
return tag.replacingCharacters(in: imageRange, with: normalizedImageTag)
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
private static func normalizeBodyLeadingSpacing(in html: String) -> String {
|
||||
var normalized = html
|
||||
|
||||
if let bodyStartRegex = try? NSRegularExpression(
|
||||
pattern: #"(<body\b[^>]*>)\s+"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
normalized = bodyStartRegex.stringByReplacingMatches(
|
||||
in: normalized,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: (normalized as NSString).length),
|
||||
withTemplate: "$1"
|
||||
)
|
||||
}
|
||||
|
||||
if let bodyEndRegex = try? NSRegularExpression(
|
||||
pattern: #"\s+(</body>)"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
normalized = bodyEndRegex.stringByReplacingMatches(
|
||||
in: normalized,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: (normalized as NSString).length),
|
||||
withTemplate: "$1"
|
||||
)
|
||||
}
|
||||
|
||||
guard let firstBlockRegex = try? NSRegularExpression(
|
||||
pattern: #"(<body\b[^>]*>)(\s*)(<(?<tag>h[1-6]|p|div|blockquote|section|article|ul|ol)\b[^>]*>)"#,
|
||||
options: [.caseInsensitive]
|
||||
) else {
|
||||
return normalized
|
||||
}
|
||||
|
||||
let nsNormalized = normalized as NSString
|
||||
let fullRange = NSRange(location: 0, length: nsNormalized.length)
|
||||
guard let match = firstBlockRegex.firstMatch(in: normalized, options: [], range: fullRange),
|
||||
match.numberOfRanges >= 4,
|
||||
let bodyRange = Range(match.range(at: 1), in: normalized),
|
||||
let blockRange = Range(match.range(at: 3), in: normalized) else {
|
||||
return normalized
|
||||
}
|
||||
|
||||
let prefix = String(normalized[..<bodyRange.lowerBound])
|
||||
let bodyTag = String(normalized[bodyRange])
|
||||
let blockTag = String(normalized[blockRange])
|
||||
let normalizedBlockTag = mergeHTMLAttributes(
|
||||
into: blockTag,
|
||||
requiredClass: nil,
|
||||
styleFragments: [
|
||||
"margin-top:0 !important",
|
||||
"-webkit-margin-before:0 !important",
|
||||
"padding-top:0 !important"
|
||||
]
|
||||
)
|
||||
return prefix + bodyTag + normalizedBlockTag + String(normalized[blockRange.upperBound...])
|
||||
}
|
||||
|
||||
static func replaceMatches(
|
||||
using regex: NSRegularExpression,
|
||||
in source: String,
|
||||
transform: (String) -> String
|
||||
) -> String {
|
||||
let nsSource = source as NSString
|
||||
|
||||
let matches = regex.matches(in: source, options: [], range: NSRange(location: 0, length: nsSource.length))
|
||||
guard !matches.isEmpty else { return source }
|
||||
|
||||
var rewritten = source
|
||||
for match in matches.reversed() {
|
||||
guard let range = Range(match.range, in: rewritten) else { continue }
|
||||
let original = String(rewritten[range])
|
||||
rewritten.replaceSubrange(range, with: transform(original))
|
||||
}
|
||||
return rewritten
|
||||
}
|
||||
|
||||
static func mergeHTMLAttributes(
|
||||
into tag: String,
|
||||
requiredClass: String?,
|
||||
styleFragments: [String]
|
||||
) -> String {
|
||||
var rewritten = tag
|
||||
if let requiredClass {
|
||||
if let classRegex = try? NSRegularExpression(pattern: #"class\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]),
|
||||
let match = classRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)),
|
||||
match.numberOfRanges > 1 {
|
||||
let existingClasses = (rewritten as NSString).substring(with: match.range(at: 1))
|
||||
if !existingClasses.localizedCaseInsensitiveContains(requiredClass) {
|
||||
let replacement = #"class="\#(existingClasses) \#(requiredClass)""#
|
||||
if let range = Range(match.range, in: rewritten) {
|
||||
rewritten.replaceSubrange(range, with: replacement)
|
||||
}
|
||||
}
|
||||
} else if let closing = rewritten.lastIndex(of: ">") {
|
||||
rewritten.insert(contentsOf: #" class="\#(requiredClass)""#, at: closing)
|
||||
}
|
||||
}
|
||||
|
||||
let styleValue = styleFragments.joined(separator: ";") + ";"
|
||||
if let styleRegex = try? NSRegularExpression(pattern: #"style\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]),
|
||||
let match = styleRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)),
|
||||
match.numberOfRanges > 1 {
|
||||
let existing = (rewritten as NSString).substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let merged = existing.isEmpty ? styleValue : existing + (existing.hasSuffix(";") ? "" : ";") + styleValue
|
||||
let replacement = #"style="\#(merged)""#
|
||||
if let range = Range(match.range, in: rewritten) {
|
||||
rewritten.replaceSubrange(range, with: replacement)
|
||||
}
|
||||
} else if let closing = rewritten.lastIndex(of: ">") {
|
||||
rewritten.insert(contentsOf: #" style="\#(styleValue)""#, at: closing)
|
||||
}
|
||||
|
||||
return rewritten
|
||||
}
|
||||
|
||||
static func injectBaseHref(into html: String, baseURL: URL?) -> String {
|
||||
guard let baseURL else {
|
||||
return html
|
||||
}
|
||||
let baseTag = "<base href=\"\(baseURL.absoluteString)\">"
|
||||
if html.range(of: "<base ", options: [.caseInsensitive]) != nil {
|
||||
return html
|
||||
}
|
||||
if html.range(of: "<head>", options: [.caseInsensitive]) != nil {
|
||||
return html.replacingOccurrences(of: "<head>", with: "<head>\n\(baseTag)", options: [.caseInsensitive])
|
||||
}
|
||||
if let htmlTagRange = html.range(of: "<html", options: [.caseInsensitive]),
|
||||
let htmlRange = html.range(of: ">", range: htmlTagRange.lowerBound..<html.endIndex) {
|
||||
return html.replacingCharacters(in: htmlRange.upperBound..<htmlRange.upperBound, with: "\n<head>\n\(baseTag)\n</head>")
|
||||
}
|
||||
return "<head>\n\(baseTag)\n</head>\n" + html
|
||||
}
|
||||
|
||||
static func string(from size: CGSize) -> String {
|
||||
"{\(Int(round(size.width))), \(Int(round(size.height)))}"
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBRenderDiagnosticsCollector {
|
||||
|
||||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
|
||||
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func collect(
|
||||
in html: String,
|
||||
input: RDEPUBTypesettingInput
|
||||
) -> [RDEPUBTextResourceReferenceDiagnostic] {
|
||||
Self.collectImageDiagnostics(
|
||||
in: html,
|
||||
chapterHref: input.href,
|
||||
baseURL: input.baseURL,
|
||||
resourceResolver: input.resourceResolver
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
static func collectImageDiagnostics(
|
||||
in html: String,
|
||||
chapterHref: String,
|
||||
baseURL: URL?,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) -> [RDEPUBTextResourceReferenceDiagnostic] {
|
||||
guard let regex = try? NSRegularExpression(pattern: imageSourcePattern, options: [.caseInsensitive]) else {
|
||||
return []
|
||||
}
|
||||
let nsHTML = html as NSString
|
||||
return regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in
|
||||
guard match.numberOfRanges > 1 else { return nil }
|
||||
let href = nsHTML.substring(with: match.range(at: 1))
|
||||
return resolveReference(
|
||||
href,
|
||||
kind: .image,
|
||||
chapterHref: chapterHref,
|
||||
baseURL: baseURL,
|
||||
resourceResolver: resourceResolver
|
||||
).diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
static func inlineLinkedStyleSheets(
|
||||
in html: String,
|
||||
chapterHref: String,
|
||||
baseURL: URL?,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) -> (html: String, inlinedCSS: String, diagnostics: [RDEPUBTextResourceReferenceDiagnostic]) {
|
||||
guard let regex = try? NSRegularExpression(pattern: stylesheetLinkPattern, options: [.caseInsensitive]) else {
|
||||
return (html, "", [])
|
||||
}
|
||||
|
||||
let nsHTML = html as NSString
|
||||
let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
|
||||
guard !matches.isEmpty else {
|
||||
return (html, "", [])
|
||||
}
|
||||
|
||||
|
||||
var rewrittenHTML = html
|
||||
|
||||
var inlinedCSSBlocks: [String] = []
|
||||
|
||||
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
|
||||
|
||||
|
||||
for match in matches.reversed() {
|
||||
guard match.numberOfRanges > 1 else { continue }
|
||||
let href = nsHTML.substring(with: match.range(at: 1))
|
||||
let resolution = resolveReference(
|
||||
href,
|
||||
kind: .stylesheet,
|
||||
chapterHref: chapterHref,
|
||||
baseURL: baseURL,
|
||||
resourceResolver: resourceResolver
|
||||
)
|
||||
diagnostics.append(resolution.diagnostic)
|
||||
|
||||
if let fileURL = resolution.resolvedFileURL,
|
||||
let css = styleSheetContent(at: fileURL, resourceResolver: resourceResolver),
|
||||
resolution.diagnostic.existsOnDisk {
|
||||
let cssWithResolvedURLs = rewriteCSSResourceURLs(
|
||||
in: css,
|
||||
styleSheetFileURL: fileURL
|
||||
)
|
||||
inlinedCSSBlocks.append(cssWithResolvedURLs)
|
||||
}
|
||||
|
||||
if let range = Range(match.range, in: rewrittenHTML) {
|
||||
rewrittenHTML.replaceSubrange(range, with: "")
|
||||
}
|
||||
}
|
||||
|
||||
return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed())
|
||||
}
|
||||
|
||||
/// 读取样式表内容:优先经加密资源 provider 解密,明文文件保持原直读路径
|
||||
private static func styleSheetContent(
|
||||
at fileURL: URL,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) -> String? {
|
||||
if let provided = resourceResolver?.providedResourceData(at: fileURL) {
|
||||
return String(data: provided, encoding: .utf8)
|
||||
?? String(data: provided, encoding: .utf16)
|
||||
}
|
||||
return try? String(contentsOf: fileURL)
|
||||
}
|
||||
|
||||
|
||||
static func rewriteCSSResourceURLs(
|
||||
in css: String,
|
||||
styleSheetFileURL: URL
|
||||
) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
|
||||
return css
|
||||
}
|
||||
|
||||
let nsCSS = css as NSString
|
||||
let matches = regex.matches(in: css, options: [], range: NSRange(location: 0, length: nsCSS.length))
|
||||
guard !matches.isEmpty else {
|
||||
return css
|
||||
}
|
||||
|
||||
var rewrittenCSS = css
|
||||
|
||||
for match in matches.reversed() {
|
||||
guard match.numberOfRanges > 1 else { continue }
|
||||
|
||||
let rawValue = nsCSS.substring(with: match.range(at: 1))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||
guard !rawValue.isEmpty else { continue }
|
||||
|
||||
if rawValue.hasPrefix("data:")
|
||||
|| rawValue.hasPrefix("http:")
|
||||
|| rawValue.hasPrefix("https:") {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let resolvedURL = URL(string: rawValue, relativeTo: styleSheetFileURL.deletingLastPathComponent())?.standardizedFileURL else {
|
||||
continue
|
||||
}
|
||||
|
||||
let replacement = "url(\"\(resolvedURL.absoluteString)\")"
|
||||
if let range = Range(match.range, in: rewrittenCSS) {
|
||||
rewrittenCSS.replaceSubrange(range, with: replacement)
|
||||
}
|
||||
}
|
||||
return rewrittenCSS
|
||||
}
|
||||
|
||||
static func resolveReference(
|
||||
_ reference: String,
|
||||
kind: RDEPUBTextResourceReferenceKind,
|
||||
chapterHref: String,
|
||||
baseURL: URL?,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) -> (normalizedHref: String?, resolvedFileURL: URL?, diagnostic: RDEPUBTextResourceReferenceDiagnostic) {
|
||||
|
||||
let trimmedReference = reference.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let normalizedHref = resourceResolver?.normalizedHref(trimmedReference, relativeToHref: chapterHref)
|
||||
|
||||
let resolvedFileURL = resourceResolver?.fileURL(forReference: trimmedReference, relativeToHref: chapterHref)
|
||||
?? URL(string: trimmedReference, relativeTo: baseURL)?.standardizedFileURL
|
||||
|
||||
let existsOnDisk = resolvedFileURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
|
||||
|
||||
let diagnostic = RDEPUBTextResourceReferenceDiagnostic(
|
||||
kind: kind,
|
||||
chapterHref: chapterHref,
|
||||
originalReference: trimmedReference,
|
||||
normalizedHref: normalizedHref,
|
||||
resolvedFileURL: resolvedFileURL,
|
||||
existsOnDisk: existsOnDisk
|
||||
)
|
||||
return (normalizedHref, resolvedFileURL, diagnostic)
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
|
||||
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
||||
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.injectPaginationSemanticMarkers(into: html)
|
||||
}
|
||||
|
||||
func apply(to attributedString: NSMutableAttributedString) {
|
||||
Self.applyPaginationSemantics(in: attributedString)
|
||||
}
|
||||
|
||||
static func injectPaginationSemanticMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
|
||||
let nsHTML = html as NSString
|
||||
let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
|
||||
guard !matches.isEmpty else {
|
||||
return html
|
||||
}
|
||||
|
||||
var output = ""
|
||||
|
||||
var cursor = 0
|
||||
|
||||
var openTagStack: [(name: String, id: String)] = []
|
||||
|
||||
var nextMarkerID = 0
|
||||
|
||||
for match in matches {
|
||||
let tagRange = match.range
|
||||
guard tagRange.location >= cursor else { continue }
|
||||
output += nsHTML.substring(with: NSRange(location: cursor, length: tagRange.location - cursor))
|
||||
|
||||
let tag = nsHTML.substring(with: tagRange)
|
||||
let loweredTag = tag.lowercased()
|
||||
|
||||
let tagName = htmlTagName(from: loweredTag)
|
||||
|
||||
if loweredTag.hasPrefix("</"), let tagName {
|
||||
|
||||
if let index = openTagStack.lastIndex(where: { $0.name == tagName }) {
|
||||
let markerID = openTagStack.remove(at: index).id
|
||||
output += semanticEndMarker(id: markerID)
|
||||
}
|
||||
output += tag
|
||||
} else if let tagName,
|
||||
let semantics = paginationSemantics(forTagName: tagName, rawTag: tag) {
|
||||
|
||||
nextMarkerID += 1
|
||||
let markerID = String(nextMarkerID)
|
||||
let startMarker = semanticStartMarker(id: markerID, semantics: semantics)
|
||||
if isVoidHTMLTag(tagName) || loweredTag.hasSuffix("/>") {
|
||||
|
||||
output += startMarker + tag + semanticEndMarker(id: markerID)
|
||||
} else {
|
||||
|
||||
openTagStack.append((name: tagName, id: markerID))
|
||||
output += tag + startMarker
|
||||
}
|
||||
} else {
|
||||
|
||||
output += tag
|
||||
}
|
||||
|
||||
cursor = tagRange.location + tagRange.length
|
||||
}
|
||||
|
||||
output += nsHTML.substring(from: cursor)
|
||||
return output
|
||||
}
|
||||
|
||||
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
|
||||
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
|
||||
return
|
||||
}
|
||||
|
||||
let mutableString = NSMutableString(string: attributedString.string)
|
||||
|
||||
var searchRange = NSRange(location: 0, length: mutableString.length)
|
||||
|
||||
var openRanges: [String: (location: Int, semantics: RDPaginationSemantics)] = [:]
|
||||
|
||||
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
|
||||
|
||||
let kind = mutableString.substring(with: match.range(at: 1))
|
||||
|
||||
let payload = mutableString.substring(with: match.range(at: 2))
|
||||
|
||||
let markerLocation = match.range.location
|
||||
|
||||
attributedString.deleteCharacters(in: match.range)
|
||||
mutableString.deleteCharacters(in: match.range)
|
||||
|
||||
if kind == "start" {
|
||||
|
||||
let semantics = parseSemanticMarkerPayload(payload)
|
||||
openRanges[semantics.id] = (markerLocation, semantics)
|
||||
} else {
|
||||
|
||||
let markerID = parseSemanticEndID(payload)
|
||||
if let markerID, let opened = openRanges.removeValue(forKey: markerID) {
|
||||
let length = max(markerLocation - opened.location, 0)
|
||||
if length > 0 {
|
||||
apply(semantics: opened.semantics, to: NSRange(location: opened.location, length: length), in: attributedString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchRange = NSRange(location: markerLocation, length: mutableString.length - markerLocation)
|
||||
}
|
||||
}
|
||||
|
||||
static func htmlTagName(from loweredTag: String) -> String? {
|
||||
let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("<") else { return nil }
|
||||
let body = trimmed.dropFirst().drop(while: { $0 == "/" || $0 == "!" || $0 == "?" })
|
||||
let name = body.prefix { $0.isLetter || $0.isNumber }
|
||||
return name.isEmpty ? nil : String(name)
|
||||
}
|
||||
|
||||
private static func paginationSemantics(forTagName tagName: String, rawTag: String) -> RDPaginationSemantics? {
|
||||
let loweredTag = rawTag.lowercased()
|
||||
let blockKind = inferredBlockKind(forTagName: tagName, rawTag: loweredTag)
|
||||
let hints = inferredHints(forTagName: tagName, rawTag: loweredTag)
|
||||
let placement = inferredAttachmentPlacement(forTagName: tagName, rawTag: loweredTag)
|
||||
|
||||
guard blockKind != nil || !hints.isEmpty || placement != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RDPaginationSemantics(
|
||||
id: "",
|
||||
blockKind: blockKind,
|
||||
hints: hints,
|
||||
attachmentPlacement: placement
|
||||
)
|
||||
}
|
||||
|
||||
private static func inferredBlockKind(forTagName tagName: String, rawTag: String) -> RDEPUBTextBlockKind? {
|
||||
if rawTag.contains("bodypic") || tagName == "img" || tagName == "figure" {
|
||||
return .attachment
|
||||
}
|
||||
switch tagName {
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
return .generic
|
||||
case "blockquote":
|
||||
return .blockquote
|
||||
case "ul", "ol", "li":
|
||||
return .list
|
||||
case "table", "thead", "tbody", "tfoot", "tr", "td", "th":
|
||||
return .table
|
||||
case "pre", "code":
|
||||
return .code
|
||||
case "p":
|
||||
return .paragraph
|
||||
case "div":
|
||||
if rawTag.contains("code") || rawTag.contains("highlight") {
|
||||
return .code
|
||||
}
|
||||
if rawTag.contains("quote") || rawTag.contains("blockquote") {
|
||||
return .blockquote
|
||||
}
|
||||
if rawTag.contains("table") {
|
||||
return .table
|
||||
}
|
||||
if rawTag.contains("list") {
|
||||
return .list
|
||||
}
|
||||
return .generic
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func inferredHints(forTagName tagName: String, rawTag: String) -> [RDEPUBTextSemanticHint] {
|
||||
var hints: [RDEPUBTextSemanticHint] = []
|
||||
if rawTag.contains("avoidpagebreakinside") ||
|
||||
rawTag.contains("break-inside: avoid") ||
|
||||
rawTag.contains("page-break-inside: avoid") ||
|
||||
["blockquote", "pre", "code", "table", "ul", "ol", "figure", "img"].contains(tagName) {
|
||||
hints.append(.avoidPageBreakInside)
|
||||
}
|
||||
if ["h1", "h2", "h3", "h4", "h5", "h6"].contains(tagName) ||
|
||||
rawTag.contains("subhead") ||
|
||||
rawTag.contains("firsttitle") ||
|
||||
rawTag.contains("secondtitle") ||
|
||||
rawTag.contains("thirdtitle") ||
|
||||
rawTag.contains("fourthtitle") ||
|
||||
rawTag.contains("fifthtitle") ||
|
||||
rawTag.contains("sixthtitle") {
|
||||
hints.append(.keepWithNext)
|
||||
}
|
||||
if rawTag.contains("pagebreakbefore") ||
|
||||
rawTag.contains("page-break-before: always") ||
|
||||
rawTag.contains("break-before: page") {
|
||||
hints.append(.pageBreakBefore)
|
||||
}
|
||||
if rawTag.contains("pagebreakafter") ||
|
||||
rawTag.contains("page-break-after: always") ||
|
||||
rawTag.contains("break-after: page") {
|
||||
hints.append(.pageBreakAfter)
|
||||
}
|
||||
if rawTag.contains("pageRelate".lowercased()) || rawTag.contains("weread-page-relate") {
|
||||
hints.append(.pageRelate)
|
||||
}
|
||||
return hints
|
||||
.reduce(into: [RDEPUBTextSemanticHint]()) { result, hint in
|
||||
if !result.contains(hint) {
|
||||
result.append(hint)
|
||||
}
|
||||
}
|
||||
.sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
|
||||
private static func inferredAttachmentPlacement(forTagName tagName: String, rawTag: String) -> RDEPUBTextAttachmentPlacement? {
|
||||
guard tagName == "img" || rawTag.contains("bodypic") || rawTag.contains("wr-vertical-center") else {
|
||||
return nil
|
||||
}
|
||||
if rawTag.contains("wr-vertical-center-style: 2") || rawTag.contains("bodypic") {
|
||||
return .centered
|
||||
}
|
||||
if rawTag.contains("wr-vertical-center-style: 1") || rawTag.contains("wr-vertical-center") {
|
||||
return .baseline
|
||||
}
|
||||
return .inline
|
||||
}
|
||||
|
||||
private static func isVoidHTMLTag(_ tagName: String) -> Bool {
|
||||
["img", "br", "hr", "input", "meta", "link"].contains(tagName)
|
||||
}
|
||||
|
||||
private static func semanticStartMarker(id: String, semantics: RDPaginationSemantics) -> String {
|
||||
var segments = ["id=\(id)"]
|
||||
if let blockKind = semantics.blockKind {
|
||||
segments.append("block=\(blockKind.rawValue)")
|
||||
}
|
||||
if !semantics.hints.isEmpty {
|
||||
segments.append("hints=\(semantics.hints.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if let placement = semantics.attachmentPlacement {
|
||||
segments.append("placement=\(placement.rawValue)")
|
||||
}
|
||||
return "${rd-sem-start:\(segments.joined(separator: ";"))}"
|
||||
}
|
||||
|
||||
private static func semanticEndMarker(id: String) -> String {
|
||||
"${rd-sem-end:id=\(id)}"
|
||||
}
|
||||
|
||||
private static func parseSemanticMarkerPayload(_ payload: String) -> RDPaginationSemantics {
|
||||
var values: [String: String] = [:]
|
||||
payload.split(separator: ";").forEach { entry in
|
||||
let parts = entry.split(separator: "=", maxSplits: 1)
|
||||
guard parts.count == 2 else { return }
|
||||
values[String(parts[0])] = String(parts[1])
|
||||
}
|
||||
let blockKind = values["block"].flatMap(RDEPUBTextBlockKind.init(rawValue:))
|
||||
let hints = values["hints"]?
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } ?? []
|
||||
let placement = values["placement"].flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
|
||||
return RDPaginationSemantics(
|
||||
id: values["id"] ?? UUID().uuidString,
|
||||
blockKind: blockKind,
|
||||
hints: hints,
|
||||
attachmentPlacement: placement
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseSemanticEndID(_ payload: String) -> String? {
|
||||
payload.split(separator: ";").first { $0.hasPrefix("id=") }.map { String($0.dropFirst(3)) }
|
||||
}
|
||||
|
||||
private static func apply(
|
||||
semantics: RDPaginationSemantics,
|
||||
to range: NSRange,
|
||||
in attributedString: NSMutableAttributedString
|
||||
) {
|
||||
var attributes: [NSAttributedString.Key: Any] = [:]
|
||||
attributes[.rdPageBlockRange] = NSStringFromRange(range)
|
||||
if let blockKind = semantics.blockKind {
|
||||
attributes[.rdPageBlockKind] = blockKind.rawValue
|
||||
}
|
||||
if !semantics.hints.isEmpty {
|
||||
attributes[.rdPageSemanticHints] = semantics.hints.map(\.rawValue).joined(separator: ",")
|
||||
}
|
||||
if let placement = semantics.attachmentPlacement {
|
||||
attributes[.rdPageAttachmentPlacement] = placement.rawValue
|
||||
}
|
||||
guard !attributes.isEmpty else { return }
|
||||
attributedString.addAttributes(attributes, range: range)
|
||||
}
|
||||
|
||||
static func normalizeBlockKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextBlockKind? {
|
||||
if let rawValue = attributes[.rdPageBlockKind] as? String,
|
||||
let blockKind = RDEPUBTextBlockKind(rawValue: rawValue) {
|
||||
return blockKind
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? {
|
||||
if let rawValue = attributes[.rdPageSemanticHints] as? String {
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
return hints.isEmpty ? nil : hints
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? {
|
||||
if let rawValue = attributes[.rdPageAttachmentPlacement] as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) {
|
||||
return placement
|
||||
}
|
||||
if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes), attachmentKind == .image {
|
||||
return .inline
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
struct RDPaginationSemantics {
|
||||
var id: String
|
||||
var blockKind: RDEPUBTextBlockKind?
|
||||
var hints: [RDEPUBTextSemanticHint]
|
||||
var attachmentPlacement: RDEPUBTextAttachmentPlacement?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBStyleSheetComposition {
|
||||
|
||||
var html: String
|
||||
|
||||
var layers: [RDEPUBTextStyleSheetLayer]
|
||||
|
||||
var inlinedCSS: String
|
||||
|
||||
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
var compatibilityReport: RDEPUBCSSCompatibilityReport
|
||||
}
|
||||
|
||||
struct RDEPUBStyleSheetComposer {
|
||||
|
||||
func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition {
|
||||
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
|
||||
in: html,
|
||||
chapterHref: input.href,
|
||||
baseURL: input.baseURL,
|
||||
resourceResolver: input.resourceResolver
|
||||
)
|
||||
let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS)
|
||||
let layers = Self.makeStyleSheetLayers(
|
||||
style: input.style,
|
||||
epubCSS: compatibility.css,
|
||||
contentLanguageCode: input.contentLanguageCode,
|
||||
sourceHTML: input.rawHTML
|
||||
)
|
||||
let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(
|
||||
into: stylesheetHrefReplacements.html,
|
||||
baseURL: input.baseURL
|
||||
)
|
||||
let htmlWithDefaultLayers = Self.injectStyleTag(
|
||||
into: htmlWithBase,
|
||||
styleID: "rd-native-default-replace-dark",
|
||||
css: layers
|
||||
.filter { $0.kind != .user && $0.kind != .epub }
|
||||
.map(\.css)
|
||||
.joined(separator: "\n\n"),
|
||||
position: .headStart
|
||||
)
|
||||
let htmlWithEPUBLayer = Self.injectStyleTag(
|
||||
into: htmlWithDefaultLayers,
|
||||
styleID: "rd-native-epub",
|
||||
css: layers.first(where: { $0.kind == .epub })?.css ?? "",
|
||||
position: .headEnd
|
||||
)
|
||||
let composedHTML = Self.injectStyleTag(
|
||||
into: htmlWithEPUBLayer,
|
||||
styleID: "rd-native-user",
|
||||
css: layers.first(where: { $0.kind == .user })?.css ?? "",
|
||||
position: .headEnd
|
||||
)
|
||||
|
||||
return RDEPUBStyleSheetComposition(
|
||||
html: composedHTML,
|
||||
layers: layers,
|
||||
inlinedCSS: compatibility.css,
|
||||
diagnostics: stylesheetHrefReplacements.diagnostics,
|
||||
compatibilityReport: compatibility.report
|
||||
)
|
||||
}
|
||||
|
||||
static func makeStyleSheetLayers(
|
||||
style: RDEPUBTextRenderStyle,
|
||||
epubCSS: String,
|
||||
contentLanguageCode: String?,
|
||||
sourceHTML: String
|
||||
) -> [RDEPUBTextStyleSheetLayer] {
|
||||
let useLatinReplace = prefersLatinLanguageCSS(
|
||||
languageCode: contentLanguageCode,
|
||||
sourceHTML: sourceHTML
|
||||
)
|
||||
var layers: [RDEPUBTextStyleSheetLayer] = [
|
||||
.init(kind: .default, css: defaultCSS()),
|
||||
.init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace))
|
||||
]
|
||||
if isDarkTheme(style: style) {
|
||||
layers.append(.init(kind: .dark, css: darkCSS(style: style)))
|
||||
}
|
||||
if !epubCSS.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
layers.append(.init(kind: .epub, css: epubCSS))
|
||||
}
|
||||
layers.append(.init(kind: .user, css: userCSS(style: style)))
|
||||
return layers
|
||||
}
|
||||
|
||||
enum StyleInjectionPosition {
|
||||
|
||||
case headStart
|
||||
|
||||
case headEnd
|
||||
}
|
||||
|
||||
static func injectStyleTag(
|
||||
into html: String,
|
||||
styleID: String,
|
||||
css: String,
|
||||
position: StyleInjectionPosition
|
||||
) -> String {
|
||||
let trimmedCSS = css.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedCSS.isEmpty else {
|
||||
return html
|
||||
}
|
||||
|
||||
let styleTag = "<style id=\"\(styleID)\">\n\(trimmedCSS)\n</style>"
|
||||
switch position {
|
||||
case .headStart:
|
||||
if html.range(of: "<head>", options: [.caseInsensitive]) != nil {
|
||||
return html.replacingOccurrences(of: "<head>", with: "<head>\n\(styleTag)", options: [.caseInsensitive])
|
||||
}
|
||||
case .headEnd:
|
||||
if html.range(of: "</head>", options: [.caseInsensitive]) != nil {
|
||||
return html.replacingOccurrences(of: "</head>", with: "\(styleTag)\n</head>", options: [.caseInsensitive])
|
||||
}
|
||||
}
|
||||
|
||||
if html.range(of: "<body", options: [.caseInsensitive]) != nil {
|
||||
return html.replacingOccurrences(of: "<body", with: "\(styleTag)\n<body", options: [.caseInsensitive])
|
||||
}
|
||||
return styleTag + "\n" + html
|
||||
}
|
||||
|
||||
private static func defaultCSS() -> String {
|
||||
RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
|
||||
}
|
||||
|
||||
private static func replaceCSS(useLatinVariant: Bool) -> String {
|
||||
let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS
|
||||
return RDEPUBAssetRepository.string(for: asset)
|
||||
}
|
||||
|
||||
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
|
||||
let background = style.backgroundColor?.rd_cssString ?? "rgba(0, 0, 0, 1.000)"
|
||||
let text = style.textColor?.rd_cssString ?? "rgba(255, 255, 255, 1.000)"
|
||||
return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
|
||||
html, body {
|
||||
background: \(background) !important;
|
||||
color: \(text) !important;
|
||||
}
|
||||
a {
|
||||
color: \(text) !important;
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private static func userCSS(style: RDEPUBTextRenderStyle) -> String {
|
||||
let lineHeight = max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1)
|
||||
let text = style.textColor.map { "color: \($0.rd_cssString) !important;" } ?? ""
|
||||
let background = style.backgroundColor.map { "background: \($0.rd_cssString) !important;" } ?? ""
|
||||
return """
|
||||
html, body {
|
||||
font-family: "\(style.font.familyName)" !important;
|
||||
font-size: \(String(format: "%.3f", style.font.pointSize))px !important;
|
||||
line-height: \(String(format: "%.3f", lineHeight)) !important;
|
||||
\(text)
|
||||
\(background)
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private static func isDarkTheme(style: RDEPUBTextRenderStyle) -> Bool {
|
||||
guard let backgroundColor = style.backgroundColor else {
|
||||
return false
|
||||
}
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
backgroundColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
|
||||
return luminance < 0.5
|
||||
}
|
||||
|
||||
private static func prefersLatinLanguageCSS(
|
||||
languageCode: String?,
|
||||
sourceHTML: String
|
||||
) -> Bool {
|
||||
let candidateCodes = inferredLanguageCodes(
|
||||
explicitLanguageCode: languageCode,
|
||||
sourceHTML: sourceHTML
|
||||
)
|
||||
|
||||
if candidateCodes.contains(where: isExplicitLatinLanguageCode) {
|
||||
return true
|
||||
}
|
||||
if candidateCodes.contains(where: isExplicitCJKLanguageCode) {
|
||||
return false
|
||||
}
|
||||
|
||||
let textSample = plainTextSample(from: sourceHTML)
|
||||
guard !textSample.isEmpty else { return false }
|
||||
|
||||
var alphabeticCount = 0
|
||||
var latinCount = 0
|
||||
for scalar in textSample.unicodeScalars {
|
||||
guard CharacterSet.letters.contains(scalar) else { continue }
|
||||
alphabeticCount += 1
|
||||
if isLatinScalar(scalar) {
|
||||
latinCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard alphabeticCount >= 80 else { return false }
|
||||
return (Double(latinCount) / Double(alphabeticCount)) >= 0.6
|
||||
}
|
||||
|
||||
private static func inferredLanguageCodes(
|
||||
explicitLanguageCode: String?,
|
||||
sourceHTML: String
|
||||
) -> [String] {
|
||||
var codes: [String] = []
|
||||
if let explicitLanguageCode {
|
||||
let normalized = explicitLanguageCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if !normalized.isEmpty {
|
||||
codes.append(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
if let regex = try? NSRegularExpression(
|
||||
pattern: #"\b(?:xml:lang|lang)\s*=\s*["']([^"']+)["']"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
let nsHTML = sourceHTML as NSString
|
||||
let range = NSRange(location: 0, length: min(nsHTML.length, 8_000))
|
||||
for match in regex.matches(in: sourceHTML, options: [], range: range) {
|
||||
guard match.numberOfRanges > 1 else { continue }
|
||||
let code = nsHTML.substring(with: match.range(at: 1))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
if !code.isEmpty {
|
||||
codes.append(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array(NSOrderedSet(array: codes)) as? [String] ?? codes
|
||||
}
|
||||
|
||||
private static func isExplicitLatinLanguageCode(_ code: String) -> Bool {
|
||||
let normalized = code.lowercased()
|
||||
if normalized.contains("latn") {
|
||||
return true
|
||||
}
|
||||
|
||||
let prefix = normalized.split(separator: "-").first.map(String.init) ?? normalized
|
||||
let latinPrefixes: Set<String> = [
|
||||
"en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi",
|
||||
"is", "ga", "cy", "pl", "cs", "sk", "sl", "hr", "hu", "ro", "tr",
|
||||
"vi", "id", "ms", "tl", "sw", "af", "sq", "et", "lv", "lt"
|
||||
]
|
||||
return latinPrefixes.contains(prefix)
|
||||
}
|
||||
|
||||
private static func isExplicitCJKLanguageCode(_ code: String) -> Bool {
|
||||
let prefix = code.lowercased().split(separator: "-").first.map(String.init) ?? code.lowercased()
|
||||
return ["zh", "ja", "ko"].contains(prefix)
|
||||
}
|
||||
|
||||
private static func plainTextSample(from html: String) -> String {
|
||||
let maxLength = min(html.count, 20_000)
|
||||
let sample = String(html.prefix(maxLength))
|
||||
let withoutTags = sample.replacingOccurrences(
|
||||
of: #"<[^>]+>"#,
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
return withoutTags.replacingOccurrences(
|
||||
of: #"&[A-Za-z0-9#]+;"#,
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
}
|
||||
|
||||
private static func isLatinScalar(_ scalar: UnicodeScalar) -> Bool {
|
||||
switch scalar.value {
|
||||
case 0x0041...0x007A,
|
||||
0x00C0...0x00FF,
|
||||
0x0100...0x024F,
|
||||
0x1E00...0x1EFF:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
enum RDEPUBTextRendererSupport {
|
||||
|
||||
static func makeChapterRenderRequest(
|
||||
href: String,
|
||||
title: String,
|
||||
rawHTML: String,
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
resourceResolver: RDEPUBResourceResolver?,
|
||||
contentLanguageCode: String? = nil,
|
||||
pageSize: CGSize? = nil,
|
||||
layoutConfig: RDEPUBTextLayoutConfig? = nil
|
||||
) -> RDEPUBTextChapterRenderRequest {
|
||||
|
||||
let normalizedHTML = RDEPUBSemanticMarkerInjector.injectPaginationSemanticMarkers(
|
||||
into: RDEPUBHTMLNormalizer.normalizeHTML(rawHTML)
|
||||
)
|
||||
|
||||
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
|
||||
in: normalizedHTML,
|
||||
chapterHref: href,
|
||||
baseURL: baseURL,
|
||||
resourceResolver: resourceResolver
|
||||
)
|
||||
let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS)
|
||||
|
||||
let layers = RDEPUBStyleSheetComposer.makeStyleSheetLayers(
|
||||
style: style,
|
||||
epubCSS: compatibility.css,
|
||||
contentLanguageCode: contentLanguageCode,
|
||||
sourceHTML: rawHTML
|
||||
)
|
||||
|
||||
RDEPUBFontNormalizer.registerEmbeddedFonts(
|
||||
in: compatibility.css + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML),
|
||||
chapterHref: href,
|
||||
resourceResolver: resourceResolver
|
||||
)
|
||||
|
||||
let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
|
||||
|
||||
let htmlWithDefaultLayers = RDEPUBStyleSheetComposer.injectStyleTag(
|
||||
into: htmlWithBase,
|
||||
styleID: "rd-native-default-replace-dark",
|
||||
css: layers
|
||||
.filter { $0.kind != .user && $0.kind != .epub }
|
||||
.map(\.css)
|
||||
.joined(separator: "\n\n"),
|
||||
position: .headStart
|
||||
)
|
||||
|
||||
let htmlWithEPUBLayer = RDEPUBStyleSheetComposer.injectStyleTag(
|
||||
into: htmlWithDefaultLayers,
|
||||
styleID: "rd-native-epub",
|
||||
css: layers.first(where: { $0.kind == .epub })?.css ?? "",
|
||||
position: .headEnd
|
||||
)
|
||||
|
||||
let composedHTML = RDEPUBStyleSheetComposer.injectStyleTag(
|
||||
into: htmlWithEPUBLayer,
|
||||
styleID: "rd-native-user",
|
||||
css: layers.first(where: { $0.kind == .user })?.css ?? "",
|
||||
position: .headEnd
|
||||
)
|
||||
|
||||
let markedHTML = RDEPUBFragmentMarkerInjector.injectFragmentMarkers(into: composedHTML)
|
||||
|
||||
let resourceDiagnostics = stylesheetHrefReplacements.diagnostics + RDEPUBRenderDiagnosticsCollector.collectImageDiagnostics(
|
||||
in: markedHTML,
|
||||
chapterHref: href,
|
||||
baseURL: baseURL,
|
||||
resourceResolver: resourceResolver
|
||||
)
|
||||
|
||||
let context = RDEPUBTextChapterContext(
|
||||
href: href,
|
||||
title: title,
|
||||
html: markedHTML,
|
||||
baseURL: baseURL,
|
||||
stylesheet: RDEPUBTextStyleSheetPackage(layers: layers),
|
||||
resourceDiagnostics: resourceDiagnostics,
|
||||
styleCompatibilityReport: compatibility.report
|
||||
)
|
||||
return RDEPUBTextChapterRenderRequest(
|
||||
context: context,
|
||||
style: style,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
}
|
||||
|
||||
static func normalizeReadingAttributes(
|
||||
in attributedString: NSMutableAttributedString,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig = .default
|
||||
) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
|
||||
var blockIndex = 0
|
||||
let sourceText = attributedString.string as NSString
|
||||
|
||||
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
|
||||
|
||||
let sourceFont = attributes[.font] as? UIFont
|
||||
let normalizedFont = RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: style.font)
|
||||
|
||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
|
||||
paragraph.lineSpacing = style.lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
||||
normalizeAlignedParagraphIndent(paragraph)
|
||||
|
||||
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont
|
||||
updatedAttributes[.paragraphStyle] = paragraph
|
||||
|
||||
if let textColor = style.textColor {
|
||||
updatedAttributes[.foregroundColor] = textColor
|
||||
}
|
||||
|
||||
RDEPUBAttachmentNormalizer.normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont)
|
||||
|
||||
let semanticBlockRange = (attributes[.rdPageBlockRange] as? String)
|
||||
.flatMap(NSRangeFromString)
|
||||
.flatMap { $0.length > 0 ? $0 : nil }
|
||||
let paragraphRange = sourceText.length > 0
|
||||
? sourceText.paragraphRange(for: NSRange(location: min(range.location, max(sourceText.length - 1, 0)), length: 0))
|
||||
: range
|
||||
|
||||
updatedAttributes[.rdPageBlockRange] = NSStringFromRange(semanticBlockRange ?? paragraphRange)
|
||||
updatedAttributes[.rdPageBlockIndex] = blockIndex
|
||||
|
||||
if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes) {
|
||||
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
|
||||
}
|
||||
|
||||
if let placement = RDEPUBSemanticMarkerInjector.normalizeAttachmentPlacement(for: attributes) {
|
||||
updatedAttributes[.rdPageAttachmentPlacement] = placement.rawValue
|
||||
}
|
||||
|
||||
if let blockKind = RDEPUBSemanticMarkerInjector.normalizeBlockKind(for: attributes) {
|
||||
updatedAttributes[.rdPageBlockKind] = blockKind.rawValue
|
||||
}
|
||||
|
||||
if let hints = RDEPUBSemanticMarkerInjector.normalizeSemanticHints(for: attributes), !hints.isEmpty {
|
||||
updatedAttributes[.rdPageSemanticHints] = hints.map(\.rawValue).joined(separator: ",")
|
||||
}
|
||||
|
||||
attributedString.setAttributes(updatedAttributes, range: range)
|
||||
blockIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
|
||||
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: style.font,
|
||||
.paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing),
|
||||
.foregroundColor: style.textColor ?? UIColor.black
|
||||
]
|
||||
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSpacing
|
||||
style.paragraphSpacing = max(6, lineSpacing / 2)
|
||||
return style
|
||||
}
|
||||
|
||||
private static func normalizeAlignedParagraphIndent(_ paragraph: NSMutableParagraphStyle) {
|
||||
guard paragraph.alignment == .right || paragraph.alignment == .center else {
|
||||
return
|
||||
}
|
||||
paragraph.firstLineHeadIndent = 0
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBTypesettingInput {
|
||||
|
||||
var href: String
|
||||
|
||||
var spineIndex: Int?
|
||||
|
||||
var title: String
|
||||
|
||||
var rawHTML: String
|
||||
|
||||
var baseURL: URL?
|
||||
|
||||
var style: RDEPUBTextRenderStyle
|
||||
|
||||
var resourceResolver: RDEPUBResourceResolver?
|
||||
|
||||
var contentLanguageCode: String?
|
||||
|
||||
var pageSize: CGSize?
|
||||
|
||||
var layoutConfig: RDEPUBTextLayoutConfig?
|
||||
}
|
||||
|
||||
struct RDEPUBTypesettingOutput {
|
||||
|
||||
var request: RDEPUBTextChapterRenderRequest
|
||||
|
||||
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
var styleCompatibilityReport: RDEPUBCSSCompatibilityReport
|
||||
}
|
||||
|
||||
protocol RDEPUBTypesettingStage {
|
||||
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String
|
||||
}
|
||||
|
||||
struct RDEPUBTextTypesetterPipeline {
|
||||
|
||||
func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput {
|
||||
|
||||
let htmlNormalizer = RDEPUBHTMLNormalizer()
|
||||
let semanticMarkerInjector = RDEPUBSemanticMarkerInjector()
|
||||
let cfiMarkerInjector = RDEPUBCFIMarkerInjector()
|
||||
let styleSheetComposer = RDEPUBStyleSheetComposer()
|
||||
let fontNormalizer = RDEPUBFontNormalizer()
|
||||
let fragmentMarkerInjector = RDEPUBFragmentMarkerInjector()
|
||||
let diagnosticsCollector = RDEPUBRenderDiagnosticsCollector()
|
||||
|
||||
let normalizedHTML = cfiMarkerInjector.process(
|
||||
semanticMarkerInjector.process(
|
||||
htmlNormalizer.process(input.rawHTML, context: input),
|
||||
context: input
|
||||
),
|
||||
context: input
|
||||
)
|
||||
|
||||
let styleSheetComposition = styleSheetComposer.compose(html: normalizedHTML, input: input)
|
||||
|
||||
let fontResults = fontNormalizer.registerEmbeddedFonts(
|
||||
html: normalizedHTML,
|
||||
inlinedCSS: styleSheetComposition.inlinedCSS,
|
||||
input: input
|
||||
)
|
||||
|
||||
let compatibilityReport = Self.mergedCompatibilityReport(
|
||||
styleSheetComposition.compatibilityReport,
|
||||
fontResults: fontResults
|
||||
)
|
||||
|
||||
let markedHTML = fragmentMarkerInjector.process(styleSheetComposition.html, context: input)
|
||||
|
||||
let diagnostics = styleSheetComposition.diagnostics + diagnosticsCollector.collect(in: markedHTML, input: input)
|
||||
|
||||
let context = RDEPUBTextChapterContext(
|
||||
href: input.href,
|
||||
title: input.title,
|
||||
html: markedHTML,
|
||||
baseURL: input.baseURL,
|
||||
stylesheet: RDEPUBTextStyleSheetPackage(layers: styleSheetComposition.layers),
|
||||
resourceDiagnostics: diagnostics,
|
||||
styleCompatibilityReport: compatibilityReport
|
||||
)
|
||||
let request = RDEPUBTextChapterRenderRequest(
|
||||
context: context,
|
||||
style: input.style,
|
||||
pageSize: input.pageSize,
|
||||
layoutConfig: input.layoutConfig
|
||||
)
|
||||
return RDEPUBTypesettingOutput(
|
||||
request: request,
|
||||
diagnostics: diagnostics,
|
||||
styleCompatibilityReport: compatibilityReport
|
||||
)
|
||||
}
|
||||
|
||||
private static func mergedCompatibilityReport(
|
||||
_ report: RDEPUBCSSCompatibilityReport,
|
||||
fontResults: [RDEPUBFontRegistrationResult]
|
||||
) -> RDEPUBCSSCompatibilityReport {
|
||||
|
||||
let fontFailures = fontResults.compactMap { result -> String? in
|
||||
guard result.didRegister == false else { return nil }
|
||||
let family = result.descriptor.family ?? "unknown-family"
|
||||
let href = result.descriptor.href
|
||||
let reason = result.errorDescription ?? "Font registration failed"
|
||||
return "\(family) <\(href)>: \(reason)"
|
||||
}
|
||||
return RDEPUBCSSCompatibilityReport(
|
||||
unsupportedRules: report.unsupportedRules,
|
||||
normalizedRules: report.normalizedRules,
|
||||
fontFailures: report.fontFailures + fontFailures
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user