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..") 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.. String { let backlinkPattern = #"]*(?:rev\s*=\s*['"]footnote['"]|epub:type\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"]|class\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"])[^>]*>[\s\S]*?"# guard let regex = try? NSRegularExpression(pattern: backlinkPattern, options: [.caseInsensitive]) else { return html } let nsRange = NSRange(html.startIndex.. String { let withoutScripts = html .replacingOccurrences(of: #""#, with: "", options: .regularExpression) .replacingOccurrences(of: #""#, 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? { let escapedTagName = NSRegularExpression.escapedPattern(for: tagName) let pattern = #"]*>"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } let searchRange = NSRange(startIndex..") { depth += 1 } if depth == 0 { return range } } return nil } }