refactor: 添加中文注释 + 优化模块结构

- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
This commit is contained in:
shen
2026-05-25 10:19:14 +08:00
parent 5af90c1148
commit 54798ba578
88 changed files with 2492 additions and 4469 deletions
@@ -1,7 +1,14 @@
import UIKit
/// 访便
///
/// `RDEPUBTextChapter` `indexTable`
/// fragment API
/// `RDEPUBTextBook.chapterData(for:)` `chapterData(atChapterIndex:)`
public final class RDEPUBChapterData {
///
public let chapter: RDEPUBTextChapter
/// fileIndex/row/column
public let indexTable: RDEPUBTextIndexTable
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
@@ -9,36 +16,61 @@ public final class RDEPUBChapterData {
self.indexTable = indexTable
}
// MARK: - 便 chapter
///
public var chapterIndex: Int { chapter.chapterIndex }
/// spine
public var spineIndex: Int { chapter.spineIndex }
/// "OEBPS/chapter1.xhtml"
public var href: String { chapter.href }
///
public var title: String { chapter.title }
///
public var attributedContent: NSAttributedString { chapter.attributedContent }
///
public var pages: [RDEPUBTextPage] { chapter.pages }
/// fragment ID
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
// MARK: -
///
/// - Parameter absoluteOffset:
/// - Returns: nil
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
}
/// 0
public func pageNumber(containing absoluteOffset: Int) -> Int? {
page(containing: absoluteOffset)?.absolutePageIndex
}
///
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
}
// MARK: -
/// fileIndex/row/column
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
}
///
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)
}
/// /
/// - Parameters:
/// - absoluteRange:
/// - bookIdentifier:
/// - Returns: nil
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)
@@ -56,6 +88,7 @@ public final class RDEPUBChapterData {
)
}
/// RDEPUBLocation
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
indexTable.location(
for: rangeAnchor(for: absoluteRange),
@@ -64,10 +97,19 @@ public final class RDEPUBChapterData {
)
}
/// RDEPUBLocation
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
location(for: page.contentRange, bookIdentifier: bookIdentifier)
}
// MARK: - Location
///
///
///
/// 1. rangeAnchor
/// 2. fragment ID
/// 3. navigationProgression退
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
if let rangeAnchor = location.rangeAnchor {
return indexTable.absoluteRange(for: rangeAnchor)
@@ -83,6 +125,7 @@ public final class RDEPUBChapterData {
return NSRange(location: offset, length: 1)
}
///
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
if let rangeAnchor = highlight.location.rangeAnchor {
return indexTable.absoluteRange(for: rangeAnchor)
@@ -90,6 +133,7 @@ public final class RDEPUBChapterData {
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
}
///
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
if let rangeAnchor = searchMatch.rangeAnchor {
return indexTable.absoluteRange(for: rangeAnchor)
@@ -100,6 +144,9 @@ public final class RDEPUBChapterData {
return nil
}
// MARK: - /
///
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
let pageRange = absoluteOffsetRange(for: page)
return allHighlights.filter { highlight in
@@ -114,6 +161,7 @@ public final class RDEPUBChapterData {
}
}
///
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
let pageRange = absoluteOffsetRange(for: page)
return matches.filter { match in
@@ -125,6 +173,7 @@ public final class RDEPUBChapterData {
}
}
/// 1
public func pageNumber(for location: RDEPUBLocation) -> Int? {
if let range = absoluteRange(for: location) {
return pageNumber(containing: range.location).map { $0 + 1 }
@@ -132,12 +181,16 @@ public final class RDEPUBChapterData {
return nil
}
// MARK: -
/// [start, end+1)
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound)
}
/// 退 chapterOffset
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
indexTable.absoluteIndex(
fileIndex: anchor.fileIndex,
@@ -4,9 +4,18 @@ import UIKit
import DTCoreText
#endif
/// DTCoreText EPUB HTML NSAttributedString
///
/// `RDEPUBTextRenderer`
/// HTML DTCoreText NSAttributedString fragment /
///
/// DTCoreText HTML
/// `willFlushCallback` DOM
/// DTCoreText 退
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
public init() {}
/// DTCoreText
public static var isAvailable: Bool {
#if canImport(DTCoreText)
return true
@@ -15,6 +24,14 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#endif
}
/// HTML NSAttributedString fragment
///
///
/// 1. HTML Data
/// 2. DTCoreText 退
/// 3. ${rd-sem-start/end}
/// 4. fragment
/// 5.
public func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent {
@@ -42,6 +59,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#endif
}
/// 便 HTML
public func renderChapter(
html: String,
baseURL: URL?,
@@ -58,6 +76,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
return try renderChapter(request: request)
}
/// 退 DTCoreText HTML
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
@@ -71,6 +90,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
}
#if canImport(DTCoreText)
/// 使 DTCoreText HTML Data
///
/// `willFlushCallback` DOM
/// /
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
let builder = DTHTMLAttributedStringBuilder(
html: data,
@@ -87,6 +110,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
return builder?.generatedAttributedString()
}
/// DTCoreText
///
/// - `( + ) / `
/// style.lineSpacing
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
let style = request.style
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)
@@ -1,48 +1,80 @@
import UIKit
// MARK: -
///
/// /
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]
/// 4
public var sampleNotes: [String]
}
// MARK: -
///
///
/// `RDEPUBTextPage` `RDEPUBTextLayoutFrame`
/// `contentRange`
public struct RDEPUBTextPage: Equatable {
/// 0
public var absolutePageIndex: Int
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var chapterTitle: String
/// 0
public var pageIndexInChapter: Int
public var totalPagesInChapter: Int
///
public var chapterContent: NSAttributedString
///
public var content: NSAttributedString
///
public var contentRange: NSRange
public var pageStartOffset: Int
public var pageEndOffset: Int
///
public var metadata: RDEPUBTextPageMetadata
}
// MARK: -
///
public struct RDEPUBTextChapter: Equatable {
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var title: String
///
public var attributedContent: NSAttributedString
/// fragment ID
public var fragmentOffsets: [String: Int]
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
public var pages: [RDEPUBTextPage]
}
// MARK: -
/// EPUB
///
/// EPUBTextRendering `RDEPUBTextBookBuilder.build()`
/// `chapterData(for:)` `chapterData(atChapterIndex:)` `RDEPUBChapterData`
public struct RDEPUBTextBook {
public var chapters: [RDEPUBTextChapter]
public var pages: [RDEPUBTextPage]
/// fileIndex/row/column
public let indexTable: RDEPUBTextIndexTable
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
@@ -55,16 +87,19 @@ public struct RDEPUBTextBook {
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
}
/// href 访
public func chapterData(for href: String) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
}
/// 访
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
guard chapters.indices.contains(index) else { return nil }
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
}
/// 1
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
return nil
@@ -72,6 +107,12 @@ public struct RDEPUBTextBook {
return pages[pageNumber - 1]
}
/// 1
///
///
/// 1. rangeAnchor
/// 2. fragment ID
/// 3. navigationProgression 退
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
@@ -98,6 +139,7 @@ public struct RDEPUBTextBook {
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
}
/// RDEPUBLocation
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard let page = page(at: pageNumber),
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
@@ -121,14 +163,29 @@ public struct RDEPUBTextBook {
}
}
// MARK: -
/// EPUB publication
///
/// spine HTML / RDEPUBTextBook
///
///
/// - WXRead
/// - /
/// -
/// -
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let cache: RDEPUBTextBookCache?
private let sampler: RDEPUBTextPerformanceSampler
///
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
///
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
///
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
/// /
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) {
@@ -137,10 +194,12 @@ public final class RDEPUBTextBookBuilder {
self.sampler = RDEPUBTextPerformanceSampler()
}
/// 使 DTCoreText
public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer())
}
/// Phase 7
public func phase7SemanticSummary(title: String? = nil) -> String? {
guard !lastBuildPaginationDiagnostics.isEmpty else { return nil }
@@ -164,6 +223,16 @@ public final class RDEPUBTextBookBuilder {
return parts.joined(separator: " · ")
}
/// EPUB publication
///
///
/// 1.
/// 2. spine 线 HTML
/// 3. HTML NSAttributedString
/// 4. /
/// 5. 使 CoreText
/// 6.
/// 7. RDEPUBTextBook
public func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -180,7 +249,7 @@ public final class RDEPUBTextBookBuilder {
let buildStart = CFAbsoluteTimeGetCurrent()
// Cache lookup WXRead style: load per-chapter page ranges
// WXRead
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) }
@@ -191,6 +260,7 @@ public final class RDEPUBTextBookBuilder {
continue
}
//
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: item.href,
@@ -201,6 +271,7 @@ public final class RDEPUBTextBookBuilder {
resourceResolver: publication.resourceResolver
)
// HTML NSAttributedString
let renderStart = CFAbsoluteTimeGetCurrent()
let rendered = try renderer.renderChapter(request: request)
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
@@ -211,6 +282,7 @@ public final class RDEPUBTextBookBuilder {
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
}
// /
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] skipped href=\(item.href)")
@@ -221,10 +293,12 @@ 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(
contentRange: NSRange(location: 0, length: content.length),
@@ -245,7 +319,7 @@ public final class RDEPUBTextBookBuilder {
]
isCacheHit = false
} else if let cached = cachedPagination?[item.href] {
// Cache hit: use cached page ranges, skip rd_paginatedFrames
// 使 CoreText
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
return RDEPUBTextLayoutFrame(
@@ -263,6 +337,7 @@ public final class RDEPUBTextBookBuilder {
}
isCacheHit = true
} else {
// CoreText
layoutFrames = content.length > 0
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
: []
@@ -270,6 +345,7 @@ public final class RDEPUBTextBookBuilder {
}
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
//
let normalizedFrames = normalizeTrailingFrames(
layoutFrames,
content: content,
@@ -298,6 +374,7 @@ 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,
@@ -312,6 +389,7 @@ public final class RDEPUBTextBookBuilder {
lastBuildCacheStats.misses += 1
}
//
let chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
@@ -369,6 +447,7 @@ public final class RDEPUBTextBookBuilder {
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
//
if let cacheKey {
let paginationCache = chapters.map { chapter in
let pageRanges = chapter.pages.map(\.contentRange)
@@ -390,6 +469,9 @@ public final class RDEPUBTextBookBuilder {
return book
}
// MARK: -
/// 退 spine item title href
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
@@ -400,12 +482,16 @@ public final class RDEPUBTextBookBuilder {
return trimmedTitle.isEmpty ? item.href : trimmedTitle
}
///
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
items.flatMap { item in
[item] + flattenedTOCItems(from: item.children)
}
}
// MARK: -
/// /
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
let lowercasedHref = item.href.lowercased()
var hasAttachment = false
@@ -422,6 +508,9 @@ public final class RDEPUBTextBookBuilder {
return false
}
// MARK: -
///
private func attachmentCount(in content: NSAttributedString) -> Int {
guard content.length > 0 else { return 0 }
var count = 0
@@ -433,6 +522,7 @@ public final class RDEPUBTextBookBuilder {
return count
}
/// NSRange
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
guard content.length > 0 else { return [] }
var ranges: [NSRange] = []
@@ -444,6 +534,9 @@ public final class RDEPUBTextBookBuilder {
return ranges
}
// MARK: -
/// href cover
private func isAttachmentOnlyCoverChapter(
item: RDEPUBSpineItem,
content: NSAttributedString,
@@ -455,6 +548,11 @@ public final class RDEPUBTextBookBuilder {
return attachmentCount(in: content) > 0 && trimmed.count <= 1
}
// MARK: -
///
/// 1.
/// 2. 2
private func normalizeTrailingFrames(
_ frames: [RDEPUBTextLayoutFrame],
content: NSAttributedString,
@@ -464,6 +562,7 @@ public final class RDEPUBTextBookBuilder {
var normalized = frames
//
while let lastFrame = normalized.last,
shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) {
normalized.removeLast()
@@ -476,6 +575,7 @@ public final class RDEPUBTextBookBuilder {
}
}
//
guard normalized.count > 1,
let lastFrame = normalized.last,
let previousFrame = normalized.dropLast().last,
@@ -489,6 +589,7 @@ public final class RDEPUBTextBookBuilder {
return normalized
}
///
private func shouldDropWhitespaceOnlyTrailingFrame(
_ frame: RDEPUBTextLayoutFrame,
in content: NSAttributedString
@@ -500,6 +601,8 @@ public final class RDEPUBTextBookBuilder {
return visibleCharacterCount(in: content, range: frame.contentRange) == 0
}
///
/// 2 8 12
private func shouldMergeShortTrailingFrame(
_ trailingFrame: RDEPUBTextLayoutFrame,
previousFrame: RDEPUBTextLayoutFrame,
@@ -522,6 +625,7 @@ public final class RDEPUBTextBookBuilder {
return previousVisibleCount >= max(visibleCount * 8, 12)
}
///
private func mergeTrailingFrame(
_ previousFrame: RDEPUBTextLayoutFrame,
with trailingFrame: RDEPUBTextLayoutFrame
@@ -547,6 +651,7 @@ public final class RDEPUBTextBookBuilder {
)
}
///
private func visibleCharacterCount(
in content: NSAttributedString,
range: NSRange
@@ -560,6 +665,9 @@ public final class RDEPUBTextBookBuilder {
return filteredScalars.count
}
// MARK: -
///
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
values.reduce(into: [T]()) { result, value in
if !result.contains(value) {
@@ -568,6 +676,7 @@ public final class RDEPUBTextBookBuilder {
}
}
/// NSRange
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
ranges.reduce(into: [NSRange]()) { result, value in
if !result.contains(value) {
@@ -576,6 +685,7 @@ public final class RDEPUBTextBookBuilder {
}
}
///
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
guard content.length > 0, range.length > 0 else { return 0 }
var count = 0
@@ -587,6 +697,9 @@ public final class RDEPUBTextBookBuilder {
return count
}
// MARK: -
/// ID SHA256
private func makeCacheKey(
bookID: String,
pageSize: CGSize,
@@ -1,15 +1,18 @@
import Foundation
import CryptoKit
// MARK: - Pagination Cache (WXRead-style: page ranges only, no attributedString)
// MARK: - WXRead
/// 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.
///
///
/// WXRead WRChapterPageCount NSRange +
/// NSAttributedString HTML CoreText
public struct RDEPUBTextChapterPaginationCache: Equatable {
///
public var href: String
///
public var pageRanges: [NSRange]
///
public var breakReasons: [RDEPUBTextPageBreakReason]
public var semanticHints: [RDEPUBTextSemanticHint]
@@ -26,8 +29,10 @@ public struct RDEPUBTextChapterPaginationCache: Equatable {
}
}
// MARK: - NSCoding Archive (simple: strings + ints only)
// MARK: - NSCoding 使
/// NSKeyedArchiver
///
final class PaginationCacheArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true }
@@ -47,14 +52,20 @@ final class PaginationCacheArchive: NSObject, NSSecureCoding {
}
}
/// NSRange location/length 便 NSSecureCoding
final class ChapterPaginationArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true }
let href: String
/// rangeLengths
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) }
@@ -86,6 +97,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
self.semanticHints = semanticHints
}
///
func toCache() -> RDEPUBTextChapterPaginationCache {
let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
NSRange(location: loc.intValue, length: len.intValue)
@@ -99,19 +111,27 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
}
}
// MARK: - RDEPUBTextBookCache
// MARK: -
/// 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.
/// WXRead WRChapterPageCount
///
///
/// - = SHA256(ID + + + + + schema)
/// - = NSRange + NSKeyedArchiver
/// - HTML CoreText
/// - 线 serial DispatchQueue
public final class RDEPUBTextBookCache {
///
public var schemaVersion: Int = 1
/// 线
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
///
private let cacheDirectory: URL
///
/// - Parameter subdirectory: Caches
public init(subdirectory: String = "RDEPUBTextBookCache") {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent(subdirectory, isDirectory: true)
@@ -120,10 +140,13 @@ public final class RDEPUBTextBookCache {
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
// MARK: - Cache Key
// Mirrors WRChapterPageCount.currentCacheKeyWithBookId:
// Encodes bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
// MARK: -
// WRChapterPageCount.currentCacheKeyWithBookId
// bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
/// SHA256 + ".cache"
///
///
public func cacheKey(
bookID: String,
fontSize: CGFloat,
@@ -137,10 +160,10 @@ public final class RDEPUBTextBookCache {
return hex + ".cache"
}
// MARK: - Load / Save (pagination metadata only)
// MARK: - /
/// Load cached pagination metadata for all chapters.
/// Returns dictionary keyed by chapter href.
/// href
/// nil
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key)
@@ -170,7 +193,7 @@ public final class RDEPUBTextBookCache {
}
}
/// Save pagination metadata for all chapters.
///
public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key)
@@ -186,8 +209,9 @@ public final class RDEPUBTextBookCache {
}
}
// MARK: - Invalidate
// MARK: -
///
public func invalidateAll() {
queue.sync {
let fileManager = FileManager.default
@@ -0,0 +1,284 @@
// RDEPUBTextIndexTable.swift
// EPUB
// href /spine
// fragment
//
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]
/// href
public let hrefToChapterIndex: [String: Int]
/// href spine
public let hrefToFileIndex: [String: Int]
/// href (fragment ID )
public let fragmentOffsetsByHref: [String: [String: Int]]
/// spine href
public let fileIndexToHref: [Int: String]
/// spine
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
///
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 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
rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapter.attributedContent.string)
}
self.chapterStartOffsets = offsets
self.chapterLengths = lengths
self.hrefToChapterIndex = hrefMap
self.hrefToFileIndex = hrefToFileMap
self.fragmentOffsetsByHref = fragmentMap
self.fileIndexToHref = fileIndexMap
self.fileRowColumnMap = rowColumnMap
}
///
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
)
}
/// 使 rangeAnchor fragment progression
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
if let anchor = location.rangeAnchor?.start {
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 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 = absoluteIndex(for: anchor)
return chapter.pages.firstIndex { page in
NSLocationInRange(resolvedOffset, page.contentRange)
}.map { $0 + basePageIndex }
}
/// href
public func href(for fileIndex: Int) -> String? {
fileIndexToHref[fileIndex]
}
/// href
public func chapterIndex(for href: String) -> Int? {
hrefToChapterIndex[href]
}
///
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
absoluteIndex(
fileIndex: anchor.fileIndex,
row: anchor.row,
column: anchor.column
) ?? anchor.chapterOffset
}
/// NSRange
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
let start = absoluteIndex(for: rangeAnchor.start)
let end = max(start, absoluteIndex(for: rangeAnchor.end))
return NSRange(location: start, length: max(end - start, 0))
}
/// progression
public func location(
for anchor: RDEPUBTextAnchor,
in chapter: RDEPUBTextChapter,
bookIdentifier: String?
) -> RDEPUBLocation {
let absoluteOffset = absoluteIndex(for: anchor)
let totalLength = max(chapter.attributedContent.length - 1, 1)
let progression = Double(min(max(absoluteOffset, 0), totalLength)) / Double(totalLength)
return RDEPUBLocation(
bookIdentifier: bookIdentifier,
href: chapter.href,
progression: progression,
lastProgression: progression,
fragment: anchor.fragmentID,
rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor)
)
}
/// progression
public func location(
for rangeAnchor: RDEPUBTextRangeAnchor,
in chapter: RDEPUBTextChapter,
bookIdentifier: String?
) -> RDEPUBLocation {
let start = absoluteIndex(for: rangeAnchor.start)
let end = max(start, absoluteIndex(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
)
}
///
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 absoluteIndex(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)
}
///
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
let lastOffset = max(chapter.attributedContent.length - 1, 0)
return min(max(index, 0), lastOffset)
}
/// fragment ID
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 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
}
}
@@ -1,17 +1,33 @@
import Foundation
/// CoreText
///
/// `RDEPUBTextLayouter`
/// `RDEPUBTextBookBuilder` `RDEPUBTextLayoutFrame`
/// `RDEPUBTextPage` `RDEPUBTextBook`
struct RDEPUBTextLayoutFrame: Equatable {
///
var contentRange: NSRange
///
var breakReason: RDEPUBTextPageBreakReason
/// HTML
var blockRange: NSRange?
///
var attachmentRanges: [NSRange]
///
var attachmentKinds: [RDEPUBTextAttachmentKind]
/// HTML
var blockKinds: [RDEPUBTextBlockKind]
///
var semanticHints: [RDEPUBTextSemanticHint]
/// 线
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// fragment ID
var trailingFragmentID: String?
///
var diagnostics: [String]
///
var metadata: RDEPUBTextPageMetadata {
RDEPUBTextPageMetadata(
breakReason: breakReason,
@@ -5,11 +5,29 @@ import UIKit
import DTCoreText
#endif
/// CoreText
///
///
/// 1. avoidPageBreakInside WXRead 退
/// 2. keepWithNext
/// 3. pageBreakBefore/After
/// 4. pageRelate
/// 5.
/// 6. CoreText
///
///
/// - DTCoreText DTCoreTextLayouter DTCoreTextLayoutFrame
/// - CoreText 退CTFramesetterCreateFrame
struct RDEPUBTextLayouter {
///
private let attributedString: NSAttributedString
///
private let pageSize: CGSize
/// CoreText
private let framesetter: CTFramesetter
/// CTFrame
private let path: CGPath
/// avoidPageBreakInside
private let config: RDEPUBTextLayoutConfig
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
@@ -20,6 +38,8 @@ struct RDEPUBTextLayouter {
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
}
///
/// DTCoreText CoreText
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
@@ -32,6 +52,11 @@ struct RDEPUBTextLayouter {
#endif
}
// MARK: - CoreText 退
/// 使 CoreText API
///
/// CTFramesetterCreateFrame
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
@@ -49,7 +74,7 @@ struct RDEPUBTextLayouter {
let proposedRange = NSRange(location: location, length: visibleRange.length)
// Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward)
// avoidPageBreakInside WXRead
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
let lineRanges = lineRanges(from: frame)
@@ -89,7 +114,10 @@ struct RDEPUBTextLayouter {
return frames
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
/// 使 DTCoreTextLayouter
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
@@ -152,6 +180,18 @@ struct RDEPUBTextLayouter {
}
#endif
// MARK: -
/// CoreText
///
///
/// 1. chapterEnd
/// 2. pageBreakBefore/After
/// 3. pageRelate
/// 4.
/// 5. 使
///
/// 55%
private func adjustedRange(
from proposedRange: NSRange,
totalLength: Int,
@@ -193,6 +233,7 @@ struct RDEPUBTextLayouter {
)
}
// 55%
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
let minimumEnd = proposedRange.location + minLength
@@ -203,6 +244,7 @@ struct RDEPUBTextLayouter {
let currentSemanticHints = proposedSemanticHints
let currentAttachmentPlacements = proposedAttachmentPlacements
// 1. pageBreakBefore/After
if let semanticBoundary = preferredSemanticBoundary(
in: proposedRange,
minimumEnd: minimumEnd
@@ -230,6 +272,7 @@ struct RDEPUBTextLayouter {
)
}
// 2. pageRelate
if let pageRelateBoundary = preferredPageRelateBoundary(
after: proposedRange,
minimumEnd: minimumEnd,
@@ -258,6 +301,7 @@ struct RDEPUBTextLayouter {
)
}
// 3.
if let attachmentBoundary = preferredAttachmentBoundary(
in: proposedRange,
minimumEnd: minimumEnd
@@ -284,6 +328,7 @@ struct RDEPUBTextLayouter {
)
}
// 4.
return (
range: proposedRange,
breakReason: .frameLimit,
@@ -305,6 +350,11 @@ struct RDEPUBTextLayouter {
)
}
// MARK: -
/// pageBreakBefore / pageBreakAfter
///
/// minimumEnd
private func preferredSemanticBoundary(
in range: NSRange,
minimumEnd: Int
@@ -337,6 +387,8 @@ struct RDEPUBTextLayouter {
return boundary
}
/// .attachment .centered
///
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
var boundary: Int?
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
@@ -346,9 +398,8 @@ struct RDEPUBTextLayouter {
let placement = attachmentPlacement(at: location)
let blockKind = blockKind(at: location)
// Mirror WXRead more closely: only block-level attachments should
// push the entire block to the next page. Inline footnote icons and
// other inline attachments must not cause a whole paragraph to move.
// WXRead
//
let isBlockLevelAttachment = blockKind == .attachment || placement == .centered
guard isBlockLevelAttachment else { return }
@@ -361,6 +412,8 @@ struct RDEPUBTextLayouter {
return boundary
}
/// pageRelate pageRelate
///
private func preferredPageRelateBoundary(
after range: NSRange,
minimumEnd: Int,
@@ -384,6 +437,9 @@ struct RDEPUBTextLayouter {
return lastLineStart
}
// MARK: -
///
private func blockRange(at location: Int) -> NSRange? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -393,6 +449,7 @@ struct RDEPUBTextLayouter {
return nil
}
///
private func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -400,6 +457,7 @@ struct RDEPUBTextLayouter {
return RDEPUBTextBlockKind(rawValue: rawValue)
}
///
private func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -407,6 +465,7 @@ struct RDEPUBTextLayouter {
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
}
///
private func paragraphRange(containing location: Int) -> NSRange {
let source = attributedString.string as NSString
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
@@ -414,6 +473,7 @@ struct RDEPUBTextLayouter {
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
}
///
private func attachmentRanges(in range: NSRange) -> [NSRange] {
var results: [NSRange] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
@@ -423,6 +483,7 @@ struct RDEPUBTextLayouter {
return results
}
///
private func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
guard location >= 0, location < attributedString.length else { return [] }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -432,6 +493,7 @@ struct RDEPUBTextLayouter {
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
}
///
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
var kinds: [RDEPUBTextAttachmentKind] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
@@ -445,6 +507,7 @@ struct RDEPUBTextLayouter {
return kinds
}
///
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
var kinds: [RDEPUBTextBlockKind] = []
attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in
@@ -458,6 +521,7 @@ struct RDEPUBTextLayouter {
return kinds
}
///
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
var hints: [RDEPUBTextSemanticHint] = []
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in
@@ -469,6 +533,7 @@ struct RDEPUBTextLayouter {
return hints
}
///
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
var placements: [RDEPUBTextAttachmentPlacement] = []
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in
@@ -482,6 +547,7 @@ struct RDEPUBTextLayouter {
return placements
}
/// fragment ID
private func nearestTrailingFragmentID(
endingAt location: Int,
fragmentOffsets: [String: Int]
@@ -492,11 +558,11 @@ struct RDEPUBTextLayouter {
.key
}
// MARK: - Line-level avoidPageBreakInside (WXRead approach)
// MARK: - avoidPageBreakInsideWXRead
/// Scans CTFrame lines backward from the last line, removing trailing lines
/// that fall inside an avoidPageBreakInside block. Mirrors WXRead's
/// WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded.
/// CTFrame avoidPageBreakInside
/// WXRead WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded
/// 3 kMaxLinesToRemove
private func trimmedRangeForAvoidPageBreakInside(
from frame: CTFrame,
proposed: NSRange
@@ -509,8 +575,8 @@ struct RDEPUBTextLayouter {
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)
//
// kMaxLinesToRemove = 3 WXRead
let kMaxLinesToRemove = 3
var linesToRemove = 0
@@ -521,8 +587,7 @@ struct RDEPUBTextLayouter {
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)
// kMaxLinesToRemove
linesToRemove = kMaxLinesToRemove
break
}
@@ -535,7 +600,7 @@ struct RDEPUBTextLayouter {
let validLineCount = lines.count - linesToRemove
guard validLineCount > 0 else {
// All lines are in protected blocks; fall back to proposed
// 退
return proposed
}
@@ -549,6 +614,7 @@ struct RDEPUBTextLayouter {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// CoreText keepWithNext
private func trimmedRangeForKeepWithNext(
from frame: CTFrame,
proposed: NSRange
@@ -561,7 +627,10 @@ struct RDEPUBTextLayouter {
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
/// DTCoreText avoidPageBreakInside
private func trimmedRangeForAvoidPageBreakInside(
from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange
@@ -603,6 +672,7 @@ struct RDEPUBTextLayouter {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// DTCoreText keepWithNext
private func trimmedRangeForKeepWithNext(
from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange
@@ -615,6 +685,8 @@ struct RDEPUBTextLayouter {
}
#endif
/// keepWithNext
/// 3
private func trimmedRangeForKeepWithNext(
proposed: NSRange,
lineRanges: [NSRange]
@@ -648,7 +720,7 @@ struct RDEPUBTextLayouter {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// Checks if a line's string range intersects with an avoidPageBreakInside block.
/// avoidPageBreakInside
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
var found = false
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
@@ -665,6 +737,7 @@ struct RDEPUBTextLayouter {
return found
}
/// keepWithNext
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
var found = false
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
@@ -681,6 +754,7 @@ struct RDEPUBTextLayouter {
return found
}
/// CTFrame
private func lineRanges(from frame: CTFrame) -> [NSRange] {
let lines = CTFrameGetLines(frame) as! [CTLine]
return lines.map {
@@ -690,6 +764,7 @@ struct RDEPUBTextLayouter {
}
#if canImport(DTCoreText)
/// DTCoreTextLayoutFrame
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
return []
@@ -698,6 +773,9 @@ struct RDEPUBTextLayouter {
}
#endif
// MARK: -
///
private func diagnostics(
reason: RDEPUBTextPageBreakReason,
range: NSRange,
@@ -1,7 +1,18 @@
import CoreText
import UIKit
// MARK: - NSAttributedString
/// NSAttributedString 便 CoreText
extension NSAttributedString {
///
///
/// `RDEPUBTextBookBuilder` `RDPlainTextBookBuilder`
/// - Parameters:
/// - size:
/// - fragmentOffsets: fragment ID
/// - config: avoidPageBreakInside
/// - Returns:
func rd_paginatedFrames(
size: CGSize,
fragmentOffsets: [String: Int] = [:],
@@ -11,12 +22,17 @@ extension NSAttributedString {
.layoutFrames(fragmentOffsets: fragmentOffsets)
}
/// NSRange
func ss_pageRanges(size: CGSize) -> [NSRange] {
rd_paginatedFrames(size: size).map(\.contentRange)
}
}
// MARK: - UIColor CSS
/// UIColor CSS CSS
extension UIColor {
/// UIColor CSS rgba()
var ss_cssString: String {
var red: CGFloat = 0
var green: CGFloat = 0
@@ -1,13 +1,20 @@
import Foundation
// MARK: - Performance Sample
// MARK: -
///
public struct RDEPUBTextPerformanceSample: Equatable {
///
public var chapterHref: String
/// HTML
public var renderDuration: TimeInterval
/// CoreText
public var paginateDuration: TimeInterval
///
public var pageCount: Int
///
public var attributedStringLength: Int
///
public var cacheHit: Bool
public init(
@@ -27,19 +34,27 @@ public struct RDEPUBTextPerformanceSample: Equatable {
}
}
// MARK: - Performance Sampler
// MARK: -
///
///
/// `RDEPUBTextBookBuilder` 使
///
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 }
@@ -47,11 +62,13 @@ public final class RDEPUBTextPerformanceSampler {
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)
}
@@ -1,43 +1,71 @@
import UIKit
// MARK: -
/// EPUBTextRendering 使 NSAttributedString
/// `RDEPUBTextRendererSupport`
/// `RDEPUBTextLayouter`
public extension NSAttributedString.Key {
/// NSString NSRange
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
///
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
/// fragment ID
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
/// /
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
/// ///
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
///
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
/// /线/
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
}
// MARK: -
/// HTML
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
case paragraph
case list
case table
case code
case blockquote
case attachment
case generic
case paragraph // <p>
case list // <ul>, <ol>, <li>
case table // <table>
case code // <pre>, <code>
case blockquote // <blockquote>
case attachment // figure
case generic // <div>, <h1>-<h6>
}
// MARK: -
/// /
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
case avoidPageBreakInside
case keepWithNext
case pageBreakBefore
case pageBreakAfter
case pageRelate
case avoidPageBreakInside //
case keepWithNext //
case pageBreakBefore //
case pageBreakAfter //
case pageRelate //
}
// MARK: -
///
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
case inline
case baseline
case centered
case inline //
case baseline // 线
case centered //
}
// MARK: -
///
public struct RDEPUBTextRenderStyle {
/// EPUB
public var font: UIFont
/// pt
public var lineSpacing: CGFloat
/// nil 使 EPUB
public var textColor: UIColor?
/// nil
public var backgroundColor: UIColor?
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
@@ -48,10 +76,17 @@ public struct RDEPUBTextRenderStyle {
}
}
// MARK: -
///
public struct RDEPUBTextLayoutConfig: Equatable {
///
public var avoidOrphans: Bool
///
public var avoidWidows: Bool
/// avoidPageBreakInside WXRead 退
public var avoidPageBreakInsideEnabled: Bool
///
public var imageMaxHeightRatio: CGFloat
public init(
@@ -66,17 +101,22 @@ public struct RDEPUBTextLayoutConfig: Equatable {
self.imageMaxHeightRatio = imageMaxHeightRatio
}
///
public static let `default` = RDEPUBTextLayoutConfig()
}
// MARK: - CSS
/// CSS
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
case `default`
case replace
case dark
case epub
case user
case `default` // marginpadding
case replace //
case dark //
case epub // EPUB
case user //
}
/// CSS CSS
public struct RDEPUBTextStyleSheetLayer: Equatable {
public var kind: RDEPUBTextStyleSheetLayerKind
public var css: String
@@ -87,6 +127,9 @@ public struct RDEPUBTextStyleSheetLayer: Equatable {
}
}
/// CSS CSS
///
/// CSS default replace dark epub user
public struct RDEPUBTextStyleSheetPackage: Equatable {
public var layers: [RDEPUBTextStyleSheetLayer]
@@ -94,6 +137,7 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
self.layers = layers
}
/// CSS
public var combinedCSS: String {
layers
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
@@ -104,11 +148,15 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
}
}
// MARK: -
///
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
case stylesheet
case image
}
/// EPUB
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
public var kind: RDEPUBTextResourceReferenceKind
public var chapterHref: String
@@ -134,6 +182,9 @@ public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
}
}
// MARK: -
/// HTML
public struct RDEPUBTextChapterContext: Equatable {
public var href: String
public var title: String
@@ -159,6 +210,7 @@ public struct RDEPUBTextChapterContext: Equatable {
}
}
/// `RDEPUBTextRenderer`
public struct RDEPUBTextChapterRenderRequest {
public var context: RDEPUBTextChapterContext
public var style: RDEPUBTextRenderStyle
@@ -169,9 +221,15 @@ public struct RDEPUBTextChapterRenderRequest {
}
}
// MARK: -
/// fragment
public struct RDEPUBRenderedChapterContent {
///
public var attributedString: NSAttributedString
/// fragment ID
public var fragmentOffsets: [String: Int]
///
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public init(
@@ -185,11 +243,18 @@ public struct RDEPUBRenderedChapterContent {
}
}
// MARK: -
/// EPUB HTML NSAttributedString
///
/// `RDEPUBDTCoreTextRenderer` DTCoreText
public protocol RDEPUBTextRenderer {
///
func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent
/// 便 HTML
func renderChapter(
html: String,
baseURL: URL?,
@@ -197,6 +262,7 @@ public protocol RDEPUBTextRenderer {
) throws -> RDEPUBRenderedChapterContent
}
/// 便
public extension RDEPUBTextRenderer {
func renderChapter(
html: String,
@@ -215,9 +281,12 @@ public extension RDEPUBTextRenderer {
}
}
// MARK: -
/// EPUB
public enum RDEPUBTextRenderingError: LocalizedError {
case htmlEncodingFailed
case htmlImportFailed
case htmlEncodingFailed // HTML Data
case htmlImportFailed // HTML
public var errorDescription: String? {
switch self {
@@ -4,13 +4,36 @@ import UIKit
import DTCoreText
#endif
/// HTML fragment /CSS
///
/// `RDEPUBTextBookBuilder`
/// `makeChapterRenderRequest()` HTML
///
///
/// - HTML
/// - Fragment /id ${id=xxx}
/// - HTML ${rd-sem-start/end} NSAttributedString
/// - CSS default/replace/dark/epub/user
/// - EPUB
/// -
enum RDEPUBTextRendererSupport {
///
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
/// 图片源地址正则
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
/// ${rd-sem-start:...} / ${rd-sem-end:...}
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
///
private static var didLogFootnoteAttachment = false
///
private static var didLogCoverAttachment = false
// MARK: - Fragment /
/// HTML id fragment
///
/// `<tag id="xxx" ...>` `${id=xxx}<tag id="xxx" ...>`
/// 使 DTCoreText fragment
static func injectFragmentMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
return html
@@ -23,6 +46,10 @@ enum RDEPUBTextRendererSupport {
)
}
/// 从渲染后的富文本中提取 fragment 偏移量映射。
///
/// 扫描 `${id=xxx}` 标记,记录每个 fragment ID 的字符偏移量,
/// 然后删除标记文本,返回 [fragmentID: offset] 字典。
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
let markerPattern = #"\$\{id=([^}]+)\}"#
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
@@ -51,6 +78,10 @@ enum RDEPUBTextRendererSupport {
return fragmentOffsets
}
/// 将 HTML 中注入的语义标记(${rd-sem-start/end})应用到富文本属性中。
///
/// 标记包含 blockKind、semanticHints、attachmentPlacement 等信息,
/// 解析后写入对应的 .rdPage* 属性键,供分页引擎读取。
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
return
@@ -85,6 +116,14 @@ enum RDEPUBTextRendererSupport {
}
}
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
///
/// 遍历富文本的所有属性区间:
/// 1. 将 EPUB 原始字体映射到用户设置字体(保留粗体/斜体特征)
/// 2. 统一行距和段间距
/// 3. 应用用户设置的文本颜色
/// 4. 规范化附件显示尺寸
/// 5. 注入块级元素范围、序号、类型等分页语义属性
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0
@@ -128,6 +167,7 @@ enum RDEPUBTextRendererSupport {
}
}
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理。
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
let fallbackAttributes: [NSAttributedString.Key: Any] = [
.font: style.font,
@@ -137,6 +177,16 @@ enum RDEPUBTextRendererSupport {
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
}
/// 核心预处理方法:将原始 HTML 转换为完整的章节渲染请求。
///
/// 预处理流程:
/// 1. normalizeHTML:清理冗余字符、规范化附件 HTML 标记
/// 2. injectPaginationSemanticMarkers:为 HTML 标签注入语义标记
/// 3. inlineLinkedStyleSheets:将外部样式表内联到 HTML 中
/// 4. 组装五层 CSSdefault/replace/dark/epub/user)并注入 style 标签
/// 5. injectBaseHref:注入 base 标签以解析相对路径
/// 6. injectFragmentMarkers:注入 fragment 锚点标记
/// 7. collectImageDiagnostics:收集图片资源引用诊断
static func makeChapterRenderRequest(
href: String,
title: String,
@@ -197,6 +247,7 @@ enum RDEPUBTextRendererSupport {
return RDEPUBTextChapterRenderRequest(context: context, style: style)
}
/// HTML 规范化:清理冗余字符(CR、多余换行),规范化附件 HTML 标记
static func normalizeHTML(_ html: String) -> String {
var cleanedHTML = html
let replacements: [(pattern: String, template: String)] = [
@@ -1,5 +1,10 @@
import Foundation
/// EPUB
///
/// `RDEPUBSearchEngine`
/// 使
///
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
private let textBook: RDEPUBTextBook
private let publication: RDEPUBPublication
@@ -9,6 +14,14 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
self.publication = publication
}
///
///
///
/// 1.
/// 2.
/// 3. progression
/// 4. 12
/// 5. /
func search(keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
@@ -60,6 +73,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
return matches
}
/// 12
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)
@@ -1,6 +1,16 @@
import UIKit
/// RDEPUBTextBook EPUB 线
/// TXT RDEPUBTextBook EPUB 线
///
/// .txt EPUB
/// 使 EPUBReader
///
///
/// 1. UTF-8 GB18030 GBK
/// 2.
/// 3. HTML
/// 4. DTCoreText
/// 5. CoreText RDEPUBTextBook
public final class RDPlainTextBookBuilder {
private let renderer: RDEPUBTextRenderer
@@ -8,6 +18,13 @@ public final class RDPlainTextBookBuilder {
self.renderer = renderer
}
///
///
/// - Parameters:
/// - textFileURL: URL
/// - pageSize:
/// - style:
/// - Returns:
public func build(
textFileURL: URL,
pageSize: CGSize,
@@ -86,12 +103,14 @@ public final class RDPlainTextBookBuilder {
// MARK: -
/// +
private struct ChapterSpec {
let title: String?
let content: String
}
///
/// "X///"
///
private func splitChapters(from text: String) -> [ChapterSpec] {
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
@@ -126,7 +145,7 @@ public final class RDPlainTextBookBuilder {
// MARK: - HTML
/// HTML
/// HTML `<p>`
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")
@@ -135,6 +154,7 @@ public final class RDPlainTextBookBuilder {
// MARK: -
/// UTF-8 GB18030 GBK
private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content