import UIKit enum RDEPUBTextRendererSupport { 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) 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 } attributedString.setAttributes(updatedAttributes, range: range) } } 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) } private static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> 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 } }