1070 lines
44 KiB
Swift
1070 lines
44 KiB
Swift
import UIKit
|
|
|
|
#if canImport(DTCoreText)
|
|
import DTCoreText
|
|
#endif
|
|
|
|
enum RDEPUBTextRendererSupport {
|
|
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
|
private static let imageSourcePattern = #"<img\b[^>]*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)] = [
|
|
(#"<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
|
|
)
|
|
}
|
|
}
|
|
|
|
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: #"<img\b([^>]*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: #"<h1\b([^>]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(<img\b[^>]*>)\s*</h1>"#,
|
|
options: [.caseInsensitive]
|
|
) {
|
|
normalized = replaceMatches(
|
|
using: coverRegex,
|
|
in: normalized
|
|
) { tag in
|
|
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, 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 = "<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
|
|
}
|
|
|
|
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("</"), let tagName {
|
|
if let index = openTagStack.lastIndex(where: { $0.name == tagName }) {
|
|
let markerID = openTagStack.remove(at: index).id
|
|
output += semanticEndMarker(id: markerID)
|
|
}
|
|
output += tag
|
|
} else if let tagName,
|
|
let semantics = paginationSemantics(forTagName: tagName, rawTag: tag) {
|
|
nextMarkerID += 1
|
|
let markerID = String(nextMarkerID)
|
|
let startMarker = semanticStartMarker(id: markerID, semantics: semantics)
|
|
if isVoidHTMLTag(tagName) || loweredTag.hasSuffix("/>") {
|
|
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 "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 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
|
|
}
|