fix: 修复右对齐短文本显示不完整及多项功能改进

右对齐文本显示修复(如"巫鸿"只显示"鸿"的问题):
- RDEPUBChapterTailNormalizer: 短尾页合并时检查 avoidPageBreakInside 语义,
  避免将带有此标记的短文本(如 right-info 署名)错误合并回上一页
- RDEPUBTextContentView: 续段归一化时跳过右对齐/居中对齐的段落,
  防止错误修改 firstLineHeadIndent 导致首字不可见
- RDEPUBCSSCompatibilityLayer: 为含 text-align:right/center 的 CSS 块
  自动注入 text-indent:0 !important,确保右对齐/居中文本无首行缩进
- RDEPUBTextRendererSupport: 排版属性归一化时将右对齐/居中段落的
  firstLineHeadIndent 重置为 0

图片查看器及脚注点击功能:
- 新增 RDEPUBImageViewController 和 RDEPUBImageViewerCoordinator,
  支持从 WebView 和 TextPage 两种模式查看图片
- epub-bridge.js: 检测图片和脚注图片的点击事件,脚注图片显示弹窗
- RDEPUBJavaScriptBridge: 新增 imageDidTap/footnoteDidTap 桥接消息
- RDEPUBWebView: 新增图片和脚注点击的 delegate 方法
- RDEPUBAttachmentNormalizer: 改进脚注检测,优先使用 alt 文本判断
- RDEPUBPaginationModels: 新增 .footnote 附件类型
- RDEPUBPageLayoutSnapshot: 运行时动态解析附件类型,脚注优先级高于图片

位置解析改进:
- RDEPUBReaderController+LocationResolution: 利用 rangeInfo 提升页码定位精度

其他:
- RDEPUBTextBookCache: schema 版本升级至 13
- RDEPUBReaderTheme: 主题更新
- Pod 项目文件更新

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-25 15:39:58 +08:00
co-authored by Claude
parent d15f20b097
commit d7f28c3f10
21 changed files with 2649 additions and 1933 deletions
@@ -91,10 +91,42 @@ struct RDEPUBChapterTailNormalizer {
return false
}
guard !frameHasAvoidPageBreakInside(trailingFrame, in: content) else {
return false
}
let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
return previousVisibleCount >= max(visibleCount * 8, 12)
}
private func frameHasAvoidPageBreakInside(
_ frame: RDEPUBTextLayoutFrame,
in content: NSAttributedString
) -> Bool {
if frame.semanticHints.contains(.avoidPageBreakInside) {
return true
}
guard content.length > 0,
let safeRange = clampedRange(frame.contentRange, in: content),
safeRange.length > 0 else {
return false
}
var found = false
content.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, stop in
guard let rawValue = value as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
if hints.contains(.avoidPageBreakInside) {
found = true
stop.pointee = true
}
}
return found
}
private func mergeTrailingFrame(
_ previousFrame: RDEPUBTextLayoutFrame,
with trailingFrame: RDEPUBTextLayoutFrame
@@ -147,6 +179,13 @@ struct RDEPUBChapterTailNormalizer {
return count
}
private func clampedRange(_ range: NSRange, in content: NSAttributedString) -> NSRange? {
guard range.location >= 0, range.length >= 0, range.location < content.length else {
return nil
}
return NSRange(location: range.location, length: min(range.length, content.length - range.location))
}
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
values.reduce(into: [T]()) { result, value in
if !result.contains(value) {
@@ -104,7 +104,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
public final class RDEPUBTextBookCache {
public var schemaVersion: Int = 6
public var schemaVersion: Int = 13
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
@@ -30,6 +30,10 @@ struct RDEPUBCSSCompatibilityLayer {
normalized += result.normalizedRules
}
let alignmentIndentResult = addTextIndentResetForAlignedBlocks(in: rewritten)
rewritten = alignmentIndentResult.css
normalized += alignmentIndentResult.normalizedRules
if !policy.allowPublisherMargins {
let result = clampLengthProperty(
in: rewritten,
@@ -133,4 +137,57 @@ struct RDEPUBCSSCompatibilityLayer {
}
return (rewritten, normalized)
}
private func addTextIndentResetForAlignedBlocks(
in css: String
) -> (css: String, normalizedRules: [String]) {
guard let regex = try? NSRegularExpression(
pattern: #"(?is)([^{}]+)\{([^{}]*)\}"#
) else {
return (css, [])
}
let nsCSS = css as NSString
var rewritten = css
var normalized: [String] = []
for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
guard match.numberOfRanges > 2 else { continue }
let selector = nsCSS.substring(with: match.range(at: 1))
let declarations = nsCSS.substring(with: match.range(at: 2))
guard declaresRightOrCenterAlignment(declarations),
!declaresProperty("text-indent", in: declarations) else {
continue
}
let replacement = "\(selector){\(declarations)\n text-indent: 0 !important;\n}"
if let range = Range(match.range, in: rewritten) {
rewritten.replaceSubrange(range, with: replacement)
normalized.append("aligned-text-indent-reset")
}
}
return (rewritten, normalized)
}
private func declaresRightOrCenterAlignment(_ declarations: String) -> Bool {
guard let regex = try? NSRegularExpression(
pattern: #"(?i)\btext-align\s*:\s*(right|center)\b"#
) else {
return false
}
let range = NSRange(location: 0, length: (declarations as NSString).length)
return regex.firstMatch(in: declarations, range: range) != nil
}
private func declaresProperty(_ property: String, in declarations: String) -> Bool {
guard let regex = try? NSRegularExpression(
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:"#
) else {
return false
}
let range = NSRange(location: 0, length: (declarations as NSString).length)
return regex.firstMatch(in: declarations, range: range) != nil
}
}
@@ -142,10 +142,8 @@ struct RDEPUBAttachmentNormalizer {
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"
let altText = attachment.attributes["alt"] as? String
return lowercasedClasses.contains("qqreader-footnote") || hasFootnoteAltText(altText)
}
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
@@ -197,6 +195,10 @@ struct RDEPUBAttachmentNormalizer {
}
static func attachmentKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentKind? {
// Check for footnote attachments first they should show tooltip, not image viewer
if isFootnoteAttachmentInAttributes(attributes) {
return .footnote
}
if let attachment = attributes[.attachment] as? NSTextAttachment {
@@ -216,4 +218,22 @@ struct RDEPUBAttachmentNormalizer {
return nil
}
private static func isFootnoteAttachmentInAttributes(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
#if canImport(DTCoreText)
if let textAttachment = attributes[.attachment] as? DTTextAttachment {
return isFootnoteAttachment(textAttachment)
}
#endif
guard let fileAttachment = attributes[.attachment] as? NSTextAttachment else {
return false
}
let label = fileAttachment.accessibilityLabel
let lowercasedLabel = (label ?? "").lowercased()
return lowercasedLabel.contains("qqreader-footnote") || hasFootnoteAltText(label)
}
private static func hasFootnoteAltText(_ text: String?) -> Bool {
text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
}
@@ -29,16 +29,17 @@ enum RDEPUBTextRendererSupport {
baseURL: baseURL,
resourceResolver: resourceResolver
)
let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS)
let layers = RDEPUBStyleSheetComposer.makeStyleSheetLayers(
style: style,
epubCSS: stylesheetHrefReplacements.inlinedCSS,
epubCSS: compatibility.css,
contentLanguageCode: contentLanguageCode,
sourceHTML: rawHTML
)
RDEPUBFontNormalizer.registerEmbeddedFonts(
in: stylesheetHrefReplacements.inlinedCSS + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML),
in: compatibility.css + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML),
chapterHref: href,
resourceResolver: resourceResolver
)
@@ -84,7 +85,8 @@ enum RDEPUBTextRendererSupport {
html: markedHTML,
baseURL: baseURL,
stylesheet: RDEPUBTextStyleSheetPackage(layers: layers),
resourceDiagnostics: resourceDiagnostics
resourceDiagnostics: resourceDiagnostics,
styleCompatibilityReport: compatibility.report
)
return RDEPUBTextChapterRenderRequest(
context: context,
@@ -112,6 +114,7 @@ enum RDEPUBTextRendererSupport {
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)
normalizeAlignedParagraphIndent(paragraph)
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
@@ -172,4 +175,11 @@ enum RDEPUBTextRendererSupport {
style.paragraphSpacing = max(6, lineSpacing / 2)
return style
}
private static func normalizeAlignedParagraphIndent(_ paragraph: NSMutableParagraphStyle) {
guard paragraph.alignment == .right || paragraph.alignment == .center else {
return
}
paragraph.firstLineHeadIndent = 0
}
}