chore: checkpoint current milestone work

This commit is contained in:
shen
2026-05-22 13:28:53 +08:00
parent 6d196d64e5
commit 5698aeaead
68 changed files with 5694 additions and 3291 deletions
@@ -167,6 +167,43 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
}
}
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
case chapterEnd
case frameLimit
case blockBoundary
case attachmentBoundary
}
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
case image
case generic
}
public struct RDEPUBTextPageMetadata: Codable, Equatable {
public var breakReason: RDEPUBTextPageBreakReason
public var blockRange: NSRange?
public var attachmentRanges: [NSRange]
public var attachmentKinds: [RDEPUBTextAttachmentKind]
public var trailingFragmentID: String?
public var diagnostics: [String]
public init(
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange? = nil,
attachmentRanges: [NSRange] = [],
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
trailingFragmentID: String? = nil,
diagnostics: [String] = []
) {
self.breakReason = breakReason
self.blockRange = blockRange
self.attachmentRanges = attachmentRanges
self.attachmentKinds = attachmentKinds
self.trailingFragmentID = trailingFragmentID
self.diagnostics = diagnostics
}
}
public struct RDEPUBHighlight: Codable, Equatable {
public var id: String
public var bookIdentifier: String?
@@ -57,6 +57,35 @@ public final class RDEPUBResourceResolver {
return pathPart
}
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? {
guard let opfDirectoryURL else {
return href.components(separatedBy: "#").first
}
let pathPart = href.components(separatedBy: "#").first ?? href
let basePath = baseHref.components(separatedBy: "#").first ?? baseHref
let baseURL = opfDirectoryURL
.appendingPathComponent(basePath)
.deletingLastPathComponent()
guard let resolvedURL = URL(string: pathPart, relativeTo: baseURL)?.standardizedFileURL else {
return pathPart
}
let opfPath = opfDirectoryURL.standardizedFileURL.path + "/"
if resolvedURL.path.hasPrefix(opfPath) {
return String(resolvedURL.path.dropFirst(opfPath.count))
}
return pathPart
}
public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? {
guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else {
return nil
}
return fileURL(forRelativePath: normalizedHref)
}
public func normalizedLocation(
_ location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil,
@@ -122,4 +151,4 @@ public final class RDEPUBResourceResolver {
normalizedHref($0.href) == targetHref
}
}
}
}
@@ -16,48 +16,70 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
}
public func renderChapter(
html: String,
baseURL: URL?,
style: RDEPUBTextRenderStyle
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent {
#if canImport(DTCoreText)
let markedHTML = RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)
guard let data = markedHTML.data(using: .utf8) else {
let chapterContext = request.context
guard let data = chapterContext.html.data(using: .utf8) else {
throw RDEPUBTextRenderingError.htmlEncodingFailed
}
guard let rendered = makeAttributedString(from: data, baseURL: baseURL, style: style) else {
return fallbackRenderedContent(for: markedHTML, style: style)
guard let rendered = makeAttributedString(from: data, request: request) else {
return fallbackRenderedContent(request: request)
}
let attributedString = NSMutableAttributedString(attributedString: rendered)
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: style)
return RDEPUBRenderedChapterContent(attributedString: attributedString, fragmentOffsets: fragmentOffsets)
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
return RDEPUBRenderedChapterContent(
attributedString: attributedString,
fragmentOffsets: fragmentOffsets,
resourceDiagnostics: chapterContext.resourceDiagnostics
)
#else
let markedHTML = RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)
return fallbackRenderedContent(for: markedHTML, style: style)
return fallbackRenderedContent(request: request)
#endif
}
private func fallbackRenderedContent(for html: String, style: RDEPUBTextRenderStyle) -> RDEPUBRenderedChapterContent {
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: html, style: style)
public func renderChapter(
html: String,
baseURL: URL?,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBRenderedChapterContent {
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: "",
title: "",
rawHTML: html,
baseURL: baseURL,
style: style,
resourceResolver: nil
)
return try renderChapter(request: request)
}
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: style)
return RDEPUBRenderedChapterContent(attributedString: attributedString, fragmentOffsets: fragmentOffsets)
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
return RDEPUBRenderedChapterContent(
attributedString: attributedString,
fragmentOffsets: fragmentOffsets,
resourceDiagnostics: request.context.resourceDiagnostics
)
}
#if canImport(DTCoreText)
private func makeAttributedString(from data: Data, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString? {
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
let builder = DTHTMLAttributedStringBuilder(
html: data,
options: dtOptions(baseURL: baseURL, style: style),
options: dtOptions(request: request),
documentAttributes: nil
)
return builder?.generatedAttributedString()
}
private func dtOptions(baseURL: URL?, style: RDEPUBTextRenderStyle) -> [AnyHashable: Any] {
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
let style = request.style
var options: [AnyHashable: Any] = [
NSTextSizeMultiplierDocumentOption: 1.0,
DTDefaultFontFamily: style.font.familyName,
@@ -67,7 +89,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
DTUseiOS6Attributes: true
]
if let baseURL {
if let baseURL = request.context.baseURL {
options[NSBaseURLDocumentOption] = baseURL
}
if let textColor = style.textColor {
@@ -77,4 +99,4 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
return options
}
#endif
}
}
@@ -1,5 +1,15 @@
import UIKit
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
public var href: String
public var title: String
public var pageCount: Int
public var breakReasons: [RDEPUBTextPageBreakReason]
public var attachmentPageCount: Int
public var blockAdjustedPageCount: Int
public var sampleNotes: [String]
}
public struct RDEPUBTextPage: Equatable {
public var absolutePageIndex: Int
public var chapterIndex: Int
@@ -12,6 +22,7 @@ public struct RDEPUBTextPage: Equatable {
public var contentRange: NSRange
public var pageStartOffset: Int
public var pageEndOffset: Int
public var metadata: RDEPUBTextPageMetadata
}
public struct RDEPUBTextChapter: Equatable {
@@ -21,6 +32,7 @@ public struct RDEPUBTextChapter: Equatable {
public var title: String
public var attributedContent: NSAttributedString
public var fragmentOffsets: [String: Int]
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
public var pages: [RDEPUBTextPage]
}
@@ -80,6 +92,8 @@ public struct RDEPUBTextBook: Equatable {
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
public init(renderer: RDEPUBTextRenderer) {
self.renderer = renderer
@@ -97,6 +111,8 @@ public final class RDEPUBTextBookBuilder {
) throws -> RDEPUBTextBook {
var chapters: [RDEPUBTextChapter] = []
var flatPages: [RDEPUBTextPage] = []
lastBuildResourceDiagnostics = []
lastBuildPaginationDiagnostics = []
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
@@ -105,12 +121,16 @@ public final class RDEPUBTextBookBuilder {
}
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
let normalizedHTML = normalizeHTML(rawHTML)
let rendered = try renderer.renderChapter(
html: normalizedHTML,
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: item.href,
title: chapterTitle,
rawHTML: rawHTML,
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
style: style
style: style,
resourceResolver: publication.resourceResolver
)
let rendered = try renderer.renderChapter(request: request)
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
if shouldSkipChapter(item: item, text: plainText) {
@@ -119,24 +139,41 @@ public final class RDEPUBTextBookBuilder {
let chapterIndex = chapters.count
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
let pageRanges = content.length > 0 ? content.ss_pageRanges(size: pageSize) : []
let effectivePageRanges = pageRanges.isEmpty && content.length > 0
? [NSRange(location: 0, length: content.length)]
: pageRanges
let layoutFrames = content.length > 0
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
: []
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
: layoutFrames
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
RDEPUBTextPage(
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
return RDEPUBTextPage(
absolutePageIndex: flatPages.count + localPageIndex,
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
chapterTitle: chapterTitle,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectivePageRanges.count,
totalPagesInChapter: effectiveFrames.count,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + max(range.length - 1, 0)
pageEndOffset: range.location + max(range.length - 1, 0),
metadata: frame.metadata
)
}
@@ -148,9 +185,25 @@ public final class RDEPUBTextBookBuilder {
title: chapterTitle,
attributedContent: content.copy() as! NSAttributedString,
fragmentOffsets: rendered.fragmentOffsets,
pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages
)
)
lastBuildPaginationDiagnostics.append(
RDEPUBTextChapterPaginationDiagnostic(
href: item.href,
title: chapterTitle,
pageCount: pages.count,
breakReasons: pages.map(\.metadata.breakReason),
attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count,
sampleNotes: Array(
pages
.flatMap(\.metadata.diagnostics)
.prefix(4)
)
)
)
flatPages.append(contentsOf: pages)
}
@@ -180,26 +233,4 @@ public final class RDEPUBTextBookBuilder {
}
return false
}
private func normalizeHTML(_ html: String) -> String {
var cleanedHTML = html
let replacements: [(pattern: String, template: String)] = [
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
(#"\r"#, "\n"),
(#"\n+"#, "\n")
]
for replacement in replacements {
if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
cleanedHTML = regex.stringByReplacingMatches(
in: cleanedHTML,
options: [],
range: NSRange(location: 0, length: cleanedHTML.utf16.count),
withTemplate: replacement.template
)
}
}
return cleanedHTML
}
}
}
@@ -0,0 +1,22 @@
import Foundation
struct RDEPUBTextLayoutFrame: Equatable {
var contentRange: NSRange
var breakReason: RDEPUBTextPageBreakReason
var blockRange: NSRange?
var attachmentRanges: [NSRange]
var attachmentKinds: [RDEPUBTextAttachmentKind]
var trailingFragmentID: String?
var diagnostics: [String]
var metadata: RDEPUBTextPageMetadata {
RDEPUBTextPageMetadata(
breakReason: breakReason,
blockRange: blockRange,
attachmentRanges: attachmentRanges,
attachmentKinds: attachmentKinds,
trailingFragmentID: trailingFragmentID,
diagnostics: diagnostics
)
}
}
@@ -0,0 +1,244 @@
import CoreText
import UIKit
struct RDEPUBTextLayouter {
private let attributedString: NSAttributedString
private let pageSize: CGSize
private let framesetter: CTFramesetter
private let path: CGPath
init(attributedString: NSAttributedString, pageSize: CGSize) {
self.attributedString = attributedString
self.pageSize = pageSize
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
}
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
}
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
while location < attributedString.length {
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil)
let visibleRange = CTFrameGetVisibleStringRange(frame)
guard visibleRange.length > 0 else {
break
}
let proposedRange = NSRange(location: location, length: visibleRange.length)
let adjusted = adjustedRange(from: proposedRange, 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,
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
}
private func adjustedRange(
from proposedRange: NSRange,
totalLength: Int
) -> (
range: NSRange,
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange?,
attachmentRanges: [NSRange],
attachmentKinds: [RDEPUBTextAttachmentKind],
diagnostics: [String]
) {
let pageEnd = proposedRange.location + proposedRange.length
guard pageEnd < totalLength else {
return (
range: proposedRange,
breakReason: .chapterEnd,
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
attachmentRanges: attachmentRanges(in: proposedRange),
attachmentKinds: attachmentKinds(in: proposedRange),
diagnostics: diagnostics(
reason: .chapterEnd,
range: proposedRange,
attachmentRanges: attachmentRanges(in: proposedRange),
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1))
)
)
}
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
let minimumEnd = proposedRange.location + minLength
let currentBlockRange = blockRange(at: max(proposedRange.location, pageEnd - 1))
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
let currentAttachmentKinds = attachmentKinds(in: proposedRange)
if let attachmentBoundary = preferredAttachmentBoundary(
in: proposedRange,
minimumEnd: minimumEnd
) {
let adjustedRange = NSRange(location: proposedRange.location, length: attachmentBoundary - proposedRange.location)
return (
range: adjustedRange,
breakReason: .attachmentBoundary,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
diagnostics: diagnostics(
reason: .attachmentBoundary,
range: adjustedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange
)
)
}
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,
diagnostics: diagnostics(
reason: .blockBoundary,
range: adjustedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange
)
)
}
return (
range: proposedRange,
breakReason: .frameLimit,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
diagnostics: diagnostics(
reason: .frameLimit,
range: proposedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange
)
)
}
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
var boundary: Int?
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
guard value != nil else { return }
let paragraphRange = paragraphRange(containing: attributeRange.location)
if paragraphRange.location > range.location, paragraphRange.location >= minimumEnd {
boundary = paragraphRange.location
stop.pointee = true
}
}
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)
if let encodedRange = attributes[.rdPageBlockRange] as? String {
return NSRangeFromString(encodedRange)
}
return nil
}
private func paragraphRange(containing location: Int) -> NSRange {
let source = attributedString.string as NSString
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
let safeLocation = min(max(location, 0), max(source.length - 1, 0))
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
}
private func attachmentRanges(in range: NSRange) -> [NSRange] {
var results: [NSRange] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
guard value != nil else { return }
results.append(attributeRange)
}
return results
}
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
var kinds: [RDEPUBTextAttachmentKind] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
guard let rawValue = value as? String,
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
!kinds.contains(kind) else {
return
}
kinds.append(kind)
}
return kinds
}
private func nearestTrailingFragmentID(
endingAt location: Int,
fragmentOffsets: [String: Int]
) -> String? {
fragmentOffsets
.filter { $0.value <= location }
.max { lhs, rhs in lhs.value < rhs.value }?
.key
}
private func diagnostics(
reason: RDEPUBTextPageBreakReason,
range: NSRange,
attachmentRanges: [NSRange],
blockRange: NSRange?
) -> [String] {
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
if let blockRange {
items.append("block range: \(NSStringFromRange(blockRange))")
}
if !attachmentRanges.isEmpty {
items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
}
return items
}
}
@@ -2,24 +2,16 @@ import CoreText
import UIKit
extension NSAttributedString {
func rd_paginatedFrames(
size: CGSize,
fragmentOffsets: [String: Int] = [:]
) -> [RDEPUBTextLayoutFrame] {
RDEPUBTextLayouter(attributedString: self, pageSize: size)
.layoutFrames(fragmentOffsets: fragmentOffsets)
}
func ss_pageRanges(size: CGSize) -> [NSRange] {
var ranges: [NSRange] = []
let framesetter = CTFramesetterCreateWithAttributedString(self)
let path = CGPath(rect: CGRect(origin: .zero, size: size), transform: nil)
var visibleRange = CFRangeMake(0, 0)
var location = 0
while visibleRange.location + visibleRange.length < length {
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil)
visibleRange = CTFrameGetVisibleStringRange(frame)
guard visibleRange.length > 0 else {
break
}
ranges.append(NSRange(location: location, length: visibleRange.length))
location += visibleRange.length
}
return ranges
rd_paginatedFrames(size: size).map(\.contentRange)
}
}
@@ -32,4 +24,4 @@ extension UIColor {
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "rgba(%d, %d, %d, %.3f)", Int(red * 255), Int(green * 255), Int(blue * 255), alpha)
}
}
}
@@ -1,5 +1,12 @@
import UIKit
public extension NSAttributedString.Key {
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
}
public struct RDEPUBTextRenderStyle {
public var font: UIFont
public var lineSpacing: CGFloat
@@ -14,17 +21,127 @@ public struct RDEPUBTextRenderStyle {
}
}
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
case `default`
case replace
case dark
case epub
case user
}
public struct RDEPUBTextStyleSheetLayer: Equatable {
public var kind: RDEPUBTextStyleSheetLayerKind
public var css: String
public init(kind: RDEPUBTextStyleSheetLayerKind, css: String) {
self.kind = kind
self.css = css
}
}
public struct RDEPUBTextStyleSheetPackage: Equatable {
public var layers: [RDEPUBTextStyleSheetLayer]
public init(layers: [RDEPUBTextStyleSheetLayer]) {
self.layers = layers
}
public var combinedCSS: String {
layers
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.map { layer in
"/* \(layer.kind.rawValue) */\n\(layer.css)"
}
.joined(separator: "\n\n")
}
}
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
case stylesheet
case image
}
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
public var kind: RDEPUBTextResourceReferenceKind
public var chapterHref: String
public var originalReference: String
public var normalizedHref: String?
public var resolvedFileURL: URL?
public var existsOnDisk: Bool
public init(
kind: RDEPUBTextResourceReferenceKind,
chapterHref: String,
originalReference: String,
normalizedHref: String?,
resolvedFileURL: URL?,
existsOnDisk: Bool
) {
self.kind = kind
self.chapterHref = chapterHref
self.originalReference = originalReference
self.normalizedHref = normalizedHref
self.resolvedFileURL = resolvedFileURL
self.existsOnDisk = existsOnDisk
}
}
public struct RDEPUBTextChapterContext: Equatable {
public var href: String
public var title: String
public var html: String
public var baseURL: URL?
public var stylesheet: RDEPUBTextStyleSheetPackage
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public init(
href: String,
title: String,
html: String,
baseURL: URL?,
stylesheet: RDEPUBTextStyleSheetPackage,
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
) {
self.href = href
self.title = title
self.html = html
self.baseURL = baseURL
self.stylesheet = stylesheet
self.resourceDiagnostics = resourceDiagnostics
}
}
public struct RDEPUBTextChapterRenderRequest {
public var context: RDEPUBTextChapterContext
public var style: RDEPUBTextRenderStyle
public init(context: RDEPUBTextChapterContext, style: RDEPUBTextRenderStyle) {
self.context = context
self.style = style
}
}
public struct RDEPUBRenderedChapterContent {
public var attributedString: NSAttributedString
public var fragmentOffsets: [String: Int]
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public init(attributedString: NSAttributedString, fragmentOffsets: [String: Int]) {
public init(
attributedString: NSAttributedString,
fragmentOffsets: [String: Int],
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
) {
self.attributedString = attributedString
self.fragmentOffsets = fragmentOffsets
self.resourceDiagnostics = resourceDiagnostics
}
}
public protocol RDEPUBTextRenderer {
func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent
func renderChapter(
html: String,
baseURL: URL?,
@@ -32,6 +149,24 @@ public protocol RDEPUBTextRenderer {
) throws -> RDEPUBRenderedChapterContent
}
public extension RDEPUBTextRenderer {
func renderChapter(
html: String,
baseURL: URL?,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBRenderedChapterContent {
let context = RDEPUBTextChapterContext(
href: "",
title: "",
html: html,
baseURL: baseURL,
stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
resourceDiagnostics: []
)
return try renderChapter(request: RDEPUBTextChapterRenderRequest(context: context, style: style))
}
}
public enum RDEPUBTextRenderingError: LocalizedError {
case htmlEncodingFailed
case htmlImportFailed
@@ -44,4 +179,4 @@ public enum RDEPUBTextRenderingError: LocalizedError {
return "HTML 富文本导入失败"
}
}
}
}
@@ -1,6 +1,9 @@
import UIKit
enum RDEPUBTextRendererSupport {
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
static func injectFragmentMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
return html
@@ -43,6 +46,7 @@ enum RDEPUBTextRendererSupport {
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
let sourceFont = attributes[.font] as? UIFont
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
@@ -55,7 +59,13 @@ enum RDEPUBTextRendererSupport {
if let textColor = style.textColor {
updatedAttributes[.foregroundColor] = textColor
}
updatedAttributes[.rdPageBlockRange] = NSStringFromRange(range)
updatedAttributes[.rdPageBlockIndex] = blockIndex
if let attachmentKind = attachmentKind(for: attributes) {
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
}
attributedString.setAttributes(updatedAttributes, range: range)
blockIndex += 1
}
}
@@ -68,6 +78,375 @@ enum RDEPUBTextRendererSupport {
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
}
static func makeChapterRenderRequest(
href: String,
title: String,
rawHTML: String,
baseURL: URL?,
style: RDEPUBTextRenderStyle,
resourceResolver: RDEPUBResourceResolver?
) -> RDEPUBTextChapterRenderRequest {
let normalizedHTML = normalizeHTML(rawHTML)
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
in: normalizedHTML,
chapterHref: href,
baseURL: baseURL,
resourceResolver: resourceResolver
)
let layers = makeStyleSheetLayers(
style: style,
epubCSS: stylesheetHrefReplacements.inlinedCSS
)
let htmlWithBase = injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
let htmlWithDefaultLayers = injectStyleTag(
into: htmlWithBase,
styleID: "rd-native-default-replace-dark",
css: layers
.filter { $0.kind != .user && $0.kind != .epub }
.map(\.css)
.joined(separator: "\n\n"),
position: .headStart
)
let htmlWithEPUBLayer = injectStyleTag(
into: htmlWithDefaultLayers,
styleID: "rd-native-epub",
css: layers.first(where: { $0.kind == .epub })?.css ?? "",
position: .headEnd
)
let composedHTML = injectStyleTag(
into: htmlWithEPUBLayer,
styleID: "rd-native-user",
css: layers.first(where: { $0.kind == .user })?.css ?? "",
position: .headEnd
)
let markedHTML = injectFragmentMarkers(into: composedHTML)
let resourceDiagnostics = stylesheetHrefReplacements.diagnostics + collectImageDiagnostics(
in: markedHTML,
chapterHref: href,
baseURL: baseURL,
resourceResolver: resourceResolver
)
let context = RDEPUBTextChapterContext(
href: href,
title: title,
html: markedHTML,
baseURL: baseURL,
stylesheet: RDEPUBTextStyleSheetPackage(layers: layers),
resourceDiagnostics: resourceDiagnostics
)
return RDEPUBTextChapterRenderRequest(context: context, style: style)
}
static func normalizeHTML(_ html: String) -> String {
var cleanedHTML = html
let replacements: [(pattern: String, template: String)] = [
(#"<hr\s+lang="zh-CN"></hr>"#, ""),
(#"\r"#, "\n"),
(#"\n+"#, "\n")
]
for replacement in replacements {
if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
cleanedHTML = regex.stringByReplacingMatches(
in: cleanedHTML,
options: [],
range: NSRange(location: 0, length: cleanedHTML.utf16.count),
withTemplate: replacement.template
)
}
}
return cleanedHTML
}
private static func makeStyleSheetLayers(
style: RDEPUBTextRenderStyle,
epubCSS: String
) -> [RDEPUBTextStyleSheetLayer] {
var layers: [RDEPUBTextStyleSheetLayer] = [
.init(kind: .default, css: defaultCSS()),
.init(kind: .replace, css: replaceCSS())
]
if isDarkTheme(style: style) {
layers.append(.init(kind: .dark, css: darkCSS(style: style)))
}
if !epubCSS.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
layers.append(.init(kind: .epub, css: epubCSS))
}
layers.append(.init(kind: .user, css: userCSS(style: style)))
return layers
}
private static func defaultCSS() -> String {
"""
html, body {
margin: 0;
padding: 0;
background: transparent;
}
body {
word-break: break-word;
overflow-wrap: break-word;
-webkit-text-size-adjust: 100%;
}
p, div, li, blockquote {
margin-top: 0;
margin-bottom: 0.75em;
}
"""
}
private static func replaceCSS() -> String {
"""
img, svg, video, canvas {
display: block;
max-width: 100%;
height: auto;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
}
pre, code {
white-space: pre-wrap;
word-break: break-word;
}
table {
max-width: 100%;
}
"""
}
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
return """
html, body {
background: \(background) !important;
color: \(text) !important;
}
a {
color: \(text) !important;
}
"""
}
private static func userCSS(style: RDEPUBTextRenderStyle) -> String {
let lineHeight = max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1)
let text = style.textColor.map { "color: \($0.ss_cssString) !important;" } ?? ""
let background = style.backgroundColor.map { "background: \($0.ss_cssString) !important;" } ?? ""
return """
html, body {
font-family: "\(style.font.familyName)" !important;
font-size: \(String(format: "%.3f", style.font.pointSize))px !important;
line-height: \(String(format: "%.3f", lineHeight)) !important;
\(text)
\(background)
}
"""
}
private static func isDarkTheme(style: RDEPUBTextRenderStyle) -> Bool {
guard let backgroundColor = style.backgroundColor else {
return false
}
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
backgroundColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
return luminance < 0.5
}
private static func injectBaseHref(into html: String, baseURL: URL?) -> String {
guard let baseURL else {
return html
}
let baseTag = "<base href=\"\(baseURL.absoluteString)\">"
if html.range(of: "<base ", options: [.caseInsensitive]) != nil {
return html
}
if html.range(of: "<head>", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "<head>", with: "<head>\n\(baseTag)", options: [.caseInsensitive])
}
if let htmlTagRange = html.range(of: "<html", options: [.caseInsensitive]),
let htmlRange = html.range(of: ">", range: htmlTagRange.lowerBound..<html.endIndex) {
return html.replacingCharacters(in: htmlRange.upperBound..<htmlRange.upperBound, with: "\n<head>\n\(baseTag)\n</head>")
}
return "<head>\n\(baseTag)\n</head>\n" + html
}
private static func inlineLinkedStyleSheets(
in html: String,
chapterHref: String,
baseURL: URL?,
resourceResolver: RDEPUBResourceResolver?
) -> (html: String, inlinedCSS: String, diagnostics: [RDEPUBTextResourceReferenceDiagnostic]) {
guard let regex = try? NSRegularExpression(pattern: stylesheetLinkPattern, options: [.caseInsensitive]) else {
return (html, "", [])
}
let nsHTML = html as NSString
let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
guard !matches.isEmpty else {
return (html, "", [])
}
var rewrittenHTML = html
var inlinedCSSBlocks: [String] = []
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
for match in matches.reversed() {
guard match.numberOfRanges > 1 else { continue }
let href = nsHTML.substring(with: match.range(at: 1))
let resolution = resolveReference(
href,
kind: .stylesheet,
chapterHref: chapterHref,
baseURL: baseURL,
resourceResolver: resourceResolver
)
diagnostics.append(resolution.diagnostic)
let replacement: String
if let fileURL = resolution.resolvedFileURL,
let css = try? String(contentsOf: fileURL),
resolution.diagnostic.existsOnDisk {
let cssWithResolvedURLs = rewriteCSSResourceURLs(
in: css,
styleSheetFileURL: fileURL
)
inlinedCSSBlocks.append(cssWithResolvedURLs)
replacement = ""
} else {
replacement = ""
}
if let range = Range(match.range, in: rewrittenHTML) {
rewrittenHTML.replaceSubrange(range, with: replacement)
}
}
return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed())
}
private static func collectImageDiagnostics(
in html: String,
chapterHref: String,
baseURL: URL?,
resourceResolver: RDEPUBResourceResolver?
) -> [RDEPUBTextResourceReferenceDiagnostic] {
guard let regex = try? NSRegularExpression(pattern: imageSourcePattern, options: [.caseInsensitive]) else {
return []
}
let nsHTML = html as NSString
return regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in
guard match.numberOfRanges > 1 else { return nil }
let href = nsHTML.substring(with: match.range(at: 1))
return resolveReference(
href,
kind: .image,
chapterHref: chapterHref,
baseURL: baseURL,
resourceResolver: resourceResolver
).diagnostic
}
}
private static func rewriteCSSResourceURLs(
in css: String,
styleSheetFileURL: URL
) -> String {
guard let regex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
return css
}
let nsCSS = css as NSString
let matches = regex.matches(in: css, options: [], range: NSRange(location: 0, length: nsCSS.length))
guard !matches.isEmpty else {
return css
}
var rewrittenCSS = css
for match in matches.reversed() {
guard match.numberOfRanges > 1 else { continue }
let rawValue = nsCSS.substring(with: match.range(at: 1))
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
guard !rawValue.isEmpty else { continue }
if rawValue.hasPrefix("data:") || rawValue.hasPrefix("http://") || rawValue.hasPrefix("https://") || rawValue.hasPrefix("file://") || rawValue.hasPrefix("#") {
continue
}
guard let resolvedURL = URL(string: rawValue, relativeTo: styleSheetFileURL.deletingLastPathComponent())?.standardizedFileURL else {
continue
}
let replacement = "url(\"\(resolvedURL.absoluteString)\")"
if let range = Range(match.range, in: rewrittenCSS) {
rewrittenCSS.replaceSubrange(range, with: replacement)
}
}
return rewrittenCSS
}
private static func resolveReference(
_ reference: String,
kind: RDEPUBTextResourceReferenceKind,
chapterHref: String,
baseURL: URL?,
resourceResolver: RDEPUBResourceResolver?
) -> (normalizedHref: String?, resolvedFileURL: URL?, diagnostic: RDEPUBTextResourceReferenceDiagnostic) {
let trimmedReference = reference.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedHref = resourceResolver?.normalizedHref(trimmedReference, relativeToHref: chapterHref)
let resolvedFileURL = resourceResolver?.fileURL(forReference: trimmedReference, relativeToHref: chapterHref)
?? URL(string: trimmedReference, relativeTo: baseURL)?.standardizedFileURL
let existsOnDisk = resolvedFileURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
let diagnostic = RDEPUBTextResourceReferenceDiagnostic(
kind: kind,
chapterHref: chapterHref,
originalReference: trimmedReference,
normalizedHref: normalizedHref,
resolvedFileURL: resolvedFileURL,
existsOnDisk: existsOnDisk
)
return (normalizedHref, resolvedFileURL, diagnostic)
}
private enum StyleInjectionPosition {
case headStart
case headEnd
}
private static func injectStyleTag(
into html: String,
styleID: String,
css: String,
position: StyleInjectionPosition
) -> String {
let trimmedCSS = css.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedCSS.isEmpty else {
return html
}
let styleTag = "<style id=\"\(styleID)\">\n\(trimmedCSS)\n</style>"
switch position {
case .headStart:
if html.range(of: "<head>", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "<head>", with: "<head>\n\(styleTag)", options: [.caseInsensitive])
}
case .headEnd:
if html.range(of: "</head>", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "</head>", with: "\(styleTag)\n</head>", options: [.caseInsensitive])
}
}
if html.range(of: "<body", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "<body", with: "\(styleTag)\n<body", options: [.caseInsensitive])
}
return styleTag + "\n" + html
}
private static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
guard let sourceFont else {
return baseFont
@@ -85,4 +464,22 @@ enum RDEPUBTextRendererSupport {
style.paragraphSpacing = max(6, lineSpacing / 2)
return style
}
}
private static func attachmentKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentKind? {
if let attachment = attributes[.attachment] as? NSTextAttachment {
if attachment.image != nil || attachment.fileType?.lowercased().contains("image") == true {
return .image
}
return .generic
}
for value in attributes.values {
let typeName = String(describing: type(of: value)).lowercased()
if typeName.contains("attachment") {
return typeName.contains("image") ? .image : .generic
}
}
return nil
}
}
@@ -24,25 +24,40 @@ public final class RDPlainTextBookBuilder {
let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style)
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
let pageRanges = content.length > 0 ? content.ss_pageRanges(size: pageSize) : []
let effectivePageRanges = pageRanges.isEmpty && content.length > 0
? [NSRange(location: 0, length: content.length)]
: pageRanges
let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize) : []
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
: layoutFrames
let href = "chapter_\(index).xhtml"
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
RDEPUBTextPage(
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
return RDEPUBTextPage(
absolutePageIndex: flatPages.count + localPageIndex,
chapterIndex: index,
spineIndex: index,
href: href,
chapterTitle: spec.title ?? "\(index + 1)",
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectivePageRanges.count,
totalPagesInChapter: effectiveFrames.count,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + max(range.length - 1, 0)
pageEndOffset: range.location + max(range.length - 1, 0),
metadata: frame.metadata
)
}
@@ -54,6 +69,7 @@ public final class RDPlainTextBookBuilder {
title: spec.title ?? "\(index + 1)",
attributedContent: content.copy() as! NSAttributedString,
fragmentOffsets: [:],
pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages
)
)
@@ -25,6 +25,8 @@ public final class RDEPUBReaderController: UIViewController {
case pendingRepagination
}
private typealias NativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
public weak var delegate: RDEPUBReaderDelegate?
public var configuration: RDEPUBReaderConfiguration {
@@ -610,9 +612,10 @@ public final class RDEPUBReaderController: UIViewController {
private func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
self.textBook = textBook
self.activePages = []
self.activeChapters = []
readingSession?.setActiveSnapshot((pages: [], chapters: []))
let snapshot = nativeTextSnapshot(from: textBook)
self.activePages = snapshot.pages
self.activeChapters = snapshot.chapters
readingSession?.setActiveSnapshot(snapshot)
guard !textBook.pages.isEmpty else {
handle(error: RDEPUBParserError.emptySpine)
@@ -667,41 +670,28 @@ public final class RDEPUBReaderController: UIViewController {
@discardableResult
fileprivate func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
if let textBook {
guard let publication, let targetPageNumber = textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
) else {
readerView.transitionToPage(pageNum: 0)
readingSession?.transition(to: .idle)
return false
}
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
guard let targetPageNumber = pageNumber(for: location) else {
readerView.transitionToPage(pageNum: 0)
readingSession?.transition(to: .idle)
return true
return false
}
guard let readingSession,
let targetPageNumber = readingSession.queueNavigation(
if textBook == nil {
_ = readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) else {
readerView.transitionToPage(pageNum: 0)
self.readingSession?.transition(to: .idle)
return false
)
} else {
readingSession?.transition(to: .jumping)
}
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
return true
}
fileprivate func currentVisibleLocation() -> RDEPUBLocation? {
if let textBook, readerView.currentPage >= 0 {
return textBook.location(
forPageNumber: readerView.currentPage + 1,
bookIdentifier: currentBookIdentifier
)
if textBook != nil, readerView.currentPage >= 0 {
return resolvedTextLocation(forPageNumber: readerView.currentPage + 1)
}
return readingSession?.currentReadingLocation(bookIdentifier: currentBookIdentifier)
}
@@ -1494,6 +1484,17 @@ extension RDEPUBReaderController {
}
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
if let textBook,
let publication,
let rangeLocation = searchMatch.rangeLocation,
let chapter = textBook.chapters.first(where: {
(publication.resourceResolver.normalizedHref($0.href) ?? $0.href) ==
(publication.resourceResolver.normalizedHref(searchMatch.href) ?? searchMatch.href)
}),
let page = chapter.pages.first(where: { rangeLocation >= $0.pageStartOffset && rangeLocation <= $0.pageEndOffset }) {
return page.absolutePageIndex + 1
}
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: searchMatch.href,
@@ -1589,7 +1590,13 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
}
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
activeHighlights.filter { $0.location.href == page.href }
guard let publication else {
return activeHighlights.filter { $0.location.href == page.href }
}
let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href
return activeHighlights.filter {
(publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref
}
}
public func topToolView(readerView: RDReaderView) -> UIView? {
@@ -1608,10 +1615,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
delegate?.epubReaderDidReachEnd(self)
}
if let textBook,
let location = textBook.location(forPageNumber: pageNum + 1, bookIdentifier: currentBookIdentifier) {
if textBook != nil,
let location = resolvedTextLocation(forPageNumber: pageNum + 1) {
persist(location: location)
readingSession?.transition(to: .idle)
synchronizeTextReadingState(pageNumber: pageNum + 1, location: location)
return
}
@@ -1707,16 +1714,18 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
return scopedSelection(selection, relativeToSpineIndex: nil)
}
let lastOffset = max(chapter.attributedContent.length - 1, 1)
let start = max(0, min(payload.start, lastOffset))
let end = max(start, min(payload.end, lastOffset))
let contentLength = max(chapter.attributedContent.length, 1)
let lastInclusiveOffset = max(contentLength - 1, 1)
let start = max(0, min(payload.start, lastInclusiveOffset))
let endExclusive = max(start + 1, min(payload.end, contentLength))
let lastSelectedOffset = max(start, min(endExclusive - 1, lastInclusiveOffset))
return RDEPUBSelection(
bookIdentifier: currentBookIdentifier,
location: RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: selection.location.href,
progression: Double(start) / Double(lastOffset),
lastProgression: Double(end) / Double(lastOffset),
progression: Double(start) / Double(lastInclusiveOffset),
lastProgression: Double(lastSelectedOffset) / Double(lastInclusiveOffset),
fragment: nil
),
text: selection.text,
@@ -1724,6 +1733,81 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
createdAt: selection.createdAt
)
}
private func pageNumber(for location: RDEPUBLocation) -> Int? {
if let textBook, let publication {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
return readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
)
}
private func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
guard let textBook,
let publication,
let location = textBook.location(
forPageNumber: pageNumber,
bookIdentifier: currentBookIdentifier
) else {
return nil
}
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
private func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
guard let textBook,
let page = textBook.page(at: pageNumber) else {
readingSession?.transition(to: .idle)
return
}
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: page.spineIndex,
chapterIndex: page.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
private func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> NativeTextSnapshot {
let chapters = textBook.chapters.map {
EPUBChapterInfo(
spineIndex: $0.spineIndex,
title: $0.title,
pageCount: $0.pages.count
)
}
let pages = textBook.pages.map {
EPUBPage(
spineIndex: $0.spineIndex,
chapterIndex: $0.chapterIndex,
pageIndexInChapter: $0.pageIndexInChapter,
totalPagesInChapter: $0.totalPagesInChapter,
chapterTitle: $0.chapterTitle,
fixedSpread: nil
)
}
return (pages, chapters)
}
}
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
@@ -127,8 +127,9 @@ final class RDEPUBTextContentView: UIView {
}
private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) {
let pageStart = Int(page.pageStartOffset)
let pageEndExclusive = Int(page.pageEndOffset) + 1
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
for highlight in highlightedRanges where highlight.location.href == page.href {
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
@@ -165,8 +166,9 @@ final class RDEPUBTextContentView: UIView {
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
let pageStart = Int(page.pageStartOffset)
let pageEndExclusive = Int(page.pageEndOffset) + 1
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
for match in searchState.matches where match.href == page.href {
guard let matchStart = match.rangeLocation else { continue }
@@ -181,6 +183,12 @@ final class RDEPUBTextContentView: UIView {
}
}
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound)
}
}
extension RDEPUBTextContentView: UITextViewDelegate {