feat(epub): align text rendering with WXRead
This commit is contained in:
@@ -21,6 +21,7 @@ public struct RDEPUBTextPage: Equatable {
|
||||
public var chapterTitle: String
|
||||
public var pageIndexInChapter: Int
|
||||
public var totalPagesInChapter: Int
|
||||
public var chapterContent: NSAttributedString
|
||||
public var content: NSAttributedString
|
||||
public var contentRange: NSRange
|
||||
public var pageStartOffset: Int
|
||||
@@ -95,11 +96,18 @@ public struct RDEPUBTextBook: Equatable {
|
||||
|
||||
public final class RDEPUBTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
private let cache: RDEPUBTextBookCache?
|
||||
private let sampler: RDEPUBTextPerformanceSampler
|
||||
|
||||
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
|
||||
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
|
||||
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
|
||||
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
|
||||
|
||||
public init(renderer: RDEPUBTextRenderer) {
|
||||
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) {
|
||||
self.renderer = renderer
|
||||
self.cache = cache
|
||||
self.sampler = RDEPUBTextPerformanceSampler()
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
@@ -139,6 +147,16 @@ public final class RDEPUBTextBookBuilder {
|
||||
var flatPages: [RDEPUBTextPage] = []
|
||||
lastBuildResourceDiagnostics = []
|
||||
lastBuildPaginationDiagnostics = []
|
||||
lastBuildPerformanceSamples = []
|
||||
lastBuildCacheStats = (0, 0)
|
||||
sampler.reset()
|
||||
|
||||
let buildStart = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
// Cache lookup — WXRead style: load per-chapter page ranges
|
||||
let bookID = publication.metadata.identifier ?? publication.metadata.title
|
||||
let cacheKey = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style)
|
||||
let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) }
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
@@ -155,7 +173,11 @@ public final class RDEPUBTextBookBuilder {
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver
|
||||
)
|
||||
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
|
||||
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -171,7 +193,10 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
|
||||
let paginateStart = CFAbsoluteTimeGetCurrent()
|
||||
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||
let isCacheHit: Bool
|
||||
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||
layoutFrames = [
|
||||
RDEPUBTextLayoutFrame(
|
||||
@@ -191,11 +216,33 @@ public final class RDEPUBTextBookBuilder {
|
||||
]
|
||||
)
|
||||
]
|
||||
isCacheHit = false
|
||||
} else if let cached = cachedPagination?[item.href] {
|
||||
// Cache hit: use cached page ranges, skip rd_paginatedFrames
|
||||
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: range,
|
||||
breakReason: breakReason,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: cached.semanticHints,
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"]
|
||||
)
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
layoutFrames = content.length > 0
|
||||
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
|
||||
: []
|
||||
isCacheHit = false
|
||||
}
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
|
||||
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
@@ -219,6 +266,21 @@ public final class RDEPUBTextBookBuilder {
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
}
|
||||
|
||||
sampler.record(RDEPUBTextPerformanceSample(
|
||||
chapterHref: item.href,
|
||||
renderDuration: renderDuration,
|
||||
paginateDuration: paginateDuration,
|
||||
pageCount: effectiveFrames.count,
|
||||
attributedStringLength: content.length,
|
||||
cacheHit: isCacheHit
|
||||
))
|
||||
if isCacheHit {
|
||||
lastBuildCacheStats.hits += 1
|
||||
} else {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
return RDEPUBTextPage(
|
||||
@@ -229,6 +291,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectiveFrames.count,
|
||||
chapterContent: chapterAttributedContent,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
@@ -243,7 +306,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: content.copy() as! NSAttributedString,
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
||||
pages: pages
|
||||
@@ -270,7 +333,29 @@ public final class RDEPUBTextBookBuilder {
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
|
||||
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
|
||||
|
||||
if let cacheKey {
|
||||
let paginationCache = chapters.map { chapter in
|
||||
let pageRanges = chapter.pages.map(\.contentRange)
|
||||
let breakReasons = chapter.pages.map(\.metadata.breakReason)
|
||||
let semanticHints = Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
|
||||
return RDEPUBTextChapterPaginationCache(
|
||||
href: chapter.href,
|
||||
pageRanges: pageRanges,
|
||||
breakReasons: breakReasons,
|
||||
semanticHints: semanticHints
|
||||
)
|
||||
}
|
||||
cache?.save(paginationCache, key: cacheKey)
|
||||
}
|
||||
|
||||
print(sampler.summary())
|
||||
lastBuildPerformanceSamples = sampler.samples
|
||||
|
||||
return book
|
||||
}
|
||||
|
||||
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
|
||||
@@ -345,4 +430,19 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCacheKey(
|
||||
bookID: String,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> String? {
|
||||
guard let cache else { return nil }
|
||||
return cache.cacheKey(
|
||||
bookID: bookID,
|
||||
fontSize: style.font.pointSize,
|
||||
lineHeightMultiple: style.lineSpacing,
|
||||
contentInsets: .zero,
|
||||
pageSize: pageSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Pagination Cache (WXRead-style: page ranges only, no attributedString)
|
||||
|
||||
/// Per-chapter pagination metadata cached to disk.
|
||||
/// Mirrors WRChapterPageCount's caching: stores page NSRange + break reasons,
|
||||
/// NOT the full attributed string. Builder re-renders HTML on cache hit but
|
||||
/// skips the rd_paginatedFrames pagination step.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSCoding Archive (simple: strings + ints only)
|
||||
|
||||
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:))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RDEPUBTextBookCache
|
||||
|
||||
/// Disk-persistent cache for per-chapter pagination metadata.
|
||||
/// Follows WXRead's WRChapterPageCount caching pattern:
|
||||
/// cache key = bookID + layout settings, cache value = page NSRange per chapter.
|
||||
/// Attributed strings are NOT cached — builder re-renders HTML on cache hit.
|
||||
public final class RDEPUBTextBookCache {
|
||||
|
||||
public var schemaVersion: Int = 1
|
||||
|
||||
private let queue = DispatchQueue(label: "com.rdreader.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)
|
||||
}
|
||||
|
||||
// MARK: - Cache Key
|
||||
// Mirrors WRChapterPageCount.currentCacheKeyWithBookId:
|
||||
// Encodes bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
|
||||
|
||||
public func cacheKey(
|
||||
bookID: String,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
contentInsets: UIEdgeInsets,
|
||||
pageSize: CGSize
|
||||
) -> String {
|
||||
let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_v\(schemaVersion)"
|
||||
let digest = SHA256.hash(data: Data(raw.utf8))
|
||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return hex + ".cache"
|
||||
}
|
||||
|
||||
// MARK: - Load / Save (pagination metadata only)
|
||||
|
||||
/// Load cached pagination metadata for all chapters.
|
||||
/// Returns dictionary keyed by chapter href.
|
||||
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
print("[Cache] load MISS key=\(key)")
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard let archive = try NSKeyedUnarchiver.unarchivedObject(
|
||||
ofClass: PaginationCacheArchive.self,
|
||||
from: data
|
||||
) else {
|
||||
print("[Cache] load MISS key=\(key) (unarchive returned nil)")
|
||||
return nil
|
||||
}
|
||||
var result: [String: RDEPUBTextChapterPaginationCache] = [:]
|
||||
for chapter in archive.chapters {
|
||||
result[chapter.href] = chapter.toCache()
|
||||
}
|
||||
print("[Cache] load HIT key=\(key) chapters=\(result.count)")
|
||||
return result
|
||||
} catch {
|
||||
print("[Cache] load MISS key=\(key) error=\(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save pagination metadata for all chapters.
|
||||
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)
|
||||
print("[Cache] save key=\(key) chapters=\(chapters.count)")
|
||||
} catch {
|
||||
print("[Cache] save FAILED key=\(key) error=\(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Invalidate
|
||||
|
||||
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)
|
||||
}
|
||||
print("[Cache] invalidateAll")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
struct RDEPUBTextLayouter {
|
||||
private let attributedString: NSAttributedString
|
||||
private let pageSize: CGSize
|
||||
private let framesetter: CTFramesetter
|
||||
private let path: CGPath
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize) {
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||||
self.attributedString = attributedString
|
||||
self.pageSize = pageSize
|
||||
self.config = config
|
||||
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
|
||||
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
|
||||
}
|
||||
@@ -19,6 +25,18 @@ struct RDEPUBTextLayouter {
|
||||
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
|
||||
|
||||
@@ -30,7 +48,11 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
let adjusted = adjustedRange(from: proposedRange, totalLength: attributedString.length)
|
||||
|
||||
// Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward)
|
||||
let lineAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
|
||||
let adjusted = adjustedRange(from: lineAdjusted, totalLength: attributedString.length)
|
||||
let trailingFragmentID = nearestTrailingFragmentID(
|
||||
endingAt: adjusted.range.location + adjusted.range.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
@@ -61,6 +83,63 @@ struct RDEPUBTextLayouter {
|
||||
return frames
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let pageRect = CGRect(origin: .zero, size: pageSize)
|
||||
|
||||
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 lineAdjusted = trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let adjusted = adjustedRange(from: lineAdjusted, totalLength: attributedString.length)
|
||||
let trailingFragmentID = 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(visibleRange.length, 1)
|
||||
continue
|
||||
}
|
||||
location = nextLocation
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
#endif
|
||||
|
||||
private func adjustedRange(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int
|
||||
@@ -164,32 +243,6 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
if let blockBoundary = preferredBlockBoundary(
|
||||
near: pageEnd,
|
||||
lowerBound: minimumEnd
|
||||
) {
|
||||
let adjustedRange = NSRange(location: proposedRange.location, length: blockBoundary - proposedRange.location)
|
||||
return (
|
||||
range: adjustedRange,
|
||||
breakReason: .blockBoundary,
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .blockBoundary,
|
||||
range: adjustedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .frameLimit,
|
||||
@@ -239,15 +292,6 @@ struct RDEPUBTextLayouter {
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
|
||||
if hints.contains(.avoidPageBreakInside),
|
||||
attributeRange.location > range.location,
|
||||
attributeRange.location < range.location + range.length,
|
||||
attributeRange.location >= minimumEnd,
|
||||
attributeEnd > range.location + range.length {
|
||||
boundary = (attributeRange.location, RDEPUBTextSemanticHint.avoidPageBreakInside.rawValue)
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
@@ -265,23 +309,6 @@ struct RDEPUBTextLayouter {
|
||||
return boundary
|
||||
}
|
||||
|
||||
private func preferredBlockBoundary(near location: Int, lowerBound: Int) -> Int? {
|
||||
var probe = max(lowerBound, 0)
|
||||
let searchEnd = min(location, attributedString.length)
|
||||
guard probe < searchEnd else { return nil }
|
||||
|
||||
var lastBoundary: Int?
|
||||
while probe < searchEnd {
|
||||
let block = blockRange(at: probe) ?? paragraphRange(containing: probe)
|
||||
let candidate = block.location
|
||||
if candidate > lowerBound, candidate < location {
|
||||
lastBoundary = candidate
|
||||
}
|
||||
probe = max(block.location + max(block.length, 1), probe + 1)
|
||||
}
|
||||
return lastBoundary
|
||||
}
|
||||
|
||||
private func blockRange(at location: Int) -> NSRange? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@@ -367,6 +394,123 @@ struct RDEPUBTextLayouter {
|
||||
.key
|
||||
}
|
||||
|
||||
// MARK: - Line-level avoidPageBreakInside (WXRead approach)
|
||||
|
||||
/// Scans CTFrame lines backward from the last line, removing trailing lines
|
||||
/// that fall inside an avoidPageBreakInside block. Mirrors WXRead's
|
||||
/// WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded.
|
||||
private 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 }
|
||||
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
// Scan backward: count consecutive trailing lines inside protected blocks
|
||||
// kMaxLinesToRemove = 3 (same as WXRead)
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for i in stride(from: lines.count - 1, through: 0, by: -1) {
|
||||
let lineRange = CTLineGetStringRange(lines[i])
|
||||
let lineNSRange = NSRange(location: lineRange.location, length: lineRange.length)
|
||||
|
||||
if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
// Don't remove more than kMaxLinesToRemove; stop here
|
||||
// (line at index i stays, so linesToRemove stays at kMaxLinesToRemove)
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lines.count - linesToRemove
|
||||
guard validLineCount > 0 else {
|
||||
// All lines are in protected blocks; fall back to proposed
|
||||
return proposed
|
||||
}
|
||||
|
||||
let lastValidLine = lines[validLineCount - 1]
|
||||
let lastLineRange = CTLineGetStringRange(lastValidLine)
|
||||
let endLocation = lastLineRange.location + lastLineRange.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private 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 kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for line in lines.reversed() {
|
||||
let lineRange = line.stringRange()
|
||||
if lineIsInAvoidPageBreakInsideBlock(lineRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lines.count - linesToRemove
|
||||
guard validLineCount > 0 else { return proposed }
|
||||
|
||||
let lastValidLine = lines[validLineCount - 1]
|
||||
let lastLineRange = lastValidLine.stringRange()
|
||||
let endLocation = lastLineRange.location + lastLineRange.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Checks if a line's string range intersects with an avoidPageBreakInside block.
|
||||
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
||||
var found = false
|
||||
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
|
||||
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(.avoidPageBreakInside) {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
|
||||
@@ -4,9 +4,10 @@ import UIKit
|
||||
extension NSAttributedString {
|
||||
func rd_paginatedFrames(
|
||||
size: CGSize,
|
||||
fragmentOffsets: [String: Int] = [:]
|
||||
fragmentOffsets: [String: Int] = [:],
|
||||
config: RDEPUBTextLayoutConfig = .default
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
RDEPUBTextLayouter(attributedString: self, pageSize: size)
|
||||
RDEPUBTextLayouter(attributedString: self, pageSize: size, config: config)
|
||||
.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Performance Sample
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance Sampler
|
||||
|
||||
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)
|
||||
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,27 @@ public struct RDEPUBTextRenderStyle {
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
public var avoidOrphans: Bool
|
||||
public var avoidWidows: Bool
|
||||
public var avoidPageBreakInsideEnabled: Bool
|
||||
public var imageMaxHeightRatio: CGFloat
|
||||
|
||||
public init(
|
||||
avoidOrphans: Bool = true,
|
||||
avoidWidows: Bool = true,
|
||||
avoidPageBreakInsideEnabled: Bool = true,
|
||||
imageMaxHeightRatio: CGFloat = 0.85
|
||||
) {
|
||||
self.avoidOrphans = avoidOrphans
|
||||
self.avoidWidows = avoidWidows
|
||||
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
|
||||
self.imageMaxHeightRatio = imageMaxHeightRatio
|
||||
}
|
||||
|
||||
public static let `default` = RDEPUBTextLayoutConfig()
|
||||
}
|
||||
|
||||
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
|
||||
case `default`
|
||||
case replace
|
||||
|
||||
@@ -88,6 +88,7 @@ enum RDEPUBTextRendererSupport {
|
||||
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||
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 = normalizedFont(from: sourceFont, baseFont: style.font)
|
||||
@@ -102,7 +103,13 @@ enum RDEPUBTextRendererSupport {
|
||||
updatedAttributes[.foregroundColor] = textColor
|
||||
}
|
||||
normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont)
|
||||
updatedAttributes[.rdPageBlockRange] = NSStringFromRange(range)
|
||||
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 = attachmentKind(for: attributes) {
|
||||
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
|
||||
@@ -219,45 +226,16 @@ enum RDEPUBTextRendererSupport {
|
||||
style: RDEPUBTextRenderStyle
|
||||
) {
|
||||
guard let attachment = element.textAttachment else { return }
|
||||
|
||||
let lowercasedClasses = (((attachment.attributes["class"] as? String) ?? (element.attributes["class"] as? String) ?? "")).lowercased()
|
||||
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
|
||||
?? ((attachment.attributes["src"] as? String) ?? (element.attributes["src"] as? String) ?? "").lowercased()
|
||||
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
|
||||
|
||||
if lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" {
|
||||
let targetHeight = max(round(pointSize * 0.54), 1)
|
||||
let originalSize = attachment.originalSize
|
||||
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
|
||||
let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
|
||||
attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
|
||||
attachment.verticalAlignment = .center
|
||||
let maxSize = CGSize(
|
||||
width: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).width),
|
||||
height: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).height * 0.85)
|
||||
)
|
||||
normalizeAttachmentLayoutForWXRead(attachment, fontPointSize: pointSize, maxImageSize: maxSize)
|
||||
if isFootnoteAttachment(attachment) {
|
||||
element.displayStyle = .inline
|
||||
if !didLogFootnoteAttachment {
|
||||
didLogFootnoteAttachment = true
|
||||
print("[EPUB][Attachment] footnote classes=\(lowercasedClasses) path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize)) font=\(pointSize)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg" {
|
||||
let maxSize = UIScreen.main.bounds.insetBy(dx: 20, dy: 28).size
|
||||
let originalSize = attachment.originalSize
|
||||
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 = CGSize(width: round(maxSize.width), height: round(maxSize.height))
|
||||
}
|
||||
attachment.verticalAlignment = .baseline
|
||||
} else if isCoverAttachment(attachment) {
|
||||
element.displayStyle = .block
|
||||
if !didLogCoverAttachment {
|
||||
didLogCoverAttachment = true
|
||||
print("[EPUB][Attachment] cover classes=\(lowercasedClasses) path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize))")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -472,26 +450,7 @@ enum RDEPUBTextRendererSupport {
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
if let textAttachment = attachment as? DTTextAttachment {
|
||||
let lowercasedClasses = ((textAttachment.attributes["class"] as? String) ?? "").lowercased()
|
||||
let lowercasedPath = textAttachment.contentURL?.lastPathComponent.lowercased() ?? ""
|
||||
if lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" {
|
||||
let targetHeight = max(round(font.pointSize * 0.14), 1)
|
||||
let originalSize = textAttachment.originalSize
|
||||
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
|
||||
let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
|
||||
textAttachment.displaySize = CGSize(width: targetWidth, height: round(targetHeight))
|
||||
textAttachment.verticalAlignment = .center
|
||||
} else if lowercasedClasses.contains("rd-front-cover-image") {
|
||||
let originalSize = textAttachment.originalSize
|
||||
let maxWidth = max(UIScreen.main.bounds.width - 48, font.lineHeight * 8)
|
||||
if originalSize.width > 0, originalSize.height > 0 {
|
||||
let scaledHeight = round(originalSize.height * (maxWidth / originalSize.width))
|
||||
textAttachment.displaySize = CGSize(width: round(maxWidth), height: scaledHeight)
|
||||
} else {
|
||||
textAttachment.displaySize = CGSize(width: round(maxWidth), height: round(maxWidth * 1.4))
|
||||
}
|
||||
textAttachment.verticalAlignment = .baseline
|
||||
}
|
||||
normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize)
|
||||
attributes[.attachment] = textAttachment
|
||||
return
|
||||
}
|
||||
@@ -887,7 +846,7 @@ enum RDEPUBTextRendererSupport {
|
||||
if rawTag.contains("avoidpagebreakinside") ||
|
||||
rawTag.contains("break-inside: avoid") ||
|
||||
rawTag.contains("page-break-inside: avoid") ||
|
||||
["blockquote", "pre", "code", "table", "ul", "ol"].contains(tagName) {
|
||||
["blockquote", "pre", "code", "table", "ul", "ol", "figure", "img"].contains(tagName) {
|
||||
hints.append(.avoidPageBreakInside)
|
||||
}
|
||||
if rawTag.contains("pagebreakbefore") ||
|
||||
@@ -977,6 +936,7 @@ enum RDEPUBTextRendererSupport {
|
||||
in attributedString: NSMutableAttributedString
|
||||
) {
|
||||
var attributes: [NSAttributedString.Key: Any] = [:]
|
||||
attributes[.rdPageBlockRange] = NSStringFromRange(range)
|
||||
if let blockKind = semantics.blockKind {
|
||||
attributes[.rdPageBlockKind] = blockKind.rawValue
|
||||
}
|
||||
@@ -1025,4 +985,85 @@ enum RDEPUBTextRendererSupport {
|
||||
var hints: [RDEPUBTextSemanticHint]
|
||||
var attachmentPlacement: RDEPUBTextAttachmentPlacement?
|
||||
}
|
||||
|
||||
#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
|
||||
print("[EPUB][Attachment] footnote original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize)) font=\(pointSize)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isCoverAttachment(attachment) {
|
||||
let maxSize = maxImageSize ?? CGSize(width: round(UIScreen.main.bounds.width - 40), height: round(UIScreen.main.bounds.height - 56))
|
||||
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
|
||||
print("[EPUB][Attachment] cover original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize))")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
private static func isFootnoteAttachment(_ 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("qqreader-footnote") || lowercasedPath == "note.png"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public final class RDPlainTextBookBuilder {
|
||||
: 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(
|
||||
@@ -56,6 +57,7 @@ public final class RDPlainTextBookBuilder {
|
||||
chapterTitle: spec.title ?? "第 \(index + 1) 章",
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectiveFrames.count,
|
||||
chapterContent: chapterAttributedContent,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
@@ -70,7 +72,7 @@ public final class RDPlainTextBookBuilder {
|
||||
spineIndex: index,
|
||||
href: href,
|
||||
title: spec.title ?? "第 \(index + 1) 章",
|
||||
attributedContent: content.copy() as! NSAttributedString,
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: [:],
|
||||
pageBreakReasons: pages.map { $0.metadata.breakReason },
|
||||
pages: pages
|
||||
|
||||
@@ -111,6 +111,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
fileprivate lazy var topToolView = makeTopToolView()
|
||||
fileprivate lazy var bottomToolView = makeBottomToolView()
|
||||
fileprivate var currentBookIdentifier: String?
|
||||
private let textBookCache = RDEPUBTextBookCache()
|
||||
private var currentBrightness: CGFloat
|
||||
private var didStartInitialLoad = false
|
||||
private var isRepaginating = false
|
||||
@@ -575,7 +576,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
let renderer = resolvedTextRenderer()
|
||||
let builder = RDEPUBTextBookBuilder(renderer: renderer)
|
||||
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache)
|
||||
let pageSize = currentTextPageSize()
|
||||
let renderStyle = currentTextRenderStyle()
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBTextOverlayDecoration {
|
||||
enum Kind: String {
|
||||
case selection
|
||||
case highlight
|
||||
case underline
|
||||
case search
|
||||
case activeSearch
|
||||
case locate
|
||||
}
|
||||
|
||||
var kind: Kind
|
||||
var absoluteRange: NSRange
|
||||
var rects: [CGRect]
|
||||
var color: UIColor
|
||||
}
|
||||
|
||||
final class RDEPUBSelectionOverlayView: UIView {
|
||||
private var page: RDEPUBTextPage?
|
||||
private var selectionRange: NSRange?
|
||||
private var decorations: [RDEPUBTextOverlayDecoration] = []
|
||||
private let selectionVerticalAdjustment: CGFloat = -1
|
||||
|
||||
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
isUserInteractionEnabled = false
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(page: RDEPUBTextPage, selectionColor: UIColor) {
|
||||
self.page = page
|
||||
self.selectionColor = selectionColor
|
||||
selectionRange = nil
|
||||
decorations = []
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
func updateSelection(absoluteRange: NSRange?) {
|
||||
selectionRange = absoluteRange
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
|
||||
self.decorations = decorations
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
updateSelection(absoluteRange: nil)
|
||||
}
|
||||
|
||||
func absoluteRange(at point: CGPoint) -> NSRange? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func decorationSummary() -> String {
|
||||
let counts = Dictionary(grouping: resolvedDecorations, by: \.kind).mapValues(\.count)
|
||||
let selectionLabel = selectionRange.map(NSStringFromRange) ?? "none"
|
||||
let highlightCount = counts[.highlight, default: 0]
|
||||
let underlineCount = counts[.underline, default: 0]
|
||||
let searchCount = counts[.search, default: 0]
|
||||
let activeSearchCount = counts[.activeSearch, default: 0]
|
||||
let locateLabel = resolvedDecorations.first(where: { $0.kind == .locate }).map { NSStringFromRange($0.absoluteRange) } ?? "none"
|
||||
return [
|
||||
"selection \(selectionLabel)",
|
||||
"highlight \(highlightCount)",
|
||||
"underline \(underlineCount)",
|
||||
"search \(searchCount)",
|
||||
"activeSearch \(activeSearchCount)",
|
||||
"locate \(locateLabel)"
|
||||
].joined(separator: " · ")
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||||
|
||||
for decoration in resolvedDecorations {
|
||||
switch decoration.kind {
|
||||
case .underline:
|
||||
context.setStrokeColor(decoration.color.cgColor)
|
||||
context.setLineWidth(2)
|
||||
for underlineRect in decoration.rects {
|
||||
let y = underlineRect.maxY - 1
|
||||
context.move(to: CGPoint(x: underlineRect.minX, y: y))
|
||||
context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
|
||||
context.strokePath()
|
||||
}
|
||||
default:
|
||||
context.setFillColor(decoration.color.cgColor)
|
||||
for selectionRect in decoration.rects {
|
||||
let adjustedRect = selectionRect.offsetBy(dx: 0, dy: selectionVerticalAdjustment)
|
||||
let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
|
||||
context.addPath(path.cgPath)
|
||||
context.fillPath()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var resolvedDecorations: [RDEPUBTextOverlayDecoration] {
|
||||
guard let page else { return [] }
|
||||
|
||||
let nonSelection = decorations.filter { !$0.rects.isEmpty }
|
||||
guard let selectionRange else { return nonSelection }
|
||||
|
||||
let selectionRects:[CGRect] = []
|
||||
guard !selectionRects.isEmpty else { return nonSelection }
|
||||
|
||||
return [
|
||||
RDEPUBTextOverlayDecoration(
|
||||
kind: .selection,
|
||||
absoluteRange: selectionRange,
|
||||
rects: selectionRects,
|
||||
color: selectionColor
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -37,12 +37,60 @@ final class RDEPUBSelectableTextView: UITextView {
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
final class RDEPUBDirectCoreTextPageView: UIView {
|
||||
var layoutFrame: DTCoreTextLayoutFrame? {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
contentMode = .redraw
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext(),
|
||||
let layoutFrame else { return }
|
||||
|
||||
context.saveGState()
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
context.restoreGState()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
final class RDEPUBTextContentView: UIView {
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
private var highlightedRanges: [RDEPUBHighlight] = []
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private let coreTextContentView: RDEPUBDirectCoreTextPageView = {
|
||||
let view = RDEPUBDirectCoreTextPageView()
|
||||
view.backgroundColor = .clear
|
||||
view.isOpaque = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private var coreTextDisplayContent: NSAttributedString?
|
||||
private var coreTextDisplayRange: NSRange?
|
||||
#endif
|
||||
|
||||
private let textView: RDEPUBSelectableTextView = {
|
||||
let view = RDEPUBSelectableTextView()
|
||||
view.isEditable = false
|
||||
@@ -70,6 +118,9 @@ final class RDEPUBTextContentView: UIView {
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(coverImageView)
|
||||
#if canImport(DTCoreText)
|
||||
addSubview(coreTextContentView)
|
||||
#endif
|
||||
addSubview(textView)
|
||||
addSubview(pageNumberLabel)
|
||||
textView.delegate = self
|
||||
@@ -91,6 +142,10 @@ final class RDEPUBTextContentView: UIView {
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
coreTextContentView.frame = bounds.inset(by: contentInsets)
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#endif
|
||||
textView.frame = bounds.inset(by: contentInsets)
|
||||
coverImageView.frame = bounds.inset(by: contentInsets)
|
||||
|
||||
@@ -119,6 +174,12 @@ final class RDEPUBTextContentView: UIView {
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
|
||||
if configureCoverIfNeeded(for: page) {
|
||||
#if canImport(DTCoreText)
|
||||
coreTextContentView.isHidden = true
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
@@ -129,7 +190,17 @@ final class RDEPUBTextContentView: UIView {
|
||||
coverImageView.isHidden = true
|
||||
coverImageView.image = nil
|
||||
|
||||
let displayContent = NSMutableAttributedString(attributedString: page.content)
|
||||
let selectionContent = NSMutableAttributedString(attributedString: page.content)
|
||||
let selectionRange = NSRange(location: 0, length: selectionContent.length)
|
||||
selectionContent.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: selectionRange
|
||||
)
|
||||
normalizeInlineAttachments(in: selectionContent, basePointSize: configuration.fontSize)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
|
||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||
displayContent.addAttribute(
|
||||
.foregroundColor,
|
||||
@@ -137,10 +208,20 @@ final class RDEPUBTextContentView: UIView {
|
||||
range: fullRange
|
||||
)
|
||||
normalizeInlineAttachments(in: displayContent, basePointSize: configuration.fontSize)
|
||||
applyHighlights(to: displayContent, page: page)
|
||||
applySearchHighlights(to: displayContent, page: page, searchState: searchState)
|
||||
applyHighlights(to: displayContent, page: page, contentBaseOffset: 0)
|
||||
applySearchHighlights(to: displayContent, page: page, searchState: searchState, contentBaseOffset: 0)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
coreTextDisplayContent = displayContent
|
||||
coreTextDisplayRange = page.contentRange
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#else
|
||||
applyHighlights(to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
|
||||
applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
|
||||
#endif
|
||||
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = displayContent
|
||||
textView.attributedText = selectionProxyContent(from: selectionContent)
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
@@ -152,6 +233,14 @@ final class RDEPUBTextContentView: UIView {
|
||||
}
|
||||
|
||||
private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) {
|
||||
applyHighlights(to: content, page: page, contentBaseOffset: page.pageStartOffset)
|
||||
}
|
||||
|
||||
private func applyHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
contentBaseOffset: Int
|
||||
) {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
@@ -163,7 +252,7 @@ final class RDEPUBTextContentView: UIView {
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(
|
||||
location: overlapStart - pageStart,
|
||||
location: overlapStart - contentBaseOffset,
|
||||
length: overlapEnd - overlapStart
|
||||
)
|
||||
switch highlight.style {
|
||||
@@ -186,6 +275,20 @@ final class RDEPUBTextContentView: UIView {
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
searchState: RDEPUBSearchState?
|
||||
) {
|
||||
applySearchHighlights(
|
||||
to: content,
|
||||
page: page,
|
||||
searchState: searchState,
|
||||
contentBaseOffset: page.pageStartOffset
|
||||
)
|
||||
}
|
||||
|
||||
private func applySearchHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
searchState: RDEPUBSearchState?,
|
||||
contentBaseOffset: Int
|
||||
) {
|
||||
guard let searchState else { return }
|
||||
|
||||
@@ -202,7 +305,7 @@ final class RDEPUBTextContentView: UIView {
|
||||
let overlapEnd = min(matchEnd, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(location: Int(overlapStart - pageStart), length: Int(overlapEnd - overlapStart))
|
||||
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
|
||||
let color = match == searchState.currentMatch ? activeColor : normalColor
|
||||
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
|
||||
}
|
||||
@@ -223,6 +326,12 @@ final class RDEPUBTextContentView: UIView {
|
||||
|
||||
coverImageView.image = image
|
||||
coverImageView.isHidden = false
|
||||
#if canImport(DTCoreText)
|
||||
coreTextContentView.isHidden = true
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
return true
|
||||
}
|
||||
@@ -265,22 +374,61 @@ final class RDEPUBTextContentView: UIView {
|
||||
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
|
||||
#if canImport(DTCoreText)
|
||||
if let attachment = value as? DTTextAttachment {
|
||||
let classes = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||
let source = (attachment.contentURL?.lastPathComponent ?? (attachment.attributes["src"] as? String) ?? "").lowercased()
|
||||
guard classes.contains("qqreader-footnote") || source.contains("note.png") else { return }
|
||||
|
||||
let pointSize = max(basePointSize, 1)
|
||||
let targetHeight = max(round(pointSize * 0.14), 1)
|
||||
let aspectRatio = attachment.originalSize.height > 0 ? attachment.originalSize.width / attachment.originalSize.height : 1
|
||||
let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
|
||||
attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
|
||||
attachment.verticalAlignment = .center
|
||||
RDEPUBTextRendererSupport.normalizeAttachmentLayoutForWXRead(
|
||||
attachment,
|
||||
fontPointSize: basePointSize
|
||||
)
|
||||
content.addAttribute(.attachment, value: attachment, range: range)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
|
||||
let proxy = NSMutableAttributedString(attributedString: content)
|
||||
let fullRange = NSRange(location: 0, length: proxy.length)
|
||||
proxy.removeAttribute(.backgroundColor, range: fullRange)
|
||||
proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
|
||||
|
||||
var attachmentRanges: [NSRange] = []
|
||||
proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
||||
guard value != nil else { return }
|
||||
attachmentRanges.append(range)
|
||||
}
|
||||
|
||||
for range in attachmentRanges.reversed() {
|
||||
let replacement = NSAttributedString(
|
||||
string: String(repeating: " ", count: max(range.length, 1)),
|
||||
attributes: [
|
||||
.font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
|
||||
.foregroundColor: UIColor.clear
|
||||
]
|
||||
)
|
||||
proxy.replaceCharacters(in: range, with: replacement)
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func updateCoreTextLayoutFrameIfNeeded() {
|
||||
guard !coreTextContentView.isHidden,
|
||||
let displayContent = coreTextDisplayContent,
|
||||
let displayRange = coreTextDisplayRange,
|
||||
coreTextContentView.bounds.width > 0,
|
||||
coreTextContentView.bounds.height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else {
|
||||
coreTextContentView.layoutFrame = nil
|
||||
return
|
||||
}
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
coreTextContentView.layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
extension RDEPUBTextContentView: UITextViewDelegate {
|
||||
|
||||
Reference in New Issue
Block a user