import UIKit import CoreText #if canImport(DTCoreText) import DTCoreText #endif /// 渲染器支持工具集:负责 HTML 预处理、fragment 标记注入/提取、CSS 组装和字体标准化。 /// /// 本枚举是渲染链路的预处理层,`RDEPUBTextBookBuilder` 在调用渲染器前通过 /// `makeChapterRenderRequest()` 完成 HTML 的全部预处理工作。 /// /// 核心职责: /// - HTML 规范化:清理冗余字符、规范化附件标记 /// - Fragment 标记注入/提取:id 属性 → ${id=xxx} 标记 → 字符偏移量映射 /// - 语义标记注入:HTML 标签 → ${rd-sem-start/end} 标记 → NSAttributedString 属性 /// - CSS 组装:按优先级合并 default/replace/dark/epub/user 五层样式表 /// - 字体标准化:将 EPUB 原始字体映射到用户设置的字体 /// - 附件规范化:图片尺寸、脚注图标、封面图片等特殊处理 enum RDEPUBTextRendererSupport { /// 外部样式表链接正则 private static let stylesheetLinkPattern = #"]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"# /// 图片源地址正则 private static let imageSourcePattern = #"]*src\s*=\s*["']([^"']+)["'][^>]*>"# /// 语义标记正则(${rd-sem-start:...} / ${rd-sem-end:...}) private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"# /// 脚注附件日志已输出标记(避免重复日志) private static var didLogFootnoteAttachment = false /// 封面附件日志已输出标记(避免重复日志) private static var didLogCoverAttachment = false /// 已注册字体资源,避免重复调用 CTFontManager。 private static var registeredFontPaths = Set() // MARK: - Fragment 标记注入/提取 /// 将 HTML 中的 id 属性元素注入 fragment 标记。 /// /// 将 `` 转换为 `${id=xxx}`, /// 使 DTCoreText 渲染后能在富文本中定位到 fragment 偏移量。 static func injectFragmentMarkers(into html: String) -> String { guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else { return html } return regex.stringByReplacingMatches( in: html, options: [], range: NSRange(location: 0, length: html.utf16.count), withTemplate: "${id=$2}$1" ) } /// 从渲染后的富文本中提取 fragment 偏移量映射。 /// /// 扫描 `${id=xxx}` 标记,记录每个 fragment ID 的字符偏移量, /// 然后删除标记文本,返回 [fragmentID: offset] 字典。 static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] { let markerPattern = #"\$\{id=([^}]+)\}"# guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else { return [:] } let mutableString = NSMutableString(string: attributedString.string) var fragmentOffsets: [String: Int] = [:] var searchRange = NSRange(location: 0, length: mutableString.length) var offsetAdjustment = 0 while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) { let fullMatch = mutableString.substring(with: match.range) as NSString let fragmentID = fullMatch .replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length)) .replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression) let adjustedLocation = max(0, match.range.location + offsetAdjustment) fragmentOffsets[fragmentID] = adjustedLocation attributedString.deleteCharacters(in: match.range) mutableString.deleteCharacters(in: match.range) offsetAdjustment -= match.range.length searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location) } return fragmentOffsets } /// 将 HTML 中注入的语义标记(${rd-sem-start/end})应用到富文本属性中。 /// /// 标记包含 blockKind、semanticHints、attachmentPlacement 等信息, /// 解析后写入对应的 .rdPage* 属性键,供分页引擎读取。 static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) { guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else { return } let mutableString = NSMutableString(string: attributedString.string) var searchRange = NSRange(location: 0, length: mutableString.length) var openRanges: [String: (location: Int, semantics: RDPaginationSemantics)] = [:] while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) { let kind = mutableString.substring(with: match.range(at: 1)) let payload = mutableString.substring(with: match.range(at: 2)) let markerLocation = match.range.location attributedString.deleteCharacters(in: match.range) mutableString.deleteCharacters(in: match.range) if kind == "start" { let semantics = parseSemanticMarkerPayload(payload) openRanges[semantics.id] = (markerLocation, semantics) } else { let markerID = parseSemanticEndID(payload) if let markerID, let opened = openRanges.removeValue(forKey: markerID) { let length = max(markerLocation - opened.location, 0) if length > 0 { apply(semantics: opened.semantics, to: NSRange(location: opened.location, length: length), in: attributedString) } } } searchRange = NSRange(location: markerLocation, length: mutableString.length - markerLocation) } } /// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。 /// /// 遍历富文本的所有属性区间: /// 1. 将 EPUB 原始字体映射到用户设置字体(保留粗体/斜体特征) /// 2. 统一行距和段间距 /// 3. 应用用户设置的文本颜色 /// 4. 规范化附件显示尺寸 /// 5. 注入块级元素范围、序号、类型等分页语义属性 static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) { let fullRange = NSRange(location: 0, length: attributedString.length) var blockIndex = 0 let sourceText = attributedString.string as NSString attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in let sourceFont = attributes[.font] as? UIFont let normalizedFont = normalizedFont(from: sourceFont, baseFont: style.font) let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing) paragraph.lineSpacing = style.lineSpacing paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2) var updatedAttributes = attributes updatedAttributes[.font] = normalizedFont updatedAttributes[.paragraphStyle] = paragraph if let textColor = style.textColor { updatedAttributes[.foregroundColor] = textColor } normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont) let semanticBlockRange = (attributes[.rdPageBlockRange] as? String) .flatMap(NSRangeFromString) .flatMap { $0.length > 0 ? $0 : nil } let paragraphRange = sourceText.length > 0 ? sourceText.paragraphRange(for: NSRange(location: min(range.location, max(sourceText.length - 1, 0)), length: 0)) : range updatedAttributes[.rdPageBlockRange] = NSStringFromRange(semanticBlockRange ?? paragraphRange) updatedAttributes[.rdPageBlockIndex] = blockIndex if let attachmentKind = attachmentKind(for: attributes) { updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue } if let placement = normalizeAttachmentPlacement(for: attributes) { updatedAttributes[.rdPageAttachmentPlacement] = placement.rawValue } if let blockKind = normalizeBlockKind(for: attributes) { updatedAttributes[.rdPageBlockKind] = blockKind.rawValue } if let hints = normalizeSemanticHints(for: attributes), !hints.isEmpty { updatedAttributes[.rdPageSemanticHints] = hints.map(\.rawValue).joined(separator: ",") } attributedString.setAttributes(updatedAttributes, range: range) blockIndex += 1 } } /// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理。 static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString { let fallbackAttributes: [NSAttributedString.Key: Any] = [ .font: style.font, .paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing), .foregroundColor: style.textColor ?? UIColor.black ] return NSMutableAttributedString(string: html, attributes: fallbackAttributes) } /// 核心预处理方法:将原始 HTML 转换为完整的章节渲染请求。 /// /// 预处理流程: /// 1. normalizeHTML:清理冗余字符、规范化附件 HTML 标记 /// 2. injectPaginationSemanticMarkers:为 HTML 标签注入语义标记 /// 3. inlineLinkedStyleSheets:将外部样式表内联到 HTML 中 /// 4. 组装五层 CSS(default/replace/dark/epub/user)并注入 style 标签 /// 5. injectBaseHref:注入 base 标签以解析相对路径 /// 6. injectFragmentMarkers:注入 fragment 锚点标记 /// 7. collectImageDiagnostics:收集图片资源引用诊断 static func makeChapterRenderRequest( href: String, title: String, rawHTML: String, baseURL: URL?, style: RDEPUBTextRenderStyle, resourceResolver: RDEPUBResourceResolver?, contentLanguageCode: String? = nil, pageSize: CGSize? = nil, layoutConfig: RDEPUBTextLayoutConfig? = nil ) -> RDEPUBTextChapterRenderRequest { let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML)) let stylesheetHrefReplacements = inlineLinkedStyleSheets( in: normalizedHTML, chapterHref: href, baseURL: baseURL, resourceResolver: resourceResolver ) let layers = makeStyleSheetLayers( style: style, epubCSS: stylesheetHrefReplacements.inlinedCSS, contentLanguageCode: contentLanguageCode, sourceHTML: rawHTML ) registerEmbeddedFonts( in: stylesheetHrefReplacements.inlinedCSS + "\n" + inlineStyleCSS(in: normalizedHTML), chapterHref: href, resourceResolver: resourceResolver ) 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, pageSize: pageSize, layoutConfig: layoutConfig ) } /// HTML 规范化:清理冗余字符(CR、多余换行),规范化附件 HTML 标记 static func normalizeHTML(_ html: String) -> String { var cleanedHTML = html let replacements: [(pattern: String, template: String)] = [ (#"分页符"#, ""), (#"\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 ) } } cleanedHTML = normalizeAttachmentHTMLMarkers(in: cleanedHTML) return cleanedHTML } #if canImport(DTCoreText) static func prepareHTMLElementForReaderRendering( _ element: DTHTMLElement, style: RDEPUBTextRenderStyle, maxImageSize: CGSize? = nil ) { guard let attachment = element.textAttachment else { return } let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize) let fallbackSize = CGSize( width: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).width), height: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).height * 0.85) ) normalizeAttachmentLayoutForWXRead( attachment, fontPointSize: pointSize, maxImageSize: maxImageSize ?? fallbackSize ) if isFootnoteAttachment(attachment) { element.displayStyle = .inline } else if isCoverAttachment(attachment) { element.displayStyle = .block } } #endif private static func makeStyleSheetLayers( style: RDEPUBTextRenderStyle, epubCSS: String, contentLanguageCode: String?, sourceHTML: String ) -> [RDEPUBTextStyleSheetLayer] { let useLatinReplace = prefersLatinLanguageCSS( languageCode: contentLanguageCode, sourceHTML: sourceHTML ) var layers: [RDEPUBTextStyleSheetLayer] = [ .init(kind: .default, css: defaultCSS()), .init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace)) ] 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 { RDEPUBAssetRepository.string(for: .wxReadDefaultCSS) } private static func replaceCSS(useLatinVariant: Bool) -> String { let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS return RDEPUBAssetRepository.string(for: asset) } private static func inlineStyleCSS(in html: String) -> String { guard let regex = try? NSRegularExpression(pattern: #"]*>([\s\S]*?)"#, options: [.caseInsensitive]) else { return "" } let nsHTML = html as NSString return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length)) .compactMap { match in guard match.numberOfRanges > 1 else { return nil } return nsHTML.substring(with: match.range(at: 1)) } .joined(separator: "\n") } private static func registerEmbeddedFonts( in css: String, chapterHref: String, resourceResolver: RDEPUBResourceResolver? ) { guard let resourceResolver, let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]), let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else { return } let nsCSS = css as NSString for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) { guard faceMatch.numberOfRanges > 1 else { continue } let block = nsCSS.substring(with: faceMatch.range(at: 1)) let nsBlock = block as NSString for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) { guard urlMatch.numberOfRanges > 1 else { continue } let rawReference = nsBlock.substring(with: urlMatch.range(at: 1)) .trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t")) guard !rawReference.isEmpty, !rawReference.hasPrefix("data:"), !rawReference.hasPrefix("http://"), !rawReference.hasPrefix("https://"), let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else { continue } registerFontIfNeeded(at: fileURL) } } } private static func registerFontIfNeeded(at fileURL: URL) { let standardizedPath = fileURL.standardizedFileURL.path guard !registeredFontPaths.contains(standardizedPath) else { return } CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil) registeredFontPaths.insert(standardizedPath) } private static func normalizeAttachmentHTMLMarkers(in html: String) -> String { var normalized = html if let bodyPicContainerRegex = try? NSRegularExpression( pattern: #"]*class\s*=\s*["'][^"']*\b(?:qrbodyPic|bodyPic)\b[^"']*["'][^>]*)>([\s\S]*?)"#, options: [.caseInsensitive] ) { normalized = replaceMatches( using: bodyPicContainerRegex, in: normalized ) { tag in guard let imageTagRegex = try? NSRegularExpression(pattern: #"]*>"#, options: [.caseInsensitive]) else { return tag } return replaceMatches( using: imageTagRegex, in: tag ) { imageTag in mergeHTMLAttributes( into: imageTag, requiredClass: "bodyPic", styleFragments: [ "wr-vertical-center-style:2", "max-width:100%", "height:auto", "display:block", "margin-left:auto", "margin-right:auto" ] ) } } } if let footnoteRegex = try? NSRegularExpression( pattern: #"]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#, options: [.caseInsensitive] ) { normalized = replaceMatches( using: footnoteRegex, in: normalized ) { tag in mergeHTMLAttributes( into: tag, requiredClass: nil, styleFragments: [ "width:1em", "height:1em", "vertical-align:middle", "display:inline-block" ] ) } } if let coverRegex = try? NSRegularExpression( pattern: #"]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(]*>)\s*"#, options: [.caseInsensitive] ) { normalized = replaceMatches( using: coverRegex, in: normalized ) { tag in guard let imageTagRegex = try? NSRegularExpression(pattern: #"]*>"#, options: [.caseInsensitive]), let imageMatch = imageTagRegex.firstMatch( in: tag, options: [], range: NSRange(location: 0, length: (tag as NSString).length) ), let imageRange = Range(imageMatch.range, in: tag) else { return tag } let imageTag = String(tag[imageRange]) let normalizedImageTag = mergeHTMLAttributes( into: imageTag, requiredClass: "rd-front-cover-image", styleFragments: [ "display:block", "width:100%", "height:auto", "margin-left:auto", "margin-right:auto" ] ) return tag.replacingCharacters(in: imageRange, with: normalizedImageTag) } } return normalized } private static func replaceMatches( using regex: NSRegularExpression, in source: String, transform: (String) -> String ) -> String { let nsSource = source as NSString let matches = regex.matches(in: source, options: [], range: NSRange(location: 0, length: nsSource.length)) guard !matches.isEmpty else { return source } var rewritten = source for match in matches.reversed() { guard let range = Range(match.range, in: rewritten) else { continue } let original = String(rewritten[range]) rewritten.replaceSubrange(range, with: transform(original)) } return rewritten } private static func string(from size: CGSize) -> String { "{\(Int(round(size.width))), \(Int(round(size.height)))}" } private static func mergeHTMLAttributes( into tag: String, requiredClass: String?, styleFragments: [String] ) -> String { var rewritten = tag if let requiredClass { if let classRegex = try? NSRegularExpression(pattern: #"class\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]), let match = classRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)), match.numberOfRanges > 1 { let existingClasses = (rewritten as NSString).substring(with: match.range(at: 1)) if !existingClasses.localizedCaseInsensitiveContains(requiredClass) { let replacement = #"class="\#(existingClasses) \#(requiredClass)""# if let range = Range(match.range, in: rewritten) { rewritten.replaceSubrange(range, with: replacement) } } } else if let closing = rewritten.lastIndex(of: ">") { rewritten.insert(contentsOf: #" class="\#(requiredClass)""#, at: closing) } } let styleValue = styleFragments.joined(separator: ";") + ";" if let styleRegex = try? NSRegularExpression(pattern: #"style\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]), let match = styleRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)), match.numberOfRanges > 1 { let existing = (rewritten as NSString).substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines) let merged = existing.isEmpty ? styleValue : existing + (existing.hasSuffix(";") ? "" : ";") + styleValue let replacement = #"style="\#(merged)""# if let range = Range(match.range, in: rewritten) { rewritten.replaceSubrange(range, with: replacement) } } else if let closing = rewritten.lastIndex(of: ">") { rewritten.insert(contentsOf: #" style="\#(styleValue)""#, at: closing) } return rewritten } private static func normalizeAttachmentDisplayIfNeeded( in attributes: inout [NSAttributedString.Key: Any], font: UIFont ) { guard let attachment = attributes[.attachment] else { return } #if canImport(DTCoreText) if let textAttachment = attachment as? DTTextAttachment { normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize) attributes[.attachment] = textAttachment return } #endif if let textAttachment = attachment as? NSTextAttachment, textAttachment.bounds.height <= 0 { let targetHeight = max(round(font.pointSize * 0.86), 1) textAttachment.bounds = CGRect(x: 0, y: 0, width: targetHeight, height: targetHeight) attributes[.attachment] = textAttachment } } 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 RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """ 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 prefersLatinLanguageCSS( languageCode: String?, sourceHTML: String ) -> Bool { let candidateCodes = inferredLanguageCodes( explicitLanguageCode: languageCode, sourceHTML: sourceHTML ) if candidateCodes.contains(where: isExplicitLatinLanguageCode) { return true } if candidateCodes.contains(where: isExplicitCJKLanguageCode) { return false } let textSample = plainTextSample(from: sourceHTML) guard !textSample.isEmpty else { return false } var alphabeticCount = 0 var latinCount = 0 for scalar in textSample.unicodeScalars { guard CharacterSet.letters.contains(scalar) else { continue } alphabeticCount += 1 if isLatinScalar(scalar) { latinCount += 1 } } guard alphabeticCount >= 80 else { return false } return (Double(latinCount) / Double(alphabeticCount)) >= 0.6 } private static func inferredLanguageCodes( explicitLanguageCode: String?, sourceHTML: String ) -> [String] { var codes: [String] = [] if let explicitLanguageCode { let normalized = explicitLanguageCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if !normalized.isEmpty { codes.append(normalized) } } if let regex = try? NSRegularExpression( pattern: #"\b(?:xml:lang|lang)\s*=\s*["']([^"']+)["']"#, options: [.caseInsensitive] ) { let nsHTML = sourceHTML as NSString let range = NSRange(location: 0, length: min(nsHTML.length, 8_000)) for match in regex.matches(in: sourceHTML, options: [], range: range) { guard match.numberOfRanges > 1 else { continue } let code = nsHTML.substring(with: match.range(at: 1)) .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() if !code.isEmpty { codes.append(code) } } } return Array(NSOrderedSet(array: codes)) as? [String] ?? codes } private static func isExplicitLatinLanguageCode(_ code: String) -> Bool { let normalized = code.lowercased() if normalized.contains("latn") { return true } let prefix = normalized.split(separator: "-").first.map(String.init) ?? normalized let latinPrefixes: Set = [ "en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi", "is", "ga", "cy", "pl", "cs", "sk", "sl", "hr", "hu", "ro", "tr", "vi", "id", "ms", "tl", "sw", "af", "sq", "et", "lv", "lt" ] return latinPrefixes.contains(prefix) } private static func isExplicitCJKLanguageCode(_ code: String) -> Bool { let prefix = code.lowercased().split(separator: "-").first.map(String.init) ?? code.lowercased() return ["zh", "ja", "ko"].contains(prefix) } private static func plainTextSample(from html: String) -> String { let maxLength = min(html.count, 20_000) let sample = String(html.prefix(maxLength)) let withoutTags = sample.replacingOccurrences( of: #"<[^>]+>"#, with: " ", options: .regularExpression ) return withoutTags.replacingOccurrences( of: #"&[A-Za-z0-9#]+;"#, with: " ", options: .regularExpression ) } private static func isLatinScalar(_ scalar: UnicodeScalar) -> Bool { switch scalar.value { case 0x0041...0x007A, 0x00C0...0x00FF, 0x0100...0x024F, 0x1E00...0x1EFF: return true default: return false } } private static func injectBaseHref(into html: String, baseURL: URL?) -> String { guard let baseURL else { return html } let baseTag = "" if html.range(of: "", options: [.caseInsensitive]) != nil { return html.replacingOccurrences(of: "", with: "\n\(baseTag)", options: [.caseInsensitive]) } if let htmlTagRange = html.range(of: "", range: htmlTagRange.lowerBound..\n\(baseTag)\n") } return "\n\(baseTag)\n\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 = "" switch position { case .headStart: if html.range(of: "", options: [.caseInsensitive]) != nil { return html.replacingOccurrences(of: "", with: "\n\(styleTag)", options: [.caseInsensitive]) } case .headEnd: if html.range(of: "", options: [.caseInsensitive]) != nil { return html.replacingOccurrences(of: "", with: "\(styleTag)\n", options: [.caseInsensitive]) } } if html.range(of: " UIFont { guard let sourceFont else { return baseFont } let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic]) if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) { return UIFont(descriptor: descriptor, size: baseFont.pointSize) } return baseFont } private static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle { let style = NSMutableParagraphStyle() style.lineSpacing = lineSpacing 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 } private static func injectPaginationSemanticMarkers(into html: String) -> String { guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, 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 output = "" var cursor = 0 var openTagStack: [(name: String, id: String)] = [] var nextMarkerID = 0 for match in matches { let tagRange = match.range guard tagRange.location >= cursor else { continue } output += nsHTML.substring(with: NSRange(location: cursor, length: tagRange.location - cursor)) let tag = nsHTML.substring(with: tagRange) let loweredTag = tag.lowercased() let tagName = htmlTagName(from: loweredTag) if loweredTag.hasPrefix("") { output += startMarker + tag + semanticEndMarker(id: markerID) } else { openTagStack.append((name: tagName, id: markerID)) output += tag + startMarker } } else { output += tag } cursor = tagRange.location + tagRange.length } output += nsHTML.substring(from: cursor) return output } private static func htmlTagName(from loweredTag: String) -> String? { let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.hasPrefix("<") else { return nil } let body = trimmed.dropFirst().drop(while: { $0 == "/" || $0 == "!" || $0 == "?" }) let name = body.prefix { $0.isLetter || $0.isNumber } return name.isEmpty ? nil : String(name) } private static func paginationSemantics(forTagName tagName: String, rawTag: String) -> RDPaginationSemantics? { let loweredTag = rawTag.lowercased() let blockKind = inferredBlockKind(forTagName: tagName, rawTag: loweredTag) let hints = inferredHints(forTagName: tagName, rawTag: loweredTag) let placement = inferredAttachmentPlacement(forTagName: tagName, rawTag: loweredTag) guard blockKind != nil || !hints.isEmpty || placement != nil else { return nil } return RDPaginationSemantics( id: "", blockKind: blockKind, hints: hints, attachmentPlacement: placement ) } private static func inferredBlockKind(forTagName tagName: String, rawTag: String) -> RDEPUBTextBlockKind? { if rawTag.contains("bodypic") || tagName == "img" || tagName == "figure" { return .attachment } switch tagName { case "h1", "h2", "h3", "h4", "h5", "h6": return .generic case "blockquote": return .blockquote case "ul", "ol", "li": return .list case "table", "thead", "tbody", "tfoot", "tr", "td", "th": return .table case "pre", "code": return .code case "p": return .paragraph case "div": if rawTag.contains("code") || rawTag.contains("highlight") { return .code } if rawTag.contains("quote") || rawTag.contains("blockquote") { return .blockquote } if rawTag.contains("table") { return .table } if rawTag.contains("list") { return .list } return .generic default: return nil } } private static func inferredHints(forTagName tagName: String, rawTag: String) -> [RDEPUBTextSemanticHint] { var hints: [RDEPUBTextSemanticHint] = [] if rawTag.contains("avoidpagebreakinside") || rawTag.contains("break-inside: avoid") || rawTag.contains("page-break-inside: avoid") || ["blockquote", "pre", "code", "table", "ul", "ol", "figure", "img"].contains(tagName) { hints.append(.avoidPageBreakInside) } if ["h1", "h2", "h3", "h4", "h5", "h6"].contains(tagName) || rawTag.contains("subhead") || rawTag.contains("firsttitle") || rawTag.contains("secondtitle") || rawTag.contains("thirdtitle") || rawTag.contains("fourthtitle") || rawTag.contains("fifthtitle") || rawTag.contains("sixthtitle") { hints.append(.keepWithNext) } if rawTag.contains("pagebreakbefore") || rawTag.contains("page-break-before: always") || rawTag.contains("break-before: page") { hints.append(.pageBreakBefore) } if rawTag.contains("pagebreakafter") || rawTag.contains("page-break-after: always") || rawTag.contains("break-after: page") { hints.append(.pageBreakAfter) } if rawTag.contains("pageRelate".lowercased()) || rawTag.contains("weread-page-relate") { hints.append(.pageRelate) } return hints .reduce(into: [RDEPUBTextSemanticHint]()) { result, hint in if !result.contains(hint) { result.append(hint) } } .sorted { $0.rawValue < $1.rawValue } } private static func inferredAttachmentPlacement(forTagName tagName: String, rawTag: String) -> RDEPUBTextAttachmentPlacement? { guard tagName == "img" || rawTag.contains("bodypic") || rawTag.contains("wr-vertical-center") else { return nil } if rawTag.contains("wr-vertical-center-style: 2") || rawTag.contains("bodypic") { return .centered } if rawTag.contains("wr-vertical-center-style: 1") || rawTag.contains("wr-vertical-center") { return .baseline } return .inline } private static func isVoidHTMLTag(_ tagName: String) -> Bool { ["img", "br", "hr", "input", "meta", "link"].contains(tagName) } private static func semanticStartMarker(id: String, semantics: RDPaginationSemantics) -> String { var segments = ["id=\(id)"] if let blockKind = semantics.blockKind { segments.append("block=\(blockKind.rawValue)") } if !semantics.hints.isEmpty { segments.append("hints=\(semantics.hints.map(\.rawValue).joined(separator: ","))") } if let placement = semantics.attachmentPlacement { segments.append("placement=\(placement.rawValue)") } return "${rd-sem-start:\(segments.joined(separator: ";"))}" } private static func semanticEndMarker(id: String) -> String { "${rd-sem-end:id=\(id)}" } private static func parseSemanticMarkerPayload(_ payload: String) -> RDPaginationSemantics { var values: [String: String] = [:] payload.split(separator: ";").forEach { entry in let parts = entry.split(separator: "=", maxSplits: 1) guard parts.count == 2 else { return } values[String(parts[0])] = String(parts[1]) } let blockKind = values["block"].flatMap(RDEPUBTextBlockKind.init(rawValue:)) let hints = values["hints"]? .split(separator: ",") .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } ?? [] let placement = values["placement"].flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:)) return RDPaginationSemantics( id: values["id"] ?? UUID().uuidString, blockKind: blockKind, hints: hints, attachmentPlacement: placement ) } private static func parseSemanticEndID(_ payload: String) -> String? { payload.split(separator: ";").first { $0.hasPrefix("id=") }.map { String($0.dropFirst(3)) } } private static func apply( semantics: RDPaginationSemantics, to range: NSRange, in attributedString: NSMutableAttributedString ) { var attributes: [NSAttributedString.Key: Any] = [:] attributes[.rdPageBlockRange] = NSStringFromRange(range) if let blockKind = semantics.blockKind { attributes[.rdPageBlockKind] = blockKind.rawValue } if !semantics.hints.isEmpty { attributes[.rdPageSemanticHints] = semantics.hints.map(\.rawValue).joined(separator: ",") } if let placement = semantics.attachmentPlacement { attributes[.rdPageAttachmentPlacement] = placement.rawValue } guard !attributes.isEmpty else { return } attributedString.addAttributes(attributes, range: range) } private static func normalizeBlockKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextBlockKind? { if let rawValue = attributes[.rdPageBlockKind] as? String, let blockKind = RDEPUBTextBlockKind(rawValue: rawValue) { return blockKind } return nil } private static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? { if let rawValue = attributes[.rdPageSemanticHints] as? String { let hints = rawValue .split(separator: ",") .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } return hints.isEmpty ? nil : hints } return nil } private static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? { if let rawValue = attributes[.rdPageAttachmentPlacement] as? String, let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) { return placement } if let attachmentKind = attachmentKind(for: attributes), attachmentKind == .image { return .inline } return nil } private struct RDPaginationSemantics { var id: String var blockKind: RDEPUBTextBlockKind? var hints: [RDEPUBTextSemanticHint] var attachmentPlacement: RDEPUBTextAttachmentPlacement? } #if canImport(DTCoreText) static func normalizeAttachmentLayoutForWXRead( _ attachment: DTTextAttachment, fontPointSize: CGFloat, maxImageSize: CGSize? = nil ) { let pointSize = max(fontPointSize, 1) let originalSize = attachment.originalSize if isFootnoteAttachment(attachment) { let targetWidth = max(round(pointSize), 1) let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1 let targetHeight = max(round(targetWidth / max(aspectRatio, 0.1)), 1) attachment.displaySize = CGSize(width: targetWidth, height: targetHeight) attachment.verticalAlignment = .baseline if !didLogFootnoteAttachment { didLogFootnoteAttachment = true print("[EPUB][Attachment] footnote original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize)) font=\(pointSize)") } return } if isCoverAttachment(attachment) { let maxSize = maxImageSize ?? CGSize(width: round(UIScreen.main.bounds.width - 40), height: round(UIScreen.main.bounds.height - 56)) if originalSize.width > 0, originalSize.height > 0 { let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height) attachment.displaySize = CGSize( width: round(originalSize.width * scale), height: round(originalSize.height * scale) ) } else { attachment.displaySize = maxSize } attachment.verticalAlignment = .baseline if !didLogCoverAttachment { didLogCoverAttachment = true print("[EPUB][Attachment] cover original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize))") } return } var resolvedSize = attachment.displaySize if resolvedSize.width <= 0 || resolvedSize.height <= 0 { resolvedSize = originalSize } if resolvedSize.width <= 0 || resolvedSize.height <= 0 { resolvedSize = CGSize(width: pointSize, height: pointSize) } if let maxImageSize, resolvedSize.width > 0, resolvedSize.height > 0, (resolvedSize.width > maxImageSize.width || resolvedSize.height > maxImageSize.height) { let scale = min(maxImageSize.width / resolvedSize.width, maxImageSize.height / resolvedSize.height) resolvedSize = CGSize( width: round(resolvedSize.width * scale), height: round(resolvedSize.height * scale) ) } attachment.displaySize = CGSize(width: round(resolvedSize.width), height: round(resolvedSize.height)) attachment.verticalAlignment = .center } private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool { let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased() let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased() ?? ((attachment.attributes["src"] as? String) ?? "").lowercased() return lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" } private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool { let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased() let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased() ?? ((attachment.attributes["src"] as? String) ?? "").lowercased() return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg" } #endif }