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 } /// Encodes a string value as a safe JavaScript string literal. /// /// Uses JSON serialization to handle escaping of quotes, backslashes, control /// characters, and Unicode, then strips the array wrapper to produce a standalone /// JS string literal (including surrounding quotes). /// /// - Parameter value: The string to encode, or `nil` which produces the JS keyword `null`. /// - Returns: A JavaScript string literal safe for inline embedding in JS source. private static func javaScriptStringLiteral(_ value: String?) -> String { guard let value else { return "null" } guard JSONSerialization.isValidJSONObject([value]), let data = try? JSONSerialization.data(withJSONObject: [value], options: []), let arrayString = String(data: data, encoding: .utf8) else { return "null" } // JSON encodes ["value"] as `["value"]`. Drop the leading `[` and trailing `]` // to yield `"value"` — a valid JS string literal with all special characters escaped. return String(arrayString.dropFirst().dropLast()) } }