chore: checkpoint current milestone work
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user