ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift

486 lines
19 KiB
Swift

import UIKit
enum RDEPUBTextRendererSupport {
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
private static let imageSourcePattern = #"<img\b[^>]*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)] = [
(#"<hr\s+lang="zh-CN"></hr>"#, ""),
(#"\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 = "<base href=\"\(baseURL.absoluteString)\">"
if html.range(of: "<base ", options: [.caseInsensitive]) != nil {
return html
}
if html.range(of: "<head>", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "<head>", with: "<head>\n\(baseTag)", options: [.caseInsensitive])
}
if let htmlTagRange = html.range(of: "<html", options: [.caseInsensitive]),
let htmlRange = html.range(of: ">", range: htmlTagRange.lowerBound..<html.endIndex) {
return html.replacingCharacters(in: htmlRange.upperBound..<htmlRange.upperBound, with: "\n<head>\n\(baseTag)\n</head>")
}
return "<head>\n\(baseTag)\n</head>\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 = "<style id=\"\(styleID)\">\n\(trimmedCSS)\n</style>"
switch position {
case .headStart:
if html.range(of: "<head>", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "<head>", with: "<head>\n\(styleTag)", options: [.caseInsensitive])
}
case .headEnd:
if html.range(of: "</head>", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "</head>", with: "\(styleTag)\n</head>", options: [.caseInsensitive])
}
}
if html.range(of: "<body", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "<body", with: "\(styleTag)\n<body", options: [.caseInsensitive])
}
return styleTag + "\n" + html
}
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
}
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
}
}