ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift
shenlei d7f28c3f10 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>
2026-06-25 15:39:58 +08:00

176 lines
6.9 KiB
Swift

import Foundation
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
case progressionChanged = "ssReaderProgressionChanged"
case selectionChanged = "ssReaderSelectionChanged"
case internalLink = "ssReaderInternalLink"
case externalLink = "ssReaderExternalLink"
case javaScriptError = "ssReaderJSError"
case fixedLayoutReady = "ssReaderFixedLayoutReady"
case imageDidTap = "ssReaderImageDidTap"
case footnoteDidTap = "ssReaderFootnoteDidTap"
}
enum RDEPUBJavaScriptBridge {
static var messageNames: [String] {
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
}
static var userScript: String {
[
RDEPUBAssetRepository.string(for: .rangyCoreScript),
RDEPUBAssetRepository.string(for: .rangySerializerScript),
RDEPUBAssetRepository.string(for: .cssInjectorScript),
RDEPUBAssetRepository.string(for: .weReadAPIScript),
RDEPUBAssetRepository.string(
for: .bridgeScript,
replacements: [
"PROGRESSION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue,
"SELECTION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue,
"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,
"IMAGE_DID_TAP_MESSAGE": RDEPUBJavaScriptBridgeMessage.imageDidTap.rawValue,
"FOOTNOTE_DID_TAP_MESSAGE": RDEPUBJavaScriptBridgeMessage.footnoteDidTap.rawValue
]
)
].joined(separator: "\n\n")
}
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
let pageStride = max(1, request.presentation.viewportSize.width)
let searchScript = applySearchScript(for: request.searchPresentation)
return """
(function() {
if (!window.WeReadApi) { return false; }
window.WeReadApi.applySharedTheme({
backgroundColor: \(javaScriptStringLiteral(request.presentation.themeBackgroundColor)),
textColor: \(javaScriptStringLiteral(request.presentation.themeTextColor)),
includeFrames: false
});
window.WeReadApi.applyPagination(`\(style)`);
window.WeReadApi.setPageMetrics(\(request.totalPagesInChapter), \(pageStride));
window.WeReadApi.clearHighlights();
window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
window.WeReadApi.scrollToLocation(
\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")),
\(request.pageIndex),
\(javaScriptStringLiteral(request.targetHighlightRangeInfo))
);
} else {
window.WeReadApi.scrollToPage(\(request.pageIndex));
}
window.WeReadApi.reportProgression();
return true;
})();
\(searchScript)
"""
}
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String {
"""
(function() {
if (!window.WeReadApi) { return false; }
window.WeReadApi.applySharedTheme({
backgroundColor: \(javaScriptStringLiteral(request.backgroundColorCSS)),
textColor: null,
includeFrames: true
});
return true;
})();
"""
}
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
return """
(function() {
if (!window.WeReadApi) { return false; }
window.WeReadApi.setSearchPresentation(\(payload));
return true;
})();
"""
}
static func resolveDecorationsScript() -> String {
"""
(function() {
if (!window.WeReadApi) { return { highlights: [], search: [] }; }
return window.WeReadApi.resolveDecorations();
})();
"""
}
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
string
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "`", with: "\\`")
}
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
highlights.compactMap { highlight in
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
return [
"id": highlight.id,
"rangeInfo": rangeInfo,
"color": highlight.color,
"style": highlight.style.rawValue
]
}
}
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
guard let location else { return nil }
return [
"progression": location.progression,
"lastProgression": location.lastProgression ?? location.progression,
"fragment": location.fragment as Any
]
}
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
guard let presentation else { return nil }
return [
"keyword": presentation.keyword,
"resources": presentation.resources.map { resource in
[
"href": resource.href,
"matchCount": resource.matchCount,
"activeLocalMatchIndex": resource.activeLocalMatchIndex as Any
]
}
]
}
private static func jsonString(from object: Any?, fallback: String) -> String {
guard let object else { return fallback }
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object, options: []),
let string = String(data: data, encoding: .utf8) else {
return fallback
}
return string
}
private static func javaScriptStringLiteral(_ value: String?) -> String {
guard let value else { return "null" }
return jsonString(from: [value], fallback: "[null]")
.replacingOccurrences(of: "[", with: "")
.replacingOccurrences(of: "]", with: "")
}
}