feat(epub): align text rendering with WXRead

This commit is contained in:
shen
2026-05-24 09:56:32 +08:00
parent 5125b8de51
commit a318c0e3d0
13 changed files with 1556 additions and 135 deletions
@@ -88,6 +88,7 @@ enum RDEPUBTextRendererSupport {
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)
@@ -102,7 +103,13 @@ enum RDEPUBTextRendererSupport {
updatedAttributes[.foregroundColor] = textColor
}
normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont)
updatedAttributes[.rdPageBlockRange] = NSStringFromRange(range)
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
@@ -219,45 +226,16 @@ enum RDEPUBTextRendererSupport {
style: RDEPUBTextRenderStyle
) {
guard let attachment = element.textAttachment else { return }
let lowercasedClasses = (((attachment.attributes["class"] as? String) ?? (element.attributes["class"] as? String) ?? "")).lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? (element.attributes["src"] as? String) ?? "").lowercased()
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
if lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" {
let targetHeight = max(round(pointSize * 0.54), 1)
let originalSize = attachment.originalSize
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
attachment.verticalAlignment = .center
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
if !didLogFootnoteAttachment {
didLogFootnoteAttachment = true
print("[EPUB][Attachment] footnote classes=\(lowercasedClasses) path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize)) font=\(pointSize)")
}
return
}
if lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg" {
let maxSize = UIScreen.main.bounds.insetBy(dx: 20, dy: 28).size
let originalSize = attachment.originalSize
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 = CGSize(width: round(maxSize.width), height: round(maxSize.height))
}
attachment.verticalAlignment = .baseline
} else if isCoverAttachment(attachment) {
element.displayStyle = .block
if !didLogCoverAttachment {
didLogCoverAttachment = true
print("[EPUB][Attachment] cover classes=\(lowercasedClasses) path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize))")
}
}
}
#endif
@@ -472,26 +450,7 @@ enum RDEPUBTextRendererSupport {
#if canImport(DTCoreText)
if let textAttachment = attachment as? DTTextAttachment {
let lowercasedClasses = ((textAttachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = textAttachment.contentURL?.lastPathComponent.lowercased() ?? ""
if lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" {
let targetHeight = max(round(font.pointSize * 0.14), 1)
let originalSize = textAttachment.originalSize
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1)
textAttachment.displaySize = CGSize(width: targetWidth, height: round(targetHeight))
textAttachment.verticalAlignment = .center
} else if lowercasedClasses.contains("rd-front-cover-image") {
let originalSize = textAttachment.originalSize
let maxWidth = max(UIScreen.main.bounds.width - 48, font.lineHeight * 8)
if originalSize.width > 0, originalSize.height > 0 {
let scaledHeight = round(originalSize.height * (maxWidth / originalSize.width))
textAttachment.displaySize = CGSize(width: round(maxWidth), height: scaledHeight)
} else {
textAttachment.displaySize = CGSize(width: round(maxWidth), height: round(maxWidth * 1.4))
}
textAttachment.verticalAlignment = .baseline
}
normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize)
attributes[.attachment] = textAttachment
return
}
@@ -887,7 +846,7 @@ enum RDEPUBTextRendererSupport {
if rawTag.contains("avoidpagebreakinside") ||
rawTag.contains("break-inside: avoid") ||
rawTag.contains("page-break-inside: avoid") ||
["blockquote", "pre", "code", "table", "ul", "ol"].contains(tagName) {
["blockquote", "pre", "code", "table", "ul", "ol", "figure", "img"].contains(tagName) {
hints.append(.avoidPageBreakInside)
}
if rawTag.contains("pagebreakbefore") ||
@@ -977,6 +936,7 @@ enum RDEPUBTextRendererSupport {
in attributedString: NSMutableAttributedString
) {
var attributes: [NSAttributedString.Key: Any] = [:]
attributes[.rdPageBlockRange] = NSStringFromRange(range)
if let blockKind = semantics.blockKind {
attributes[.rdPageBlockKind] = blockKind.rawValue
}
@@ -1025,4 +985,85 @@ enum RDEPUBTextRendererSupport {
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
}