From 587c789610ee354df4774eac1f598e2fc4181c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Wed, 1 Jul 2026 22:41:48 +0200 Subject: [PATCH] feat: configurable injection of js and css --- .vscode/settings.json | 8 +- CLAUDE.md | 20 +++- flutter_readium/CHANGELOG.md | 5 + .../nota/flutterreadium/PublicationChannel.kt | 26 +++++ .../nota/flutterreadium/ReadiumExtensions.kt | 102 ++++++++++++------ .../dk/nota/flutterreadium/ReadiumReader.kt | 14 ++- .../plugin_integration_test.dart | 2 +- .../FlutterReadiumPlugin.swift | 30 ++++++ flutter_readium/lib/flutter_readium.dart | 11 ++ .../test/flutter_readium_test.dart | 6 ++ .../flutter_readium_platform_interface.dart | 12 +++ .../lib/method_channel_flutter_readium.dart | 16 +++ .../lib/src/shared/index.dart | 1 + 13 files changed, 209 insertions(+), 44 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index bd751403..7ce1a92b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -102,12 +102,12 @@ "java.configuration.updateBuildConfiguration": "interactive", "dart.mcpServer": true, "chat.tools.terminal.autoApprove": { - "flutter": true, - "dart": true, "./gradlew": true, + "bin/analyze": true, "command": true, + "dart": true, + "flutter": true, "ktlint": true, - "npm run build:flutter": true, - "bin/analyze": true + "npm run build:flutter": true } } diff --git a/CLAUDE.md b/CLAUDE.md index 848ebe58..ebc92601 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,9 +48,23 @@ When upgrading a toolkit, move all three platforms together where API surface ov ### Android -- After editing any Kotlin file: `ktlint --format`; resolve all violations before committing. -- Every `PluginLog.*` message starts with `::functionName` (exact enclosing named function). -- Navigator-dependent `suspend` funcs: capture `navigator` as a local with a `?: run { … return }` guard, then wrap calls in `return withContext(coroutineContext) { }`. Funcs that only delegate to other wrappers skip the guard. +- **Kotlin formatting**: after writing or editing any Kotlin file, run `ktlint --format` on it. All violations must be resolved before committing. +- **Android log messages**: every `PluginLog.*` call in Kotlin must start with `::functionName` (double colon, then the exact name of the enclosing function). For lambdas, use the name of the enclosing named function. Example: `Log.d(TAG, "::goBackward. Navigator not ready.")`. Single-colon or missing prefixes are bugs; wrong function names from copy-paste are also bugs. +- **Android navigator null guard**: every `suspend` function that needs the navigator must capture it as a local variable with a `?: run { }` early-return guard, then wrap direct navigator calls in `return withContext(coroutineContext) { }`. Functions that only call other wrapper functions (e.g. `evaluateJavascript`) do not need their own guard or `withContext` — delegate instead. Example: + ```kotlin + val navigator = epubNavigator ?: run { + PluginLog.w(TAG, "::myFunction. Navigator not ready.") + return + } + return withContext(coroutineContext) { navigator.someCall() } + ``` + +## Build / toolchain facts + +- Dart SDK: `>=3.8.0 <4.0.0`, Flutter `>=3.32.0`. +- Android: `minSdkVersion 24`, `compileSdk 36`, Kotlin 2.3.21, AGP 8.13.2, Java 18 source/target. +- iOS: requires `use_frameworks!` and `use_modular_headers!` in consuming `Podfile` (see top-level `README.md`). +- Web: webpack 5, TypeScript 5.7+. ## Gotchas diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index 452995a0..5eb26163 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -7,6 +7,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **Extra JS/CSS injection** — `FlutterReadium().setJavaScriptInjections(List)` + and `FlutterReadium().setCssInjections(List)` register additional JavaScript + and CSS assets to inject into every EPUB HTML resource alongside the + built-in `flutterReadiumTools.js` / `flutterReadiumTools.css`. Supported on iOS and Android. + Call before opening a publication so the injections are active when the reader view is created. - **EPUB image tap** — tapping an image in an EPUB now fires `onImageTapped` with an `ImageTapEvent` carrying the publication-relative `href`, optional `alt` / `caption`, on-screen `rect`, and a `srcUrl` on Web. Detection runs on diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PublicationChannel.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PublicationChannel.kt index fcae9cf6..b7920ba3 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PublicationChannel.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PublicationChannel.kt @@ -90,6 +90,32 @@ internal class PublicationMethodCallHandler : MethodChannel.MethodCallHandler { return Try.success(null) } + "setCssInjections" -> { + @Suppress("UNCHECKED_CAST") + val items = arguments as? List> ?: emptyList() + ReadiumReader.cssInjections = + items.map { map -> + InjectionAsset( + assetPath = map["assetPath"] as String, + packageName = map["package"] as? String, + ) + } + return Try.success(null) + } + + "setJavaScriptInjections" -> { + @Suppress("UNCHECKED_CAST") + val items = arguments as? List> ?: emptyList() + ReadiumReader.javaScriptInjections = + items.map { map -> + InjectionAsset( + assetPath = map["assetPath"] as String, + packageName = map["package"] as? String, + ) + } + return Try.success(null) + } + "loadPublication" -> { val args = arguments as List val pubUrlStr = args[0] as String diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt index e8623bc2..a4f11b1a 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt @@ -117,19 +117,41 @@ fun decorationStyleFromMap(decoMap: Map<*, *>?): Decoration.Style? { } } -private const val READIUM_FLUTTER_PATH_PREFIX = - "https://readium_assets/flutter_assets/packages/flutter_readium" +private const val FLUTTER_ASSETS_BASE = "https://readium_assets/flutter_assets" + +private const val INJECT_START_MARKER = "" +private const val INJECT_END_MARKER = "" + +/** A Flutter asset (JS or CSS) to inject into every EPUB HTML resource. */ +data class InjectionAsset( + val assetPath: String, + val packageName: String?, +) { + val assetUrl: String + get() = + if (packageName != null) { + "$FLUTTER_ASSETS_BASE/packages/$packageName/$assetPath" + } else { + "$FLUTTER_ASSETS_BASE/$assetPath" + } +} + +private val BUILT_IN_INJECTIONS = + listOf( + InjectionAsset("assets/helpers/flutterReadiumTools.js", "flutter_readium"), + InjectionAsset("assets/helpers/flutterReadiumTools.css", "flutter_readium"), + ) // Helper for injecting extra files into an epub. fun Resource.injectScriptsAndStyles( tocIds: List, epubPreferences: FlutterEpubPreferences?, + extraInjections: List = emptyList(), ): Resource = TransformingResource(this) { bytes -> val props = this.properties().getOrNull() val filename = props?.filename ?: return@TransformingResource Try.success(bytes) - // Skip all non-html files if (!filename.endsWith("html", ignoreCase = true)) { return@TransformingResource Try.success(bytes) } @@ -141,37 +163,26 @@ fun Resource.injectScriptsAndStyles( return@TransformingResource Try.success(bytes) } - val injectStyle = epubPreferences?.toInjectableStyleSheet() + val assetLines = + (BUILT_IN_INJECTIONS + extraInjections).mapNotNull { injection -> + when { + injection.assetPath.endsWith(".js", ignoreCase = true) -> { + """""" + } - if (content.take(headEndIndex).contains(READIUM_FLUTTER_PATH_PREFIX)) { - injectStyle?.let { - if (!content.contains(it)) { - PluginLog.d( - TAG, - "Scripts already loaded for $filename, but custom css needs to be updated.", - ) - return@TransformingResource Try.success( - content - .replace( - "", - "$it", - true, - ).toByteArray(), - ) + injection.assetPath.endsWith(".css", ignoreCase = true) -> { + """""" + } + + else -> { + null + } } } - PluginLog.d(TAG, "Skip injecting - already done for: $filename") - return@TransformingResource Try.success(bytes) - } - - PluginLog.d(TAG, "Injecting files into: $filename") - - val injectLines = - listOf( - """""", - """""", - """ $injectStyle - """, - ) + """ + + val allLines = assetLines + listOf(platformScript) + listOfNotNull(injectStyle) + val newBlock = "$INJECT_START_MARKER\n${allLines.joinToString("\n")}\n$INJECT_END_MARKER" + + val startIdx = content.indexOf(INJECT_START_MARKER) + if (startIdx != -1) { + val endIdx = content.indexOf(INJECT_END_MARKER, startIdx) + if (endIdx == -1) { + PluginLog.w(TAG, "Injection start marker found without end marker in: $filename") + } else { + val existingBlock = content.substring(startIdx, endIdx + INJECT_END_MARKER.length) + if (existingBlock == newBlock) { + PluginLog.d(TAG, "Skip injecting - no changes for: $filename") + return@TransformingResource Try.success(bytes) + } + PluginLog.d(TAG, "Replacing injection block for: $filename") + val newContent = + content.substring(0, startIdx) + + newBlock + + content.substring(endIdx + INJECT_END_MARKER.length) + return@TransformingResource Try.success(newContent.toByteArray()) + } + } + + PluginLog.d(TAG, "Injecting files into: $filename") val newContent = StringBuilder(content) - .insert(headEndIndex, "\n" + injectLines.joinToString("\n") + "\n") + .insert(headEndIndex, "\n$newBlock\n") .toString() - Try.success(newContent.toByteArray()) } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt index e82ef725..f5e75819 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt @@ -81,7 +81,7 @@ import kotlin.time.Duration private const val TAG = "ReadiumReader" -private val stateKey = "dk.nota.flutterreadium.ReadiumReaderState" +private const val stateKey = "dk.nota.flutterreadium.ReadiumReaderState" private val currentPublicationUrlKey = "currentPublicationUrl" private val ttsEnabledKey = "ttsEnabled" @@ -498,6 +498,12 @@ object ReadiumReader : /** Selection actions configured from Dart. Used by EpubReaderFragment to build ActionMode menu. */ var selectionActions: List = emptyList() + /** Extra CSS assets injected alongside the built-in helpers. */ + var cssInjections: List = emptyList() + + /** Extra JavaScript assets injected alongside the built-in helpers. */ + var javaScriptInjections: List = emptyList() + private val context: Context get() = application.applicationContext @@ -678,7 +684,11 @@ object ReadiumReader : val epubPreferences = navigator.preferences?.effectiveForLayout(publication.metadata.layout) if (url.extension?.value?.endsWith("html", ignoreCase = true) == true) { - resource.injectScriptsAndStyles(tocIds, epubPreferences) + resource.injectScriptsAndStyles( + tocIds, + epubPreferences, + javaScriptInjections + cssInjections, + ) } else { resource } diff --git a/flutter_readium/example/integration_test/plugin_integration_test.dart b/flutter_readium/example/integration_test/plugin_integration_test.dart index ccf5084b..f40e0958 100644 --- a/flutter_readium/example/integration_test/plugin_integration_test.dart +++ b/flutter_readium/example/integration_test/plugin_integration_test.dart @@ -898,7 +898,7 @@ void main() { await _waitUntil( () => states.last.currentOffset != null && states.last.currentOffset! >= expectedMinOffset, - timeout: const Duration(seconds: 10), + timeout: const Duration(seconds: 110), reason: 'currentOffset did not advance after audioSeekBy($seekDuration)', ); diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift index 481af0e4..02768caa 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -5,6 +5,22 @@ import MediaPlayer import ReadiumNavigator import ReadiumShared +/// A Flutter asset (JS or CSS) to inject into every EPUB HTML resource. +struct InjectionAsset { + let assetPath: String + let packageName: String? + + init(assetPath: String, packageName: String? = nil) { + self.assetPath = assetPath + self.packageName = packageName + } + + init(from map: [String: Any?]) { + assetPath = map["assetPath"] as! String + packageName = map["package"] as? String + } +} + public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.WarningLogger, TimebasedListener { static var registrar: FlutterPluginRegistrar? = nil @@ -14,6 +30,12 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin public var currentPublication: Publication? public var currentReaderView: (any ReadiumReaderView)? + /// Extra CSS assets injected alongside the built-in helpers. + var cssInjections: [InjectionAsset] = [] + + /// Extra JavaScript assets injected alongside the built-in helpers. + var javaScriptInjections: [InjectionAsset] = [] + /// Incremented each time a new publication is successfully opened. /// Used to guard against stale `closePublication` calls from a previous /// Dart session (hot restart) clobbering a freshly opened publication. @@ -194,6 +216,14 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin } } } + case "setCssInjections": + let items = call.arguments as? [[String: Any?]] ?? [] + self.cssInjections = items.map { InjectionAsset(from: $0) } + result(nil) + case "setJavaScriptInjections": + let items = call.arguments as? [[String: Any?]] ?? [] + self.javaScriptInjections = items.map { InjectionAsset(from: $0) } + result(nil) case "setCustomHeaders": guard let args = call.arguments as? [String: Any], let httpHeaders = args["httpHeaders"] as? [String: String] else { diff --git a/flutter_readium/lib/flutter_readium.dart b/flutter_readium/lib/flutter_readium.dart index d113dea0..1f5dc940 100644 --- a/flutter_readium/lib/flutter_readium.dart +++ b/flutter_readium/lib/flutter_readium.dart @@ -37,6 +37,17 @@ class FlutterReadium { _platform.setDefaultPreferences(preferences); } + /// Registers extra CSS assets to inject into every EPUB HTML resource, + /// in addition to the built-in `flutterReadiumTools.css`. + /// Call before opening a publication so the injections are active when the reader view is created. + Future setCssInjections(List injections) => _platform.setCssInjections(injections); + + /// Registers extra JavaScript assets to inject into every EPUB HTML resource, + /// in addition to the built-in `flutterReadiumTools.js`. + /// Call before opening a publication so the injections are active when the reader view is created. + Future setJavaScriptInjections(List injections) => + _platform.setJavaScriptInjections(injections); + /// Loads a publication from the given URL and returns a [Publication] object representing its metadata and structure. This does not open the publication for reading. Future loadPublication(String pubUrl) => _platform.loadPublication(pubUrl); diff --git a/flutter_readium/test/flutter_readium_test.dart b/flutter_readium/test/flutter_readium_test.dart index cb97e9a8..7da689ff 100644 --- a/flutter_readium/test/flutter_readium_test.dart +++ b/flutter_readium/test/flutter_readium_test.dart @@ -140,6 +140,12 @@ class MockFlutterReadiumPlatform with MockPlatformInterfaceMixin implements Flut @override Future setLogLevel(LogLevel level) async {} + @override + Future setCssInjections(List injections) async {} + + @override + Future setJavaScriptInjections(List injections) async {} + @override Future getResourceBytes(String href) async => Uint8List(0); diff --git a/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart b/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart index 1888b683..63bd0d2e 100644 --- a/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart +++ b/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart @@ -67,6 +67,18 @@ abstract class FlutterReadiumPlatform extends PlatformInterface { /// Sets the log verbosity of the plugin's internal logging system, for both Dart and native code. Future setLogLevel(LogLevel level) => throw UnimplementedError('setLogLevel() has not been implemented.'); + /// Registers extra CSS assets to inject into every EPUB HTML resource, + /// in addition to the built-in `flutterReadiumTools.css`. + /// Call before opening a publication so the injections are in effect when the reader view is created. + Future setCssInjections(List injections) => + throw UnimplementedError('setCssInjections() has not been implemented.'); + + /// Registers extra JavaScript assets to inject into every EPUB HTML resource, + /// in addition to the built-in `flutterReadiumTools.js`. + /// Call before opening a publication so the injections are in effect when the reader view is created. + Future setJavaScriptInjections(List injections) => + throw UnimplementedError('setJavaScriptInjections() has not been implemented.'); + /// Stores [preferences] as the default EPUB preferences applied to future publications. void setDefaultPreferences(EPUBPreferences preferences) { defaultPreferences = preferences; diff --git a/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart b/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart index 07d69fcd..07b7d44a 100644 --- a/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart +++ b/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart @@ -138,6 +138,22 @@ class MethodChannelFlutterReadium extends FlutterReadiumPlatform { ReadiumLog.setLevel(level); } + @override + Future setCssInjections(List injections) async { + await methodChannel.invokeMethod( + 'setCssInjections', + injections.map((e) => e.toJson()).toList(), + ); + } + + @override + Future setJavaScriptInjections(List injections) async { + await methodChannel.invokeMethod( + 'setJavaScriptInjections', + injections.map((e) => e.toJson()).toList(), + ); + } + @override Future openPublication(String pubUrl) async { final publicationString = await methodChannel diff --git a/flutter_readium_platform_interface/lib/src/shared/index.dart b/flutter_readium_platform_interface/lib/src/shared/index.dart index cb46b91f..770e1dab 100644 --- a/flutter_readium_platform_interface/lib/src/shared/index.dart +++ b/flutter_readium_platform_interface/lib/src/shared/index.dart @@ -1,5 +1,6 @@ export 'epub.dart'; export 'guided_navigation.dart'; +export 'injection_asset.dart'; export 'mediatype.dart'; export 'opds.dart'; export 'publication.dart';