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 image
|
||||||
|
|
||||||
case generic
|
case generic
|
||||||
|
|
||||||
|
case footnote
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||||
|
|||||||
@ -14,6 +14,10 @@ enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
|
|||||||
case javaScriptError = "ssReaderJSError"
|
case javaScriptError = "ssReaderJSError"
|
||||||
|
|
||||||
case fixedLayoutReady = "ssReaderFixedLayoutReady"
|
case fixedLayoutReady = "ssReaderFixedLayoutReady"
|
||||||
|
|
||||||
|
case imageDidTap = "ssReaderImageDidTap"
|
||||||
|
|
||||||
|
case footnoteDidTap = "ssReaderFootnoteDidTap"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RDEPUBJavaScriptBridge {
|
enum RDEPUBJavaScriptBridge {
|
||||||
@ -36,7 +40,9 @@ enum RDEPUBJavaScriptBridge {
|
|||||||
"INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue,
|
"INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue,
|
||||||
"EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue,
|
"EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue,
|
||||||
"JAVASCRIPT_ERROR_MESSAGE": RDEPUBJavaScriptBridgeMessage.javaScriptError.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")
|
].joined(separator: "\n\n")
|
||||||
|
|||||||
@ -107,8 +107,43 @@ extension RDEPUBWebView: WKScriptMessageHandler {
|
|||||||
self.rendered()
|
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:
|
default:
|
||||||
break
|
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 epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
|
||||||
|
|
||||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
|
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 {
|
public extension RDEPUBWebViewDelegate {
|
||||||
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
|
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 {
|
final class RDEPUBAnnotationWebView: WKWebView {
|
||||||
|
|||||||
@ -386,6 +386,43 @@
|
|||||||
}, 120);
|
}, 120);
|
||||||
}
|
}
|
||||||
function handleDocumentClick(event) {
|
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;
|
var anchor = event.target.closest ? event.target.closest('a[href]') : null;
|
||||||
if (!anchor) { return; }
|
if (!anchor) { return; }
|
||||||
var href = anchor.getAttribute('href');
|
var href = anchor.getAttribute('href');
|
||||||
|
|||||||
@ -91,10 +91,42 @@ struct RDEPUBChapterTailNormalizer {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard !frameHasAvoidPageBreakInside(trailingFrame, in: content) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
|
let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
|
||||||
return previousVisibleCount >= max(visibleCount * 8, 12)
|
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(
|
private func mergeTrailingFrame(
|
||||||
_ previousFrame: RDEPUBTextLayoutFrame,
|
_ previousFrame: RDEPUBTextLayoutFrame,
|
||||||
with trailingFrame: RDEPUBTextLayoutFrame
|
with trailingFrame: RDEPUBTextLayoutFrame
|
||||||
@ -147,6 +179,13 @@ struct RDEPUBChapterTailNormalizer {
|
|||||||
return count
|
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] {
|
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||||
values.reduce(into: [T]()) { result, value in
|
values.reduce(into: [T]()) { result, value in
|
||||||
if !result.contains(value) {
|
if !result.contains(value) {
|
||||||
|
|||||||
@ -104,7 +104,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
|||||||
|
|
||||||
public final class RDEPUBTextBookCache {
|
public final class RDEPUBTextBookCache {
|
||||||
|
|
||||||
public var schemaVersion: Int = 6
|
public var schemaVersion: Int = 13
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
|
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,10 @@ struct RDEPUBCSSCompatibilityLayer {
|
|||||||
normalized += result.normalizedRules
|
normalized += result.normalizedRules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let alignmentIndentResult = addTextIndentResetForAlignedBlocks(in: rewritten)
|
||||||
|
rewritten = alignmentIndentResult.css
|
||||||
|
normalized += alignmentIndentResult.normalizedRules
|
||||||
|
|
||||||
if !policy.allowPublisherMargins {
|
if !policy.allowPublisherMargins {
|
||||||
let result = clampLengthProperty(
|
let result = clampLengthProperty(
|
||||||
in: rewritten,
|
in: rewritten,
|
||||||
@ -133,4 +137,57 @@ struct RDEPUBCSSCompatibilityLayer {
|
|||||||
}
|
}
|
||||||
return (rewritten, normalized)
|
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 {
|
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||||
|
|
||||||
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||||
|
let altText = attachment.attributes["alt"] as? String
|
||||||
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
|
return lowercasedClasses.contains("qqreader-footnote") || hasFootnoteAltText(altText)
|
||||||
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
|
|
||||||
return lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
|
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||||
@ -197,6 +195,10 @@ struct RDEPUBAttachmentNormalizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func attachmentKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentKind? {
|
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 {
|
if let attachment = attributes[.attachment] as? NSTextAttachment {
|
||||||
|
|
||||||
@ -216,4 +218,22 @@ struct RDEPUBAttachmentNormalizer {
|
|||||||
|
|
||||||
return nil
|
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,
|
baseURL: baseURL,
|
||||||
resourceResolver: resourceResolver
|
resourceResolver: resourceResolver
|
||||||
)
|
)
|
||||||
|
let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS)
|
||||||
|
|
||||||
let layers = RDEPUBStyleSheetComposer.makeStyleSheetLayers(
|
let layers = RDEPUBStyleSheetComposer.makeStyleSheetLayers(
|
||||||
style: style,
|
style: style,
|
||||||
epubCSS: stylesheetHrefReplacements.inlinedCSS,
|
epubCSS: compatibility.css,
|
||||||
contentLanguageCode: contentLanguageCode,
|
contentLanguageCode: contentLanguageCode,
|
||||||
sourceHTML: rawHTML
|
sourceHTML: rawHTML
|
||||||
)
|
)
|
||||||
|
|
||||||
RDEPUBFontNormalizer.registerEmbeddedFonts(
|
RDEPUBFontNormalizer.registerEmbeddedFonts(
|
||||||
in: stylesheetHrefReplacements.inlinedCSS + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML),
|
in: compatibility.css + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML),
|
||||||
chapterHref: href,
|
chapterHref: href,
|
||||||
resourceResolver: resourceResolver
|
resourceResolver: resourceResolver
|
||||||
)
|
)
|
||||||
@ -84,7 +85,8 @@ enum RDEPUBTextRendererSupport {
|
|||||||
html: markedHTML,
|
html: markedHTML,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
stylesheet: RDEPUBTextStyleSheetPackage(layers: layers),
|
stylesheet: RDEPUBTextStyleSheetPackage(layers: layers),
|
||||||
resourceDiagnostics: resourceDiagnostics
|
resourceDiagnostics: resourceDiagnostics,
|
||||||
|
styleCompatibilityReport: compatibility.report
|
||||||
)
|
)
|
||||||
return RDEPUBTextChapterRenderRequest(
|
return RDEPUBTextChapterRenderRequest(
|
||||||
context: context,
|
context: context,
|
||||||
@ -112,6 +114,7 @@ enum RDEPUBTextRendererSupport {
|
|||||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
|
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
|
||||||
paragraph.lineSpacing = style.lineSpacing
|
paragraph.lineSpacing = style.lineSpacing
|
||||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
||||||
|
normalizeAlignedParagraphIndent(paragraph)
|
||||||
|
|
||||||
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
|
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
|
||||||
|
|
||||||
@ -172,4 +175,11 @@ enum RDEPUBTextRendererSupport {
|
|||||||
style.paragraphSpacing = max(6, lineSpacing / 2)
|
style.paragraphSpacing = max(6, lineSpacing / 2)
|
||||||
return style
|
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)")
|
print("EPUB JS Error: \(message)")
|
||||||
#endif
|
#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 {
|
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||||
@ -96,6 +110,16 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
|||||||
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect, sourcePoint: sourcePoint)
|
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(
|
func textContentView(
|
||||||
_ contentView: RDEPUBTextContentView,
|
_ contentView: RDEPUBTextContentView,
|
||||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import UIKit
|
|||||||
|
|
||||||
extension RDEPUBReaderController {
|
extension RDEPUBReaderController {
|
||||||
|
|
||||||
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
func pageNumber(for location: RDEPUBLocation, rangeInfo: String? = nil) -> Int? {
|
||||||
if let publication,
|
if let publication,
|
||||||
let bookPageMap = readerContext.bookPageMap,
|
let bookPageMap = readerContext.bookPageMap,
|
||||||
let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||||||
@ -16,7 +16,8 @@ extension RDEPUBReaderController {
|
|||||||
let localPageIndex = resolvedLocalPageIndex(
|
let localPageIndex = resolvedLocalPageIndex(
|
||||||
for: normalizedLocation,
|
for: normalizedLocation,
|
||||||
spineIndex: spineIndex,
|
spineIndex: spineIndex,
|
||||||
fallbackEntry: entry
|
fallbackEntry: entry,
|
||||||
|
rangeInfo: rangeInfo
|
||||||
) ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
) ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||||||
return bookPageMap.absolutePageIndex(
|
return bookPageMap.absolutePageIndex(
|
||||||
spineIndex: spineIndex,
|
spineIndex: spineIndex,
|
||||||
@ -25,17 +26,22 @@ extension RDEPUBReaderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let textBook, let publication {
|
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(
|
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||||
location,
|
location,
|
||||||
relativeToSpineIndex: nil,
|
relativeToSpineIndex: nil,
|
||||||
bookIdentifier: currentBookIdentifier
|
bookIdentifier: currentBookIdentifier
|
||||||
) ?? location
|
) ?? 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(
|
return textBook.pageNumber(
|
||||||
for: normalizedLocation,
|
for: normalizedLocation,
|
||||||
resolver: publication.resourceResolver,
|
resolver: publication.resourceResolver,
|
||||||
@ -130,7 +136,10 @@ extension RDEPUBReaderController {
|
|||||||
return (pages, chapters)
|
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),
|
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||||||
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
|
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
|
||||||
let cfi = primaryLocationCFI(for: location),
|
let cfi = primaryLocationCFI(for: location),
|
||||||
@ -167,9 +176,10 @@ extension RDEPUBReaderController {
|
|||||||
private func resolvedLocalPageIndex(
|
private func resolvedLocalPageIndex(
|
||||||
for location: RDEPUBLocation,
|
for location: RDEPUBLocation,
|
||||||
spineIndex: Int,
|
spineIndex: Int,
|
||||||
fallbackEntry: RDEPUBBookPageMapEntry
|
fallbackEntry: RDEPUBBookPageMapEntry,
|
||||||
|
rangeInfo: String? = nil
|
||||||
) -> Int? {
|
) -> 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),
|
if let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
|
||||||
let pageIndex = runtimeChapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
|
let pageIndex = runtimeChapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
|
||||||
@ -193,6 +203,55 @@ extension RDEPUBReaderController {
|
|||||||
return location.cfi
|
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? {
|
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
|
||||||
var bestID: String?
|
var bestID: String?
|
||||||
var bestOffset = Int.min
|
var bestOffset = Int.min
|
||||||
|
|||||||
@ -13,11 +13,20 @@ protocol RDEPUBWebContentViewDelegate: AnyObject {
|
|||||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
|
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
|
||||||
|
|
||||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
|
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 {
|
final class RDEPUBWebContentView: UIView {
|
||||||
weak var delegate: RDEPUBWebContentViewDelegate?
|
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 epubWebView = RDEPUBWebView()
|
||||||
private let decorationOverlayView = RDEPUBWebDecorationOverlayView()
|
private let decorationOverlayView = RDEPUBWebDecorationOverlayView()
|
||||||
private let pageNumberLabel: UILabel = {
|
private let pageNumberLabel: UILabel = {
|
||||||
@ -108,4 +117,19 @@ extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
|
|||||||
delegate?.epubWebContentView(self, didLogJavaScriptError: message)
|
delegate?.epubWebContentView(self, didLogJavaScriptError: message)
|
||||||
}
|
}
|
||||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {}
|
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]
|
let pageMetadataList: [PageMetadataSummary]
|
||||||
|
|
||||||
static let currentSchemaVersion = 9
|
static let currentSchemaVersion = 16
|
||||||
|
|
||||||
struct RangeData: Codable {
|
struct RangeData: Codable {
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ final class RDEPUBReaderLocationCoordinator {
|
|||||||
if context.bookPageMap != nil {
|
if context.bookPageMap != nil {
|
||||||
_ = context.runtime?.ensureOnDemandNavigationTargetAvailable(for: location)
|
_ = 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)
|
readerView.transitionToPage(pageNum: 0)
|
||||||
context.readingSession?.transition(to: .idle)
|
context.readingSession?.transition(to: .idle)
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -87,6 +87,12 @@ public struct RDEPUBReaderTheme: Equatable {
|
|||||||
|
|
||||||
extension RDEPUBReaderTheme {
|
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 {
|
var themeBackgroundColorCSS: String {
|
||||||
contentBackgroundColor.rd_cssString
|
contentBackgroundColor.rd_cssString
|
||||||
}
|
}
|
||||||
|
|||||||
@ -200,10 +200,23 @@ struct RDEPUBPageLayoutSnapshot {
|
|||||||
let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex)
|
let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex)
|
||||||
? page.metadata.attachmentPlacements[attachmentIndex]
|
? page.metadata.attachmentPlacements[attachmentIndex]
|
||||||
: nil
|
: nil
|
||||||
let kind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
|
let metadataKind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
|
||||||
? page.metadata.attachmentKinds[attachmentIndex]
|
? page.metadata.attachmentKinds[attachmentIndex]
|
||||||
: nil
|
: 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 {
|
private static func offset(_ range: NSRange, by offset: Int) -> NSRange {
|
||||||
|
|||||||
@ -19,6 +19,13 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
|||||||
sourceRect: CGRect,
|
sourceRect: CGRect,
|
||||||
sourcePoint: CGPoint
|
sourcePoint: CGPoint
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func textContentView(
|
||||||
|
_ contentView: RDEPUBTextContentView,
|
||||||
|
didActivateImage image: UIImage,
|
||||||
|
sourceRect: CGRect,
|
||||||
|
altText: String?
|
||||||
|
)
|
||||||
func textContentView(
|
func textContentView(
|
||||||
_ contentView: RDEPUBTextContentView,
|
_ contentView: RDEPUBTextContentView,
|
||||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
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 {
|
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
|
||||||
|
|
||||||
private var contentInsets: UIEdgeInsets = .zero
|
private var contentInsets: UIEdgeInsets = .zero
|
||||||
@ -508,6 +526,16 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
return nil
|
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(
|
private func applyHighlightsToContent(
|
||||||
_ content: NSMutableAttributedString,
|
_ content: NSMutableAttributedString,
|
||||||
highlights: [RDEPUBHighlight],
|
highlights: [RDEPUBHighlight],
|
||||||
@ -539,6 +567,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
let text = content.string as NSString
|
let text = content.string as NSString
|
||||||
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
|
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
|
||||||
guard firstParagraphRange.length > 0 else { return content }
|
guard firstParagraphRange.length > 0 else { return content }
|
||||||
|
guard !firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
|
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
|
||||||
guard let style = value as? NSParagraphStyle else { return }
|
guard let style = value as? NSParagraphStyle else { return }
|
||||||
@ -558,6 +589,21 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
return !CharacterSet.newlines.contains(previousScalar)
|
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)
|
#if canImport(DTCoreText)
|
||||||
|
|
||||||
private func updateCoreTextLayoutFrameIfNeeded() {
|
private func updateCoreTextLayoutFrameIfNeeded() {
|
||||||
@ -650,6 +696,29 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
|||||||
clearSelection()
|
clearSelection()
|
||||||
return
|
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),
|
if let attachmentText = attachmentText(at: point),
|
||||||
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
|
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
|
||||||
delegate?.textContentView(
|
delegate?.textContentView(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user