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
parent d15f20b097
commit d7f28c3f10
21 changed files with 2649 additions and 1933 deletions

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,8 @@ public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
case image
case generic
case footnote
}
public struct RDEPUBTextPageMetadata: Codable, Equatable {

View File

@ -14,6 +14,10 @@ enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
case javaScriptError = "ssReaderJSError"
case fixedLayoutReady = "ssReaderFixedLayoutReady"
case imageDidTap = "ssReaderImageDidTap"
case footnoteDidTap = "ssReaderFootnoteDidTap"
}
enum RDEPUBJavaScriptBridge {
@ -36,7 +40,9 @@ enum RDEPUBJavaScriptBridge {
"INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue,
"EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue,
"JAVASCRIPT_ERROR_MESSAGE": RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue,
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue,
"IMAGE_DID_TAP_MESSAGE": RDEPUBJavaScriptBridgeMessage.imageDidTap.rawValue,
"FOOTNOTE_DID_TAP_MESSAGE": RDEPUBJavaScriptBridgeMessage.footnoteDidTap.rawValue
]
)
].joined(separator: "\n\n")

View File

@ -107,8 +107,43 @@ extension RDEPUBWebView: WKScriptMessageHandler {
self.rendered()
}
}
case RDEPUBJavaScriptBridgeMessage.imageDidTap.rawValue:
guard let body = message.body as? [String: Any],
let src = body["src"] as? String else { return }
let sourceRect = sourceRect(from: body["rect"])
delegate?.epubWebView(self, didTapImageWithSource: src, sourceRect: sourceRect)
case RDEPUBJavaScriptBridgeMessage.footnoteDidTap.rawValue:
guard let body = message.body as? [String: Any],
let altText = body["alt"] as? String else { return }
let sourceRect = sourceRect(from: body["rect"])
delegate?.epubWebView(self, didTapFootnoteWithAltText: altText, sourceRect: sourceRect)
default:
break
}
}
private func sourceRect(from value: Any?) -> CGRect? {
guard let rectDict = value as? [String: Any] else { return nil }
return CGRect(
x: cgFloatValue(rectDict["x"]),
y: cgFloatValue(rectDict["y"]),
width: cgFloatValue(rectDict["width"]),
height: cgFloatValue(rectDict["height"])
)
}
private func cgFloatValue(_ value: Any?) -> CGFloat {
switch value {
case let number as NSNumber:
return CGFloat(truncating: number)
case let double as Double:
return CGFloat(double)
case let int as Int:
return CGFloat(int)
case let string as String:
return CGFloat(Double(string) ?? 0)
default:
return 0
}
}
}

View File

@ -17,10 +17,16 @@ public protocol RDEPUBWebViewDelegate: AnyObject {
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?)
func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?)
}
public extension RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) {}
func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {}
}
final class RDEPUBAnnotationWebView: WKWebView {

View File

@ -386,6 +386,43 @@
}, 120);
}
function handleDocumentClick(event) {
// Check for image tap. Footnote images always show the note popup; regular
// images inside links are left to the link handler below.
var img = event.target;
while (img && img.tagName !== 'IMG') {
img = img.parentElement;
}
if (img && img.tagName === 'IMG') {
var src = img.getAttribute('src');
var classes = (img.getAttribute('class') || '').toLowerCase();
var alt = (img.getAttribute('alt') || '').trim();
var isFootnote = classes.indexOf('qqreader-footnote') >= 0 || alt.length > 0;
if (isFootnote) {
event.preventDefault();
event.stopPropagation();
var footnoteRect = img.getBoundingClientRect();
window.webkit.messageHandlers.{{FOOTNOTE_DID_TAP_MESSAGE}}.postMessage({
alt: alt,
rect: { x: footnoteRect.x, y: footnoteRect.y, width: footnoteRect.width, height: footnoteRect.height }
});
return;
}
// If a regular image is inside a link, let the link handler deal with it.
var linkParent = img.closest ? img.closest('a[href]') : null;
if (!linkParent) {
if (src) {
event.preventDefault();
event.stopPropagation();
var rect = img.getBoundingClientRect();
window.webkit.messageHandlers.{{IMAGE_DID_TAP_MESSAGE}}.postMessage({
src: src,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
});
return;
}
}
}
var anchor = event.target.closest ? event.target.closest('a[href]') : null;
if (!anchor) { return; }
var href = anchor.getAttribute('href');

View File

@ -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) {

View File

@ -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)

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,241 @@
import UIKit
final class RDEPUBImageViewController: UIViewController {
struct Configuration {
let image: UIImage
let sourceRect: CGRect?
let altText: String?
let theme: RDEPUBReaderTheme
init(image: UIImage, sourceRect: CGRect? = nil, altText: String? = nil, theme: RDEPUBReaderTheme) {
self.image = image
self.sourceRect = sourceRect
self.altText = altText
self.theme = theme
}
}
// MARK: - Properties
private let configuration: Configuration
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let closeButton = UIButton(type: .system)
private let backgroundView = UIView()
private var isShowingChrome = true
// MARK: - Init
init(configuration: Configuration) {
self.configuration = configuration
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupBackground()
setupScrollView()
setupImageView()
setupCloseButton()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
centerImage()
}
override var prefersStatusBarHidden: Bool { true }
// MARK: - Setup
private func setupBackground() {
backgroundView.translatesAutoresizingMaskIntoConstraints = false
backgroundView.backgroundColor = configuration.theme.imageViewerBackgroundColor
view.addSubview(backgroundView)
NSLayoutConstraint.activate([
backgroundView.topAnchor.constraint(equalTo: view.topAnchor),
backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
let tap = UITapGestureRecognizer(target: self, action: #selector(dismissViewer))
backgroundView.addGestureRecognizer(tap)
}
private func setupScrollView() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.delegate = self
scrollView.minimumZoomScale = 1.0
scrollView.maximumZoomScale = 5.0
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.alwaysBounceVertical = false
scrollView.alwaysBounceHorizontal = false
scrollView.decelerationRate = .fast
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
let swipeDown = UISwipeGestureRecognizer(target: self, action: #selector(dismissViewer))
swipeDown.direction = .down
scrollView.addGestureRecognizer(swipeDown)
}
private func setupImageView() {
imageView.image = configuration.image
imageView.contentMode = .scaleAspectFit
imageView.isUserInteractionEnabled = true
imageView.accessibilityLabel = configuration.altText
imageView.isAccessibilityElement = configuration.altText != nil
scrollView.addSubview(imageView)
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTap.numberOfTapsRequired = 2
imageView.addGestureRecognizer(doubleTap)
let singleTap = UITapGestureRecognizer(target: self, action: #selector(toggleChrome))
singleTap.numberOfTapsRequired = 1
singleTap.require(toFail: doubleTap)
imageView.addGestureRecognizer(singleTap)
}
private func setupCloseButton() {
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
closeButton.setImage(UIImage(systemName: "xmark", withConfiguration: config), for: .normal)
closeButton.tintColor = .white
closeButton.backgroundColor = UIColor(white: 0.3, alpha: 0.6)
closeButton.layer.cornerRadius = 16
closeButton.clipsToBounds = true
closeButton.addTarget(self, action: #selector(dismissViewer), for: .touchUpInside)
closeButton.accessibilityLabel = NSLocalizedString("Close image viewer", comment: "Accessibility label for close button in image viewer")
view.addSubview(closeButton)
closeButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
closeButton.widthAnchor.constraint(equalToConstant: 32),
closeButton.heightAnchor.constraint(equalToConstant: 32)
])
}
// MARK: - Layout
private func centerImage() {
guard let image = imageView.image else { return }
let boundsSize = scrollView.bounds.size
guard boundsSize.width > 0, boundsSize.height > 0 else { return }
let imageSize = image.size
guard imageSize.width > 0, imageSize.height > 0 else { return }
let widthRatio = boundsSize.width / imageSize.width
let heightRatio = boundsSize.height / imageSize.height
let fitScale = min(widthRatio, heightRatio)
let fitWidth = imageSize.width * fitScale
let fitHeight = imageSize.height * fitScale
imageView.frame = CGRect(
x: 0,
y: 0,
width: fitWidth,
height: fitHeight
)
scrollView.contentSize = imageView.frame.size
let horizontalInset = max(0, (boundsSize.width - fitWidth) / 2)
let verticalInset = max(0, (boundsSize.height - fitHeight) / 2)
scrollView.contentInset = UIEdgeInsets(
top: verticalInset,
left: horizontalInset,
bottom: verticalInset,
right: horizontalInset
)
}
// MARK: - Actions
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
if scrollView.zoomScale > scrollView.minimumZoomScale {
scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true)
} else {
let point = gesture.location(in: imageView)
let zoomSize = CGSize(width: 100, height: 100)
let zoomRect = CGRect(
x: point.x - zoomSize.width / 2,
y: point.y - zoomSize.height / 2,
width: zoomSize.width,
height: zoomSize.height
)
scrollView.zoom(to: zoomRect, animated: true)
}
}
@objc private func toggleChrome() {
isShowingChrome.toggle()
UIView.animate(withDuration: 0.25) {
self.closeButton.alpha = self.isShowingChrome ? 1.0 : 0.0
}
}
@objc private func dismissViewer() {
dismiss(animated: true)
}
}
// MARK: - UIScrollViewDelegate
extension RDEPUBImageViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
imageView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
guard let image = imageView.image else { return }
let boundsSize = scrollView.bounds.size
let imageSize = image.size
guard imageSize.width > 0, imageSize.height > 0 else { return }
let widthRatio = boundsSize.width / imageSize.width
let heightRatio = boundsSize.height / imageSize.height
let fitScale = min(widthRatio, heightRatio)
let fitWidth = imageSize.width * fitScale * scrollView.zoomScale
let fitHeight = imageSize.height * fitScale * scrollView.zoomScale
imageView.frame = CGRect(
x: 0,
y: 0,
width: fitWidth,
height: fitHeight
)
let horizontalInset = max(0, (boundsSize.width - fitWidth) / 2)
let verticalInset = max(0, (boundsSize.height - fitHeight) / 2)
scrollView.contentInset = UIEdgeInsets(
top: verticalInset,
left: horizontalInset,
bottom: verticalInset,
right: horizontalInset
)
}
}

View File

@ -0,0 +1,60 @@
import UIKit
final class RDEPUBImageViewerCoordinator {
private weak var presentingController: UIViewController?
private let theme: RDEPUBReaderTheme
init(presentingController: UIViewController, theme: RDEPUBReaderTheme) {
self.presentingController = presentingController
self.theme = theme
}
// MARK: - From Native Text Path (UIImage already resolved)
func presentImage(_ image: UIImage, sourceRect: CGRect? = nil, altText: String? = nil) {
let config = RDEPUBImageViewController.Configuration(
image: image,
sourceRect: sourceRect,
altText: altText,
theme: theme
)
let viewerVC = RDEPUBImageViewController(configuration: config)
presentingController?.present(viewerVC, animated: true)
}
// MARK: - From WebView Path (need to resolve from src URL)
func presentImageFromWebView(
src: String,
baseHref: String?,
resourceResolver: RDEPUBResourceResolver
) {
guard let image = loadImage(src: src, baseHref: baseHref, resourceResolver: resourceResolver) else {
return
}
presentImage(image)
}
// MARK: - Private
private func loadImage(
src: String,
baseHref: String?,
resourceResolver: RDEPUBResourceResolver
) -> UIImage? {
let pathPart = src.components(separatedBy: "#").first ?? src
guard !pathPart.isEmpty else { return nil }
let fileURL: URL?
if let baseHref {
fileURL = resourceResolver.fileURL(forReference: pathPart, relativeToHref: baseHref)
} else {
let normalized = resourceResolver.normalizedHref(pathPart)
fileURL = normalized.flatMap { resourceResolver.fileURL(forRelativePath: $0) }
}
guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return nil }
return UIImage(data: data)
}
}

View File

@ -62,6 +62,20 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
print("EPUB JS Error: \(message)")
#endif
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?) {
guard let publication else { return }
let baseHref = contentView.currentHref
let coordinator = RDEPUBImageViewerCoordinator(presentingController: self, theme: configuration.theme)
coordinator.presentImageFromWebView(src: src, baseHref: baseHref, resourceResolver: publication.resourceResolver)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {
guard !altText.isEmpty else { return }
let rect = sourceRect ?? .zero
let sourcePoint = rect.isNull ? CGPoint(x: contentView.bounds.midX, y: contentView.bounds.midY) : CGPoint(x: rect.midX, y: rect.midY)
presentAttachmentTooltip(text: altText, sourceView: contentView, sourceRect: rect, sourcePoint: sourcePoint)
}
}
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
@ -96,6 +110,16 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect, sourcePoint: sourcePoint)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateImage image: UIImage,
sourceRect: CGRect,
altText: String?
) {
let coordinator = RDEPUBImageViewerCoordinator(presentingController: self, theme: configuration.theme)
coordinator.presentImage(image, sourceRect: sourceRect, altText: altText)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,

View File

@ -3,7 +3,7 @@ import UIKit
extension RDEPUBReaderController {
func pageNumber(for location: RDEPUBLocation) -> Int? {
func pageNumber(for location: RDEPUBLocation, rangeInfo: String? = nil) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
let spineIndex = readerContext.normalizedSpineIndex(for: location),
@ -16,7 +16,8 @@ extension RDEPUBReaderController {
let localPageIndex = resolvedLocalPageIndex(
for: normalizedLocation,
spineIndex: spineIndex,
fallbackEntry: entry
fallbackEntry: entry,
rangeInfo: rangeInfo
) ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
return bookPageMap.absolutePageIndex(
spineIndex: spineIndex,
@ -25,17 +26,22 @@ extension RDEPUBReaderController {
}
if let textBook, let publication {
if let anchor = location.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
return page + 1
}
}
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
if let pageNumber = pageNumberFromRangeInfo(rangeInfo, in: textBook, matching: normalizedLocation) {
return pageNumber
}
if let anchor = normalizedLocation.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
return page + 1
}
}
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
@ -130,7 +136,10 @@ extension RDEPUBReaderController {
return (pages, chapters)
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry, rangeInfo: String? = nil) -> Int {
if let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location) {
return decodedRangeInfo.start
}
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let cfi = primaryLocationCFI(for: location),
@ -167,9 +176,10 @@ extension RDEPUBReaderController {
private func resolvedLocalPageIndex(
for location: RDEPUBLocation,
spineIndex: Int,
fallbackEntry: RDEPUBBookPageMapEntry
fallbackEntry: RDEPUBBookPageMapEntry,
rangeInfo: String? = nil
) -> Int? {
let offset = chapterOffset(for: location, fallbackEntry: fallbackEntry)
let offset = chapterOffset(for: location, fallbackEntry: fallbackEntry, rangeInfo: rangeInfo)
if let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let pageIndex = runtimeChapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
@ -193,6 +203,55 @@ extension RDEPUBReaderController {
return location.cfi
}
private func pageNumberFromRangeInfo(
_ rangeInfo: String?,
in textBook: RDEPUBTextBook,
matching location: RDEPUBLocation
) -> Int? {
guard let decodedRangeInfo = decodedRangeInfo(from: rangeInfo, matching: location),
let chapterData = chapterData(in: textBook, for: decodedRangeInfo),
let page = chapterData.page(containing: decodedRangeInfo.start) else {
return nil
}
return page.absolutePageIndex + 1
}
private func decodedRangeInfo(
from rangeInfo: String?,
matching location: RDEPUBLocation
) -> RDEPUBTextOffsetRangeInfo? {
guard let decodedRangeInfo = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
normalizedHref(decodedRangeInfo.href) == normalizedHref(location.href) else {
return nil
}
return decodedRangeInfo
}
private func chapterData(
in textBook: RDEPUBTextBook,
for rangeInfo: RDEPUBTextOffsetRangeInfo
) -> RDEPUBChapterData? {
if let chapterData = textBook.chapterData(for: rangeInfo.href) {
return chapterData
}
let targetHref = normalizedHref(rangeInfo.href)
if let chapterData = textBook.chapterData(for: targetHref) {
return chapterData
}
guard let chapter = textBook.chapters.first(where: { normalizedHref($0.href) == targetHref }) else {
return nil
}
return textBook.chapterData(for: chapter.href)
}
private func normalizedHref(_ href: String) -> String {
publication?.resourceResolver.normalizedHref(href)
?? href.components(separatedBy: "#").first
?? href
}
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
var bestID: String?
var bestOffset = Int.min

View File

@ -13,11 +13,20 @@ protocol RDEPUBWebContentViewDelegate: AnyObject {
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?)
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?)
}
final class RDEPUBWebContentView: UIView {
weak var delegate: RDEPUBWebContentViewDelegate?
/// The current href of the loaded chapter (exposed for image resolution).
var currentHref: String {
epubWebView.currentHref
}
private let epubWebView = RDEPUBWebView()
private let decorationOverlayView = RDEPUBWebDecorationOverlayView()
private let pageNumberLabel: UILabel = {
@ -108,4 +117,19 @@ extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
delegate?.epubWebContentView(self, didLogJavaScriptError: message)
}
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {}
func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) {
delegate?.epubWebContentView(self, didTapImageWithSource: src, sourceRect: sourceRect)
}
func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {
delegate?.epubWebContentView(self, didTapFootnoteWithAltText: altText, sourceRect: sourceRect)
}
}
extension RDEPUBWebContentViewDelegate {
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapImageWithSource src: String, sourceRect: CGRect?) {
// Default: no-op. Implementors can present an image viewer.
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {
// Default: no-op. Implementors can present a footnote tooltip.
}
}

View File

@ -172,7 +172,7 @@ struct RDEPUBChapterSummary: Codable {
let pageMetadataList: [PageMetadataSummary]
static let currentSchemaVersion = 9
static let currentSchemaVersion = 16
struct RangeData: Codable {

View File

@ -20,7 +20,7 @@ final class RDEPUBReaderLocationCoordinator {
if context.bookPageMap != nil {
_ = context.runtime?.ensureOnDemandNavigationTargetAvailable(for: location)
}
guard let targetPageNumber = controller.pageNumber(for: location) else {
guard let targetPageNumber = controller.pageNumber(for: location, rangeInfo: targetHighlightRangeInfo) else {
readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle)
return false

View File

@ -87,6 +87,12 @@ public struct RDEPUBReaderTheme: Equatable {
extension RDEPUBReaderTheme {
/// Background color for the full-screen image viewer.
/// Always near-black regardless of theme, since the image is the focus.
var imageViewerBackgroundColor: UIColor {
UIColor(white: 0.0, alpha: 0.95)
}
var themeBackgroundColorCSS: String {
contentBackgroundColor.rd_cssString
}

View File

@ -200,10 +200,23 @@ struct RDEPUBPageLayoutSnapshot {
let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex)
? page.metadata.attachmentPlacements[attachmentIndex]
: nil
let kind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
let metadataKind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
? page.metadata.attachmentKinds[attachmentIndex]
: nil
return (placement, kind)
let resolvedKind = attachmentKind(at: range, on: page) ?? metadataKind
return (placement, resolvedKind)
}
private static func attachmentKind(
at range: NSRange,
on page: RDEPUBTextPage
) -> RDEPUBTextAttachmentKind? {
guard range.location >= 0,
range.location < page.chapterContent.length else {
return nil
}
let attributes = page.chapterContent.attributes(at: range.location, effectiveRange: nil)
return RDEPUBAttachmentNormalizer.attachmentKind(for: attributes)
}
private static func offset(_ range: NSRange, by offset: Int) -> NSRange {

View File

@ -19,6 +19,13 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
sourceRect: CGRect,
sourcePoint: CGPoint
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateImage image: UIImage,
sourceRect: CGRect,
altText: String?
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
@ -26,6 +33,17 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
)
}
extension RDEPUBTextContentViewDelegate {
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateImage image: UIImage,
sourceRect: CGRect,
altText: String?
) {
// Default: no-op. Implementors can present an image viewer.
}
}
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
private var contentInsets: UIEdgeInsets = .zero
@ -508,6 +526,16 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
return nil
}
private func imageFromPage(attachment: RDEPUBPageAttachment, page: RDEPUBTextPage) -> UIImage? {
guard page.chapterContent.length > attachment.stringRange.location else { return nil }
let attachmentValue = page.chapterContent.attribute(
.attachment,
at: attachment.stringRange.location,
effectiveRange: nil
)
return image(from: attachmentValue)
}
private func applyHighlightsToContent(
_ content: NSMutableAttributedString,
highlights: [RDEPUBHighlight],
@ -539,6 +567,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
let text = content.string as NSString
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
guard firstParagraphRange.length > 0 else { return content }
guard !firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
return content
}
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
guard let style = value as? NSParagraphStyle else { return }
@ -558,6 +589,21 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
return !CharacterSet.newlines.contains(previousScalar)
}
private func firstParagraphUsesNonLeadingAlignment(
in content: NSAttributedString,
range: NSRange
) -> Bool {
var usesNonLeadingAlignment = false
content.enumerateAttribute(.paragraphStyle, in: range) { value, _, stop in
guard let style = value as? NSParagraphStyle else { return }
if style.alignment == .right || style.alignment == .center {
usesNonLeadingAlignment = true
stop.pointee = true
}
}
return usesNonLeadingAlignment
}
#if canImport(DTCoreText)
private func updateCoreTextLayoutFrameIfNeeded() {
@ -650,6 +696,29 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
clearSelection()
return
}
// PRIORITY: Check for attachment tap
if let page = currentPage,
let attachment = interactionController.snapshot?.attachment(at: point) {
let kind = attachment.kind
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) ?? CGRect.zero
// Footnote attachments carry their note text in alt/accessibility metadata.
// Prefer that semantic text over image preview even if attachment kind metadata is incomplete.
if let footnoteText = attachmentText(at: point), kind == .footnote || !footnoteText.isEmpty {
delegate?.textContentView(
self,
didActivateAttachmentText: footnoteText,
sourceRect: sourceRect,
sourcePoint: convert(point, from: overlayView)
)
return
}
// Regular image attachments: present image viewer
if let image = imageFromPage(attachment: attachment, page: page) {
let altText = attachmentText(at: point)
delegate?.textContentView(self, didActivateImage: image, sourceRect: sourceRect, altText: altText)
return
}
}
if let attachmentText = attachmentText(at: point),
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
delegate?.textContentView(