1261 lines
53 KiB
Swift
1261 lines
53 KiB
Swift
import UIKit
|
||
import CoreText
|
||
|
||
#if canImport(DTCoreText)
|
||
import DTCoreText
|
||
#endif
|
||
|
||
/// 渲染器支持工具集:负责 HTML 预处理、fragment 标记注入/提取、CSS 组装和字体标准化。
|
||
///
|
||
/// 本枚举是渲染链路的预处理层,`RDEPUBTextBookBuilder` 在调用渲染器前通过
|
||
/// `makeChapterRenderRequest()` 完成 HTML 的全部预处理工作。
|
||
///
|
||
/// 核心职责:
|
||
/// - HTML 规范化:清理冗余字符、规范化附件标记
|
||
/// - Fragment 标记注入/提取:id 属性 → ${id=xxx} 标记 → 字符偏移量映射
|
||
/// - 语义标记注入:HTML 标签 → ${rd-sem-start/end} 标记 → NSAttributedString 属性
|
||
/// - CSS 组装:按优先级合并 default/replace/dark/epub/user 五层样式表
|
||
/// - 字体标准化:将 EPUB 原始字体映射到用户设置的字体
|
||
/// - 附件规范化:图片尺寸、脚注图标、封面图片等特殊处理
|
||
enum RDEPUBTextRendererSupport {
|
||
/// 外部样式表链接正则
|
||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
||
/// 图片源地址正则
|
||
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
||
/// 语义标记正则(${rd-sem-start:...} / ${rd-sem-end:...})
|
||
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
||
/// 脚注附件日志已输出标记(避免重复日志)
|
||
private static var didLogFootnoteAttachment = false
|
||
/// 封面附件日志已输出标记(避免重复日志)
|
||
private static var didLogCoverAttachment = false
|
||
/// 已注册字体资源,避免重复调用 CTFontManager。
|
||
private static var registeredFontPaths = Set<String>()
|
||
|
||
// MARK: - Fragment 标记注入/提取
|
||
|
||
/// 将 HTML 中的 id 属性元素注入 fragment 标记。
|
||
///
|
||
/// 将 `<tag id="xxx" ...>` 转换为 `${id=xxx}<tag id="xxx" ...>`,
|
||
/// 使 DTCoreText 渲染后能在富文本中定位到 fragment 偏移量。
|
||
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"
|
||
)
|
||
}
|
||
|
||
/// 从渲染后的富文本中提取 fragment 偏移量映射。
|
||
///
|
||
/// 扫描 `${id=xxx}` 标记,记录每个 fragment ID 的字符偏移量,
|
||
/// 然后删除标记文本,返回 [fragmentID: offset] 字典。
|
||
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
|
||
}
|
||
|
||
/// 将 HTML 中注入的语义标记(${rd-sem-start/end})应用到富文本属性中。
|
||
///
|
||
/// 标记包含 blockKind、semanticHints、attachmentPlacement 等信息,
|
||
/// 解析后写入对应的 .rdPage* 属性键,供分页引擎读取。
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
|
||
///
|
||
/// 遍历富文本的所有属性区间:
|
||
/// 1. 将 EPUB 原始字体映射到用户设置字体(保留粗体/斜体特征)
|
||
/// 2. 统一行距和段间距
|
||
/// 3. 应用用户设置的文本颜色
|
||
/// 4. 规范化附件显示尺寸
|
||
/// 5. 注入块级元素范围、序号、类型等分页语义属性
|
||
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
|
||
}
|
||
}
|
||
|
||
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理。
|
||
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)
|
||
}
|
||
|
||
/// 核心预处理方法:将原始 HTML 转换为完整的章节渲染请求。
|
||
///
|
||
/// 预处理流程:
|
||
/// 1. normalizeHTML:清理冗余字符、规范化附件 HTML 标记
|
||
/// 2. injectPaginationSemanticMarkers:为 HTML 标签注入语义标记
|
||
/// 3. inlineLinkedStyleSheets:将外部样式表内联到 HTML 中
|
||
/// 4. 组装五层 CSS(default/replace/dark/epub/user)并注入 style 标签
|
||
/// 5. injectBaseHref:注入 base 标签以解析相对路径
|
||
/// 6. injectFragmentMarkers:注入 fragment 锚点标记
|
||
/// 7. collectImageDiagnostics:收集图片资源引用诊断
|
||
static func makeChapterRenderRequest(
|
||
href: String,
|
||
title: String,
|
||
rawHTML: String,
|
||
baseURL: URL?,
|
||
style: RDEPUBTextRenderStyle,
|
||
resourceResolver: RDEPUBResourceResolver?,
|
||
contentLanguageCode: String? = nil
|
||
) -> 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,
|
||
contentLanguageCode: contentLanguageCode,
|
||
sourceHTML: rawHTML
|
||
)
|
||
registerEmbeddedFonts(
|
||
in: stylesheetHrefReplacements.inlinedCSS + "\n" + inlineStyleCSS(in: normalizedHTML),
|
||
chapterHref: href,
|
||
resourceResolver: resourceResolver
|
||
)
|
||
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)
|
||
}
|
||
|
||
/// HTML 规范化:清理冗余字符(CR、多余换行),规范化附件 HTML 标记
|
||
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,
|
||
contentLanguageCode: String?,
|
||
sourceHTML: String
|
||
) -> [RDEPUBTextStyleSheetLayer] {
|
||
let useLatinReplace = prefersLatinLanguageCSS(
|
||
languageCode: contentLanguageCode,
|
||
sourceHTML: sourceHTML
|
||
)
|
||
var layers: [RDEPUBTextStyleSheetLayer] = [
|
||
.init(kind: .default, css: defaultCSS()),
|
||
.init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace))
|
||
]
|
||
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 {
|
||
RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
|
||
}
|
||
|
||
private static func replaceCSS(useLatinVariant: Bool) -> String {
|
||
let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS
|
||
return RDEPUBAssetRepository.string(for: asset)
|
||
}
|
||
|
||
private static func inlineStyleCSS(in html: String) -> String {
|
||
guard let regex = try? NSRegularExpression(pattern: #"<style\b[^>]*>([\s\S]*?)</style>"#, options: [.caseInsensitive]) else {
|
||
return ""
|
||
}
|
||
let nsHTML = html as NSString
|
||
return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
|
||
.compactMap { match in
|
||
guard match.numberOfRanges > 1 else { return nil }
|
||
return nsHTML.substring(with: match.range(at: 1))
|
||
}
|
||
.joined(separator: "\n")
|
||
}
|
||
|
||
private static func registerEmbeddedFonts(
|
||
in css: String,
|
||
chapterHref: String,
|
||
resourceResolver: RDEPUBResourceResolver?
|
||
) {
|
||
guard let resourceResolver,
|
||
let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]),
|
||
let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
|
||
return
|
||
}
|
||
|
||
let nsCSS = css as NSString
|
||
for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) {
|
||
guard faceMatch.numberOfRanges > 1 else { continue }
|
||
let block = nsCSS.substring(with: faceMatch.range(at: 1))
|
||
let nsBlock = block as NSString
|
||
for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) {
|
||
guard urlMatch.numberOfRanges > 1 else { continue }
|
||
let rawReference = nsBlock.substring(with: urlMatch.range(at: 1))
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t"))
|
||
guard !rawReference.isEmpty,
|
||
!rawReference.hasPrefix("data:"),
|
||
!rawReference.hasPrefix("http://"),
|
||
!rawReference.hasPrefix("https://"),
|
||
let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else {
|
||
continue
|
||
}
|
||
registerFontIfNeeded(at: fileURL)
|
||
}
|
||
}
|
||
}
|
||
|
||
private static func registerFontIfNeeded(at fileURL: URL) {
|
||
let standardizedPath = fileURL.standardizedFileURL.path
|
||
guard !registeredFontPaths.contains(standardizedPath) else { return }
|
||
CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
|
||
registeredFontPaths.insert(standardizedPath)
|
||
}
|
||
|
||
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 RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
|
||
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 prefersLatinLanguageCSS(
|
||
languageCode: String?,
|
||
sourceHTML: String
|
||
) -> Bool {
|
||
let candidateCodes = inferredLanguageCodes(
|
||
explicitLanguageCode: languageCode,
|
||
sourceHTML: sourceHTML
|
||
)
|
||
|
||
if candidateCodes.contains(where: isExplicitLatinLanguageCode) {
|
||
return true
|
||
}
|
||
if candidateCodes.contains(where: isExplicitCJKLanguageCode) {
|
||
return false
|
||
}
|
||
|
||
let textSample = plainTextSample(from: sourceHTML)
|
||
guard !textSample.isEmpty else { return false }
|
||
|
||
var alphabeticCount = 0
|
||
var latinCount = 0
|
||
for scalar in textSample.unicodeScalars {
|
||
guard CharacterSet.letters.contains(scalar) else { continue }
|
||
alphabeticCount += 1
|
||
if isLatinScalar(scalar) {
|
||
latinCount += 1
|
||
}
|
||
}
|
||
|
||
guard alphabeticCount >= 80 else { return false }
|
||
return (Double(latinCount) / Double(alphabeticCount)) >= 0.6
|
||
}
|
||
|
||
private static func inferredLanguageCodes(
|
||
explicitLanguageCode: String?,
|
||
sourceHTML: String
|
||
) -> [String] {
|
||
var codes: [String] = []
|
||
if let explicitLanguageCode {
|
||
let normalized = explicitLanguageCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
if !normalized.isEmpty {
|
||
codes.append(normalized)
|
||
}
|
||
}
|
||
|
||
if let regex = try? NSRegularExpression(
|
||
pattern: #"\b(?:xml:lang|lang)\s*=\s*["']([^"']+)["']"#,
|
||
options: [.caseInsensitive]
|
||
) {
|
||
let nsHTML = sourceHTML as NSString
|
||
let range = NSRange(location: 0, length: min(nsHTML.length, 8_000))
|
||
for match in regex.matches(in: sourceHTML, options: [], range: range) {
|
||
guard match.numberOfRanges > 1 else { continue }
|
||
let code = nsHTML.substring(with: match.range(at: 1))
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.lowercased()
|
||
if !code.isEmpty {
|
||
codes.append(code)
|
||
}
|
||
}
|
||
}
|
||
|
||
return Array(NSOrderedSet(array: codes)) as? [String] ?? codes
|
||
}
|
||
|
||
private static func isExplicitLatinLanguageCode(_ code: String) -> Bool {
|
||
let normalized = code.lowercased()
|
||
if normalized.contains("latn") {
|
||
return true
|
||
}
|
||
|
||
let prefix = normalized.split(separator: "-").first.map(String.init) ?? normalized
|
||
let latinPrefixes: Set<String> = [
|
||
"en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi",
|
||
"is", "ga", "cy", "pl", "cs", "sk", "sl", "hr", "hu", "ro", "tr",
|
||
"vi", "id", "ms", "tl", "sw", "af", "sq", "et", "lv", "lt"
|
||
]
|
||
return latinPrefixes.contains(prefix)
|
||
}
|
||
|
||
private static func isExplicitCJKLanguageCode(_ code: String) -> Bool {
|
||
let prefix = code.lowercased().split(separator: "-").first.map(String.init) ?? code.lowercased()
|
||
return ["zh", "ja", "ko"].contains(prefix)
|
||
}
|
||
|
||
private static func plainTextSample(from html: String) -> String {
|
||
let maxLength = min(html.count, 20_000)
|
||
let sample = String(html.prefix(maxLength))
|
||
let withoutTags = sample.replacingOccurrences(
|
||
of: #"<[^>]+>"#,
|
||
with: " ",
|
||
options: .regularExpression
|
||
)
|
||
return withoutTags.replacingOccurrences(
|
||
of: #"&[A-Za-z0-9#]+;"#,
|
||
with: " ",
|
||
options: .regularExpression
|
||
)
|
||
}
|
||
|
||
private static func isLatinScalar(_ scalar: UnicodeScalar) -> Bool {
|
||
switch scalar.value {
|
||
case 0x0041...0x007A,
|
||
0x00C0...0x00FF,
|
||
0x0100...0x024F,
|
||
0x1E00...0x1EFF:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
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 "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
|
||
}
|