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:
parent
d15f20b097
commit
d7f28c3f10
3826
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
3826
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,8 @@ public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
|
||||
case image
|
||||
|
||||
case generic
|
||||
|
||||
case footnote
|
||||
}
|
||||
|
||||
public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
241
Sources/RDReaderView/EPUBUI/RDEPUBImageViewController.swift
Normal file
241
Sources/RDReaderView/EPUBUI/RDEPUBImageViewController.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -172,7 +172,7 @@ struct RDEPUBChapterSummary: Codable {
|
||||
|
||||
let pageMetadataList: [PageMetadataSummary]
|
||||
|
||||
static let currentSchemaVersion = 9
|
||||
static let currentSchemaVersion = 16
|
||||
|
||||
struct RangeData: Codable {
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user