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:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user