import Foundation enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable { case progressionChanged = "ssReaderProgressionChanged" case selectionChanged = "ssReaderSelectionChanged" case internalLink = "ssReaderInternalLink" case externalLink = "ssReaderExternalLink" case javaScriptError = "ssReaderJSError" case fixedLayoutReady = "ssReaderFixedLayoutReady" } enum RDEPUBJavaScriptBridge { static var messageNames: [String] { RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue) } static var userScript: String { 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 ] ) } 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.RDReaderBridge) { return false; } window.RDReaderBridge.applyPagination(`\(style)`); window.RDReaderBridge.setPageMetrics(\(request.totalPagesInChapter), \(pageStride)); window.RDReaderBridge.clearHighlights(); window.RDReaderBridge.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]"))); if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) { window.RDReaderBridge.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex)); } else { window.RDReaderBridge.scrollToPage(\(request.pageIndex)); } window.RDReaderBridge.reportProgression(); return true; })(); \(searchScript) """ } static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String { let payload = jsonString(from: searchPayload(presentation), fallback: "null") return """ (function() { var payload = \(payload); function normalizeHref(rawHref) { if (!rawHref) { return ''; } var value = String(rawHref); value = value.replace(/^.*?:\\/\\/[^/]+\\//, ''); value = value.split('#')[0]; value = value.replace(/^\\/+/, ''); try { value = decodeURIComponent(value); } catch (error) { } return value; } function ensureStyle(doc) { if (!doc || !doc.head || doc.getElementById('ss-reader-search-style')) { return; } var style = doc.createElement('style'); style.id = 'ss-reader-search-style'; style.textContent = '.ss-reader-search-mark{background:rgba(248,225,108,0.55);color:inherit;border-radius:2px;padding:0;}.ss-reader-search-mark-active{background:rgba(255,159,67,0.75);color:inherit;border-radius:2px;padding:0;}'; doc.head.appendChild(style); } function clearMarks(doc) { if (!doc || !doc.body) { return; } var marks = doc.querySelectorAll('span.ss-reader-search-mark, span.ss-reader-search-mark-active'); marks.forEach(function(mark) { var textNode = doc.createTextNode(mark.textContent || ''); if (mark.parentNode) { mark.parentNode.replaceChild(textNode, mark); } }); doc.body.normalize(); } function searchableDocuments(rootDoc) { var items = []; if (!rootDoc) { return items; } items.push({ doc: rootDoc, href: normalizeHref(rootDoc.location && rootDoc.location.href) }); var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : []; frames.forEach(function(frame) { try { if (frame.contentDocument) { items.push({ doc: frame.contentDocument, href: normalizeHref(frame.contentWindow && frame.contentWindow.location && frame.contentWindow.location.href) }); } } catch (error) { } }); return items; } function textNodes(doc) { if (!doc || !doc.body) { return []; } var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (!node || !node.nodeValue || !node.nodeValue.trim()) { return NodeFilter.FILTER_REJECT; } var parent = node.parentNode; if (!parent || !parent.nodeName) { return NodeFilter.FILTER_REJECT; } var name = parent.nodeName.toLowerCase(); if (['script', 'style', 'noscript'].indexOf(name) >= 0) { return NodeFilter.FILTER_REJECT; } if (parent.classList && (parent.classList.contains('ss-reader-search-mark') || parent.classList.contains('ss-reader-search-mark-active'))) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }); var nodes = []; while (walker.nextNode()) { nodes.push(walker.currentNode); } return nodes; } function applyToDocument(doc, resourceState, keyword) { clearMarks(doc); ensureStyle(doc); if (!doc || !doc.body || !resourceState || !keyword || resourceState.matchCount <= 0) { return; } var lowerKeyword = String(keyword).toLowerCase(); var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null; var currentLocalIndex = 0; var activeElement = null; textNodes(doc).forEach(function(node) { var source = node.nodeValue || ''; var lowerSource = source.toLowerCase(); var cursor = 0; var replaced = false; var fragment = doc.createDocumentFragment(); while (cursor < source.length) { var foundIndex = lowerSource.indexOf(lowerKeyword, cursor); if (foundIndex < 0) { break; } replaced = true; if (foundIndex > cursor) { fragment.appendChild(doc.createTextNode(source.slice(cursor, foundIndex))); } var mark = doc.createElement('span'); mark.className = currentLocalIndex === activeIndex ? 'ss-reader-search-mark-active' : 'ss-reader-search-mark'; mark.textContent = source.slice(foundIndex, foundIndex + keyword.length); fragment.appendChild(mark); if (currentLocalIndex === activeIndex) { activeElement = mark; } currentLocalIndex += 1; cursor = foundIndex + keyword.length; } if (!replaced) { return; } if (cursor < source.length) { fragment.appendChild(doc.createTextNode(source.slice(cursor))); } if (node.parentNode) { node.parentNode.replaceChild(fragment, node); } }); if (activeElement && activeElement.scrollIntoView) { activeElement.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' }); } } var docs = searchableDocuments(document); var states = new Map(); if (payload && Array.isArray(payload.resources)) { payload.resources.forEach(function(resource) { states.set(normalizeHref(resource.href), resource); }); } docs.forEach(function(item) { applyToDocument(item.doc, states.get(item.href), payload && payload.keyword ? payload.keyword : ''); }); return true; })(); """ } 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 } }