- 新增 RDEPUBResourceDataProvider 解密钩子协议 + 访问登记表,收敛章节 HTML/ 图片/内联 CSS/脚注/封面/图片查看器/正文取图等读取点,scheme handler 对加密书 禁用流式分支;新增 RDEPUBDecryptingImageAttachment 解密 DTCoreText 图片附件; RDEPUBReaderDependencies.live(resourceDataProvider:) 便捷注入。明文书零影响。 - 试读墙:configuration.trialPolicy + delegate epubReaderTrialWallView/ DidReachTrialWall,UI 由宿主提供(RDEPUBReaderController+Trial)。 - 工具栏定制:RDEPUBReaderTop/BottomToolViewProtocol 协议 + dependencies 工厂注入, 内置栏已 conform,configureTopToolView 改协议类型。 - 朝向锁定:RDEPUBMetadata.orientation(OPF rendition:orientation 主, iBooks display-options 兜底)+ RDEPUBReaderController+Orientation 重写支持朝向、 打开后主动转向。 注:RDEPUBReaderController+LocationResolution / PaginationCoordinator 为本次改动前 即存在的工作区修改,一并纳入。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
169 lines
6.4 KiB
Swift
169 lines
6.4 KiB
Swift
import Foundation
|
||
|
||
public final class RDEPUBNoteResolver {
|
||
|
||
private let resourceResolver: RDEPUBResourceResolver
|
||
|
||
public init(resourceResolver: RDEPUBResourceResolver) {
|
||
self.resourceResolver = resourceResolver
|
||
}
|
||
|
||
public func resolveInternalLink(
|
||
sourceHref: String,
|
||
sourceCFI: String?,
|
||
targetLocation: RDEPUBLocation,
|
||
sourceLocation: RDEPUBLocation? = nil,
|
||
relativeToSpineIndex spineIndex: Int?
|
||
) -> RDEPUBResolvedNote? {
|
||
guard let normalizedTarget = resourceResolver.normalizedLocation(
|
||
targetLocation,
|
||
relativeToSpineIndex: spineIndex,
|
||
bookIdentifier: targetLocation.bookIdentifier
|
||
) else {
|
||
return nil
|
||
}
|
||
|
||
let fragment = normalizedTarget.fragment
|
||
guard let elementHTML = extractElementHTML(href: normalizedTarget.href, fragment: fragment) else {
|
||
return nil
|
||
}
|
||
|
||
guard let reference = RDEPUBNoteDetector.makeReferenceIfLikelyNote(
|
||
sourceHref: sourceHref,
|
||
sourceCFI: sourceCFI,
|
||
targetHref: normalizedTarget.href,
|
||
targetFragment: fragment,
|
||
targetCFI: normalizedTarget.cfi,
|
||
targetElementHTML: elementHTML
|
||
) else {
|
||
return nil
|
||
}
|
||
|
||
let sanitizedHTML = stripBacklinks(from: elementHTML)
|
||
let plainText = plainText(from: sanitizedHTML)
|
||
guard !plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
return nil
|
||
}
|
||
|
||
return RDEPUBResolvedNote(
|
||
reference: reference,
|
||
sourceLocation: sourceLocation,
|
||
targetLocation: normalizedTarget,
|
||
title: title(for: reference),
|
||
html: sanitizedHTML,
|
||
plainText: plainText
|
||
)
|
||
}
|
||
|
||
public func extractElementHTML(href: String, fragment: String?) -> String? {
|
||
guard let fragment,
|
||
!fragment.isEmpty,
|
||
let fileURL = resourceResolver.fileURL(forRelativePath: href),
|
||
let html = chapterHTML(at: fileURL) else {
|
||
return nil
|
||
}
|
||
|
||
return elementHTML(withID: fragment, in: html)
|
||
}
|
||
|
||
/// 读取章节 HTML:优先经加密资源 provider 解密,明文文件保持原直读路径
|
||
private func chapterHTML(at fileURL: URL) -> String? {
|
||
if let provided = resourceResolver.providedResourceData(at: fileURL) {
|
||
return String(data: provided, encoding: .utf8)
|
||
?? String(data: provided, encoding: .utf16)
|
||
}
|
||
return try? String(contentsOf: fileURL, encoding: .utf8)
|
||
}
|
||
|
||
private func elementHTML(withID id: String, in html: String) -> String? {
|
||
let escapedID = NSRegularExpression.escapedPattern(for: id)
|
||
let pattern = #"<([A-Za-z][A-Za-z0-9:_-]*)(?=[^>]*(?:id|xml:id)\s*=\s*(['"])"# + escapedID + #"\2)[^>]*>"#
|
||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||
return nil
|
||
}
|
||
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
|
||
guard let match = regex.firstMatch(in: html, options: [], range: nsRange),
|
||
let startRange = Range(match.range, in: html),
|
||
let tagRange = Range(match.range(at: 1), in: html) else {
|
||
return nil
|
||
}
|
||
let tagName = String(html[tagRange])
|
||
guard !String(html[startRange]).hasSuffix("/>") else {
|
||
return String(html[startRange])
|
||
}
|
||
guard let endRange = matchingEndTagRange(
|
||
tagName: tagName,
|
||
in: html,
|
||
after: startRange.upperBound
|
||
) else {
|
||
return String(html[startRange])
|
||
}
|
||
return String(html[startRange.lowerBound..<endRange.upperBound])
|
||
}
|
||
|
||
private func stripBacklinks(from html: String) -> String {
|
||
let backlinkPattern = #"<a\b[^>]*(?:rev\s*=\s*['"]footnote['"]|epub:type\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"]|class\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"])[^>]*>[\s\S]*?</a>"#
|
||
guard let regex = try? NSRegularExpression(pattern: backlinkPattern, options: [.caseInsensitive]) else {
|
||
return html
|
||
}
|
||
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
|
||
return regex.stringByReplacingMatches(in: html, options: [], range: nsRange, withTemplate: "")
|
||
}
|
||
|
||
private func plainText(from html: String) -> String {
|
||
let withoutScripts = html
|
||
.replacingOccurrences(of: #"<script[\s\S]*?</script>"#, with: "", options: .regularExpression)
|
||
.replacingOccurrences(of: #"<style[\s\S]*?</style>"#, with: "", options: .regularExpression)
|
||
let withoutTags = withoutScripts.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
|
||
return withoutTags
|
||
.replacingOccurrences(of: " ", with: " ")
|
||
.replacingOccurrences(of: "<", with: "<")
|
||
.replacingOccurrences(of: ">", with: ">")
|
||
.replacingOccurrences(of: "&", with: "&")
|
||
.components(separatedBy: .whitespacesAndNewlines)
|
||
.filter { !$0.isEmpty }
|
||
.joined(separator: " ")
|
||
}
|
||
|
||
private func title(for reference: RDEPUBNoteReference) -> String {
|
||
switch reference.kind {
|
||
case .footnote:
|
||
return "脚注"
|
||
case .endnote:
|
||
return "尾注"
|
||
case .rearnote:
|
||
return "后注"
|
||
case .unknown:
|
||
return "注释"
|
||
}
|
||
}
|
||
|
||
private func matchingEndTagRange(
|
||
tagName: String,
|
||
in html: String,
|
||
after startIndex: String.Index
|
||
) -> Range<String.Index>? {
|
||
let escapedTagName = NSRegularExpression.escapedPattern(for: tagName)
|
||
let pattern = #"</?\s*"# + escapedTagName + #"\b[^>]*>"#
|
||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||
return nil
|
||
}
|
||
|
||
let searchRange = NSRange(startIndex..<html.endIndex, in: html)
|
||
var depth = 1
|
||
for match in regex.matches(in: html, options: [], range: searchRange) {
|
||
guard let range = Range(match.range, in: html) else { continue }
|
||
let tag = String(html[range])
|
||
if tag.hasPrefix("</") {
|
||
depth -= 1
|
||
} else if !tag.hasSuffix("/>") {
|
||
depth += 1
|
||
}
|
||
if depth == 0 {
|
||
return range
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
}
|