import UIKit #if canImport(DTCoreText) import DTCoreText #endif enum RDEPUBTextRendererSupport { private static let stylesheetLinkPattern = #"]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"# private static let imageSourcePattern = #"]*src\s*=\s*["']([^"']+)["'][^>]*>"# private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"# private static var didLogFootnoteAttachment = false private static var didLogCoverAttachment = false 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 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) } } 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 } } 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 = injectPaginationSemanticMarkers(into: 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 ) } } cleanedHTML = normalizeAttachmentHTMLMarkers(in: cleanedHTML) return cleanedHTML } #if canImport(DTCoreText) static func prepareHTMLElementForReaderRendering( _ element: DTHTMLElement, style: RDEPUBTextRenderStyle ) { guard let attachment = element.textAttachment else { return } let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize) let maxSize = 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: maxSize) if isFootnoteAttachment(attachment) { element.displayStyle = .inline } else if isCoverAttachment(attachment) { element.displayStyle = .block } } #endif 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 { max-width: 100%; height: auto; } .frontCover, .bodyPic, .qrbodyPic, figure { text-align: center; text-indent: 0; } .frontCover img, .rd-front-cover-image, .bodyPic img, .qrbodyPic img, figure img { display: block; margin-left: auto; margin-right: auto; } .qqreader-footnote, .s-pic, .h-pic, .g-pic { display: inline; vertical-align: middle; } 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 normalizeAttachmentHTMLMarkers(in html: String) -> String { var normalized = html 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 """ 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 } 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 }