486 lines
19 KiB
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
|
|
}
|
|
}
|