- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
160 lines
6.0 KiB
Swift
160 lines
6.0 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 = try? String(contentsOf: fileURL, encoding: .utf8) else {
|
|
return nil
|
|
}
|
|
|
|
return elementHTML(withID: fragment, in: html)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|