import UIKit enum RDEPUBTextRendererSupport { private static let stylesheetLinkPattern = #"]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"# private static let imageSourcePattern = #"]*src\s*=\s*["']([^"']+)["'][^>]*>"# 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" ) } 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 } 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) paragraph.lineSpacing = style.lineSpacing paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2) var updatedAttributes = attributes updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: style.font) updatedAttributes[.paragraphStyle] = paragraph 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 } } 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) } 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)] = [ (#"分页符"#, ""), (#"\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 = "" 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 } }