From cb5f9cdb9cfb7dff9272bb374b9a3733ae27bf19 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Sun, 21 Jun 2026 17:24:03 +0200 Subject: [PATCH 1/3] fix(ios): prevent MO paragraph column-breaks for audio/visual sync In paginated CSS-column layout, a paragraph straddling two columns would play its full audio while the reader stayed pinned to column 1, leaving the column-2 continuation invisible. When media-overlay playback starts, inject `p { break-inside: avoid !important; }` so each paragraph stays whole on one page, keeping audio and visible text in sync. Exposed as `EPUBPreferences.preventMOColumnBreaks` (default `true`) so consumers can opt out if they prefer the original layout. Re-evaluated whenever preferences are updated mid-session. The `ReadiumReaderView` protocol gains `setMOActive(_:)` to separate the "MO is active" signal from the CSS-injection decision, which lives entirely in EPUBReaderView. Also adds `.npmrc` to the helper-scripts package to suppress `min-release-age` for the git-sourced `readium-css` dependency, which caused `npm install` to fail when a global `min-release-age` was set. Co-Authored-By: Claude Sonnet 4.6 --- flutter_readium/CHANGELOG.md | 20 +++++++- flutter_readium/assets/_helper_scripts/.npmrc | 7 +++ .../flutter_readium/EPUBReaderView.swift | 47 +++++++++++++++++++ .../FlutterReadiumPlugin.swift | 10 ++++ .../flutter_readium/PDFReaderView.swift | 4 ++ .../flutter_readium/ReadiumReaderView.swift | 4 ++ .../model/FlutterEPUBPreferences.swift | 10 +++- .../CHANGELOG.md | 5 ++ .../src/reader/reader_epub_preferences.dart | 18 ++++++- .../test/models_test.dart | 47 +++++++++++++++++++ 10 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 flutter_readium/assets/_helper_scripts/.npmrc diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index 68b2f895..fe9e806c 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -11,13 +11,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). `EPUBPreferences.disableSynchronization` is turned back off (`true -> false`), the visual EPUB navigator now jumps to the last sync locator that was reached while synchronization was disabled, matching Android behavior. +- **iOS: Media Overlay clips that span a CSS column boundary no longer desync** — in + paginated mode, a paragraph straddling two columns would start audio on the first + column but continue playing through text visible only on the second column. When + media-overlay playback is active, a `break-inside: avoid` CSS rule is now injected + so each paragraph stays whole on one page, keeping audio and visible text in sync. + Controlled by `EPUBPreferences.preventMOColumnBreaks` (default `true`; set to + `false` to opt out and restore the original layout). +- **iOS: TTS no longer snaps back to the previous page mid-sentence** — when a spoken sentence + crossed a paginated page boundary, the reader correctly advanced to page N+1 for the word being + spoken but then flickered back to page N on each subsequent word. The cause was a double-assignment + to the `@Published playingUtterance` property (raw locator, then position mutation), which defeated + `removeDuplicates()` and fired the page-sync on every word update instead of only on utterance + changes. +- **Web: Improved error-handling** - `ttsEnable`, `audioEnable`, and `ttsGetAvailableVoices` failures are + now caught and `.stack` is now included in `PlatformException.message`. +- **iOS: early reader events are no longer dropped** — the `text-locator` and + `reader-status` event channels now buffer the most-recent event on the native + side when Dart has not yet attached a listener. The buffer is flushed + immediately when `onListen` fires. ## [0.1.0] - 2026-06-20 Brings the Web platform up to feature parity with iOS / Android (audio, Media Overlay, TTS, Guided Navigation, decorations), plus a handful of supporting cross-platform additions. - ### Added - **Web: Audio Navigator** — audiobook publications now play on web. `audioEnable`, diff --git a/flutter_readium/assets/_helper_scripts/.npmrc b/flutter_readium/assets/_helper_scripts/.npmrc new file mode 100644 index 00000000..212e5b01 --- /dev/null +++ b/flutter_readium/assets/_helper_scripts/.npmrc @@ -0,0 +1,7 @@ +# This package installs a git dependency (readium-css from github:readium/readium-css). +# npm's git-dep preparation spawns a sub-process with --before= derived from +# min-release-age, and that sub-process also inherits min-release-age from the global +# config — npm rejects the combination. Disable min-release-age here since it applies +# to registry packages only; a pinned git dep and private build tooling are outside +# the supply-chain threat model it protects against. +min-release-age=0 diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/EPUBReaderView.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/EPUBReaderView.swift index 659549cd..80ded94c 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/EPUBReaderView.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/EPUBReaderView.swift @@ -15,6 +15,8 @@ public class EPUBReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, E private var hasSentReady = false private var isJumpingToLocator = false private var lastHrefLocation: String? + private var isMOActive = false + private var shouldPreventColumnBreaks: Bool { isMOActive && (preferences?.preventMOColumnBreaks ?? true) } private var preferences: FlutterEPUBPreferences? private var lastSyncLocator: Locator? private var lastSyncSegmentDuration: TimeInterval? @@ -267,6 +269,9 @@ public class EPUBReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, E if let preferences = self.preferences { updateCustomPreferences(preferences) } + if shouldPreventColumnBreaks { + injectColumnBreakCSS() + } } emitOnPageChanged(locator: locator) } @@ -391,8 +396,10 @@ public class EPUBReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, E } private func setUserPreferences(preferences: FlutterEPUBPreferences) { + self.preferences = preferences self.readiumViewController.submitPreferences(preferences.readium) self.updateCustomPreferences(preferences) + applyColumnBreakPrevention() } private func updateCustomPreferences(_ preferences: FlutterEPUBPreferences) { @@ -407,6 +414,46 @@ public class EPUBReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, E } } + public func setMOActive(_ active: Bool) { + isMOActive = active + applyColumnBreakPrevention() + } + + private func applyColumnBreakPrevention() { + if shouldPreventColumnBreaks { + injectColumnBreakCSS() + } else { + removeColumnBreakCSS() + } + } + + private func injectColumnBreakCSS() { + Task.detached(priority: .high) { + let js = """ + (function(){ + if (document.getElementById('flutter-readium-mo-breaks')) return; + var s = document.createElement('style'); + s.id = 'flutter-readium-mo-breaks'; + s.textContent = 'p { break-inside: avoid !important; }'; + document.head && document.head.appendChild(s); + })(); + """ + await self.readiumViewController.evaluateJavaScript(js) + } + } + + private func removeColumnBreakCSS() { + Task.detached(priority: .high) { + let js = """ + (function(){ + var s = document.getElementById('flutter-readium-mo-breaks'); + if (s) s.parentNode.removeChild(s); + })(); + """ + await self.readiumViewController.evaluateJavaScript(js) + } + } + private func emitOnPageChanged(locator: Locator) -> Void { Log.reader.debug("emitOnPageChanged, locator: \(locator)") 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 063a0a04..38c936a5 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -307,10 +307,14 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin case "stop": Task { @MainActor in if self.timebasedNavigator != nil { + let wasMO = self.timebasedNavigator is FlutterMediaOverlayNavigator self.timebasedNavigator?.dispose() self.timebasedNavigator = nil self.timebasedPlayerStateStreamHandler?.sendEvent(ReadiumTimebasedState(state: .none).toJsonString()) self.updateReaderViewTimebasedDecorations([]) + if wasMO { + self.currentReaderView?.setMOActive(false) + } } } result(nil) @@ -436,6 +440,12 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin self.timebasedNavigator?.listener = self await self.timebasedNavigator?.initNavigator() + if self.timebasedNavigator is FlutterMediaOverlayNavigator { + await MainActor.run { + self.currentReaderView?.setMOActive(true) + } + } + await MainActor.run { result(nil) } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/PDFReaderView.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/PDFReaderView.swift index 0c3237cf..ea1a8d52 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/PDFReaderView.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/PDFReaderView.swift @@ -195,6 +195,10 @@ public class PDFReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, PD Log.reader.debug("onCustomEditingAction: not supported for PDF") } + public func setMOActive(_ active: Bool) { + // PDF has no CSS column layout — no-op. + } + // MARK: - Locator emission private func emitOnPageChanged(locator: Locator) -> Void { diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift index 0603f091..46c4f066 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift @@ -46,4 +46,8 @@ public protocol ReadiumReaderView: AnyObject { func syncToLocator(_ locator: Locator, animated: Bool, segmentDuration: TimeInterval?, isWordRange: Bool) async -> Bool func applyDecorations(_ decorations: [Decoration], forGroup groupIdentifier: String) func onCustomEditingAction() -> Void + /// Called when media-overlay playback starts (`active: true`) or stops (`active: false`). + /// The reader view decides whether to inject column-break-prevention CSS based on this + /// flag combined with the `preventMOColumnBreaks` preference. + func setMOActive(_ active: Bool) } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterEPUBPreferences.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterEPUBPreferences.swift index c0c28637..6403c94f 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterEPUBPreferences.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterEPUBPreferences.swift @@ -3,6 +3,7 @@ import ReadiumNavigator let blackAndWhiteComicModeKey: String = "blackAndWhiteComicMode"; let disableSynchronizationKey: String = "disableSynchronization"; let firstElementTopMarginKey: String = "firstElementTopMargin"; +let preventMOColumnBreaksKey: String = "preventMOColumnBreaks"; let topMarginCssVariable = "--FLUTTER_READIUM-first-element-top-margin" let blackAndWhiteComicModeCssVariable = "--FLUTTER_READIUM-black-white-comic-mode"; @@ -18,6 +19,9 @@ public struct FlutterEPUBPreferences { /// Top margin to the first element in the content. /// This is used to create space for UI elements like a toolbar without overlapping the content. public var firstElementTopMargin: Int? + /// When true (default), prevents paragraph elements from splitting across CSS columns during + /// media-overlay playback. Nil means the Dart-side default (true) applies. + public var preventMOColumnBreaks: Bool? init() { readium = EPUBPreferences.init(); @@ -38,7 +42,11 @@ public struct FlutterEPUBPreferences { self.firstElementTopMargin = firstElementTopMargin mutableMap.removeValue(forKey: firstElementTopMarginKey); } - + if let preventMOColumnBreaks = jsonMap[preventMOColumnBreaksKey] as? Bool { + self.preventMOColumnBreaks = preventMOColumnBreaks + mutableMap.removeValue(forKey: preventMOColumnBreaksKey); + } + readium = EPUBPreferences.init(fromMap: mutableMap) } diff --git a/flutter_readium_platform_interface/CHANGELOG.md b/flutter_readium_platform_interface/CHANGELOG.md index 723c830a..a8027a3c 100644 --- a/flutter_readium_platform_interface/CHANGELOG.md +++ b/flutter_readium_platform_interface/CHANGELOG.md @@ -18,6 +18,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). per-item Sync Narration media-overlay format used by the Readium ts-toolkit (web). - `TaggedReadiumLog` — `ReadiumLog.tag('Name')` factory creating child loggers named `flutter_readium.`, surfacing the source / area in log records. +- `EPUBPreferences.preventMOColumnBreaks` (`bool`, default `true`) — when `true`, + prevents paragraph elements from splitting across CSS columns during media-overlay + playback, keeping audio and visible text in sync on paginated iOS layouts. Set to + `false` to opt out and preserve the EPUB's original column layout. Has no effect + outside of media-overlay mode. ### Changed diff --git a/flutter_readium_platform_interface/lib/src/reader/reader_epub_preferences.dart b/flutter_readium_platform_interface/lib/src/reader/reader_epub_preferences.dart index 1eb584b7..b988dfc8 100644 --- a/flutter_readium_platform_interface/lib/src/reader/reader_epub_preferences.dart +++ b/flutter_readium_platform_interface/lib/src/reader/reader_epub_preferences.dart @@ -36,6 +36,7 @@ class EPUBPreferences with EquatableMixin implements JSONable { this.blackAndWhiteComicMode = false, this.disableSynchronization = false, this.firstElementTopMargin, + this.preventMOColumnBreaks = true, }); /// Default page background color. @@ -125,6 +126,11 @@ class EPUBPreferences with EquatableMixin implements JSONable { /// This is used to create space for UI elements like a toolbar without overlapping the content. final int? firstElementTopMargin; + /// When true (default), prevents paragraph elements from splitting across CSS columns during + /// media-overlay playback. This ensures audio and visible text stay in sync when a paragraph + /// would otherwise straddle a column boundary. Has no effect outside of media-overlay mode. + final bool preventMOColumnBreaks; + factory EPUBPreferences.fromJson(Map json) { final jsonObject = Map.of(json); final backgroundColorStr = jsonObject.optNullableString( @@ -212,6 +218,11 @@ class EPUBPreferences with EquatableMixin implements JSONable { 'firstElementTopMargin', remove: true, ); + final preventMOColumnBreaks = jsonObject.optBoolean( + 'preventMOColumnBreaks', + fallback: true, + remove: true, + ); return EPUBPreferences( backgroundColor: backgroundColorStr != null ? ReadiumColorExtension.fromCSS(backgroundColorStr) : null, @@ -241,6 +252,7 @@ class EPUBPreferences with EquatableMixin implements JSONable { blackAndWhiteComicMode: blackAndWhiteComicMode, disableSynchronization: disableSynchronization, firstElementTopMargin: firstElementTopMargin, + preventMOColumnBreaks: preventMOColumnBreaks, ); } @@ -272,7 +284,8 @@ class EPUBPreferences with EquatableMixin implements JSONable { ..putOpt('wordSpacing', wordSpacing) ..put('blackAndWhiteComicMode', blackAndWhiteComicMode) ..put('disableSynchronization', disableSynchronization) - ..putOpt('firstElementTopMargin', firstElementTopMargin); + ..putOpt('firstElementTopMargin', firstElementTopMargin) + ..put('preventMOColumnBreaks', preventMOColumnBreaks); EPUBPreferences copyWith({ Color? backgroundColor, @@ -302,6 +315,7 @@ class EPUBPreferences with EquatableMixin implements JSONable { bool? blackAndWhiteComicMode, bool? disableSynchronization, int? firstElementTopMargin, + bool? preventMOColumnBreaks, }) => EPUBPreferences( backgroundColor: backgroundColor ?? this.backgroundColor, columnCount: columnCount ?? this.columnCount, @@ -330,6 +344,7 @@ class EPUBPreferences with EquatableMixin implements JSONable { blackAndWhiteComicMode: blackAndWhiteComicMode ?? this.blackAndWhiteComicMode, disableSynchronization: disableSynchronization ?? this.disableSynchronization, firstElementTopMargin: firstElementTopMargin ?? this.firstElementTopMargin, + preventMOColumnBreaks: preventMOColumnBreaks ?? this.preventMOColumnBreaks, ); @override @@ -361,6 +376,7 @@ class EPUBPreferences with EquatableMixin implements JSONable { blackAndWhiteComicMode, disableSynchronization, firstElementTopMargin, + preventMOColumnBreaks, ]; } diff --git a/flutter_readium_platform_interface/test/models_test.dart b/flutter_readium_platform_interface/test/models_test.dart index 94a8048b..3cc46948 100644 --- a/flutter_readium_platform_interface/test/models_test.dart +++ b/flutter_readium_platform_interface/test/models_test.dart @@ -57,6 +57,53 @@ void main() { }); }); + // --------------------------------------------------------------------------- + // EPUBPreferences serialisation + // --------------------------------------------------------------------------- + group('EPUBPreferences', () { + test('round-trips preventMOColumnBreaks: true through toJson / fromJson', () { + const prefs = EPUBPreferences(preventMOColumnBreaks: true); + final restored = EPUBPreferences.fromJson(prefs.toJson()); + expect(restored.preventMOColumnBreaks, isTrue); + }); + + test('round-trips preventMOColumnBreaks: false through toJson / fromJson', () { + const prefs = EPUBPreferences(preventMOColumnBreaks: false); + final restored = EPUBPreferences.fromJson(prefs.toJson()); + expect(restored.preventMOColumnBreaks, isFalse); + }); + + test('toJson emits preventMOColumnBreaks under the correct key', () { + const prefs = EPUBPreferences(preventMOColumnBreaks: false); + final json = prefs.toJson(); + expect(json.containsKey('preventMOColumnBreaks'), isTrue); + expect(json['preventMOColumnBreaks'], isFalse); + }); + + test('fromJson defaults preventMOColumnBreaks to true when key is absent', () { + final restored = EPUBPreferences.fromJson({}); + expect(restored.preventMOColumnBreaks, isTrue); + }); + + test('copyWith preserves preventMOColumnBreaks when not overridden', () { + const prefs = EPUBPreferences(preventMOColumnBreaks: false); + final copied = prefs.copyWith(); + expect(copied.preventMOColumnBreaks, isFalse); + }); + + test('copyWith overrides preventMOColumnBreaks', () { + const prefs = EPUBPreferences(preventMOColumnBreaks: false); + final copied = prefs.copyWith(preventMOColumnBreaks: true); + expect(copied.preventMOColumnBreaks, isTrue); + }); + + test('equality distinguishes preventMOColumnBreaks values', () { + const a = EPUBPreferences(preventMOColumnBreaks: true); + const b = EPUBPreferences(preventMOColumnBreaks: false); + expect(a, isNot(equals(b))); + }); + }); + group('PDFPreferences', () { const prefs = PDFPreferences( layout: PDFLayout.scrollVertical, From 34bc8497184ffa39d2fb195405a09dfdbd4538f1 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Tue, 23 Jun 2026 16:58:54 +0200 Subject: [PATCH 2/3] fix(android,web): prevent MO paragraph column-breaks for audio/visual sync Extends the iOS fix to Android and Web. When media-overlay playback starts, injectMOBreakCSS() / removeMOBreakCSS() are called via the shared window.flutterReadium helper-script bundle (FlutterReadiumTools.ts) so the CSS lives in one place and each platform only calls one-line JS. - Android: inject on MO start, remove on stop, re-inject on spine-item change (onPageChanged), and re-evaluate when preferences change mid-session. - Web: inject on audioEnable (MO path), remove on stop, re-inject in the frameLoaded callback (via onFrameLoaded hook in FlutterEpubNavigator), and re-evaluate when setEPUBPreferences is called mid-session. - Helper scripts: adds injectMOBreakCSS() and removeMOBreakCSS() to FlutterReadiumTools; rebuilds the rollup bundle. - Dart: preventMOColumnBreaks field already present from iOS commit; Android also wires it in FlutterEpubPreferences.kt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- flutter_readium/CHANGELOG.md | 2 + .../flutterreadium/FlutterEpubPreferences.kt | 2 + .../dk/nota/flutterreadium/ReadiumReader.kt | 27 +++++++++++ .../flutterreadium/ReadiumReaderWidget.kt | 5 +++ .../src/FlutterReadiumTools.ts | 17 +++++++ flutter_readium/web/src/ReadiumReader.ts | 45 +++++++++++++++++-- .../src/navigators/FlutterEpubNavigator.ts | 4 +- .../src/preferences/FlutterEpubPreferences.ts | 13 ++++++ 8 files changed, 110 insertions(+), 5 deletions(-) diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index fe9e806c..4f5a7ec1 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -18,6 +18,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). so each paragraph stays whole on one page, keeping audio and visible text in sync. Controlled by `EPUBPreferences.preventMOColumnBreaks` (default `true`; set to `false` to opt out and restore the original layout). +- **Android + Web: Media Overlay clips that span a CSS column boundary no longer desync** — same fix + as iOS above, now applied to Android and Web via the shared `flutterReadium` helper-script bundle. - **iOS: TTS no longer snaps back to the previous page mid-sentence** — when a spoken sentence crossed a paginated page boundary, the reader correctly advanced to page N+1 for the word being spoken but then flickered back to page N on each subsequent word. The cause was a double-assignment diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterEpubPreferences.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterEpubPreferences.kt index 8b0f9d53..8373c9ba 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterEpubPreferences.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterEpubPreferences.kt @@ -48,6 +48,7 @@ data class FlutterEpubPreferences( val blackAndWhiteComicMode: Boolean? = false, val disableSynchronization: Boolean? = false, val firstElementTopMargin: Int? = null, + val preventMOColumnBreaks: Boolean? = true, ) : Configurable.Preferences { override fun plus(other: FlutterEpubPreferences): FlutterEpubPreferences = FlutterEpubPreferences( @@ -78,6 +79,7 @@ data class FlutterEpubPreferences( blackAndWhiteComicMode = other.blackAndWhiteComicMode ?: blackAndWhiteComicMode, disableSynchronization = other.disableSynchronization ?: disableSynchronization, firstElementTopMargin = other.firstElementTopMargin ?: firstElementTopMargin, + preventMOColumnBreaks = other.preventMOColumnBreaks ?: preventMOColumnBreaks, ) fun toEpubPreferences(): EpubPreferences = 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 c4f65bb1..758f2597 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 @@ -164,6 +164,16 @@ object ReadiumReader : private var audiobookNavigator: AudiobookNavigator? = null private var syncAudiobookNavigator: SyncAudiobookNavigator? = null + /** + * Mirrors `EPUBPreferences.preventMOColumnBreaks`. Defaults to `true`. + * Consumer can opt out via preferences. + */ + private var _preventMOColumnBreaks: Boolean = true + + /** True when a Media Overlay (sync-narration) navigator is active. */ + val isMOActive: Boolean + get() = syncAudiobookNavigator != null + private val timebasedNavigator: TimebasedNavigator<*>? get() = audiobookNavigator ?: syncAudiobookNavigator ?: ttsNavigator @@ -1130,12 +1140,16 @@ object ReadiumReader : audiobookNavigator = null } + val wasMOActive = isMOActive syncAudiobookNavigator?.apply { pause() dispose() syncAudiobookNavigator = null } + if (wasMOActive) { + epubEvaluateJavascript("window.flutterReadium.removeMOBreakCSS()") + } ttsNavigator?.apply { pause() @@ -1289,6 +1303,9 @@ object ReadiumReader : ).apply { initNavigator() } + if (_preventMOColumnBreaks) { + epubEvaluateJavascript("window.flutterReadium.injectMOBreakCSS()") + } } } @@ -1367,6 +1384,16 @@ object ReadiumReader : return } + val newPreventBreaks = preferences.preventMOColumnBreaks ?: true + if (isMOActive && newPreventBreaks != _preventMOColumnBreaks) { + if (newPreventBreaks) { + epubEvaluateJavascript("window.flutterReadium.injectMOBreakCSS()") + } else { + epubEvaluateJavascript("window.flutterReadium.removeMOBreakCSS()") + } + } + _preventMOColumnBreaks = newPreventBreaks + navigator.updatePreferences(preferences) } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReaderWidget.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReaderWidget.kt index 091997f5..13d52b4e 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReaderWidget.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReaderWidget.kt @@ -203,6 +203,11 @@ class ReadiumReaderWidget( } emitOnPageChanged(pageIndex, totalPages, locator) + + // Re-inject MO column-break CSS for each freshly-loaded spine item. + if (ReadiumReader.isMOActive) { + ReadiumReader.epubEvaluateJavascript("window.flutterReadium.injectMOBreakCSS()") + } } } diff --git a/flutter_readium/assets/_helper_scripts/src/FlutterReadiumTools.ts b/flutter_readium/assets/_helper_scripts/src/FlutterReadiumTools.ts index 15a0b56a..367c5024 100644 --- a/flutter_readium/assets/_helper_scripts/src/FlutterReadiumTools.ts +++ b/flutter_readium/assets/_helper_scripts/src/FlutterReadiumTools.ts @@ -45,6 +45,23 @@ class FlutterReadiumTools { }; } + /** + * Inject a style tag that prevents paragraphs from splitting across CSS columns during + * Media Overlay playback. Idempotent: no-op if the tag is already present. + */ + public injectMOBreakCSS(): void { + if (document.getElementById('flutter-readium-mo-breaks')) return; + const style = document.createElement('style'); + style.id = 'flutter-readium-mo-breaks'; + style.textContent = 'p { break-inside: avoid !important }'; + document.head.appendChild(style); + } + + /** Remove the MO column-break style tag injected by injectMOBreakCSS. No-op if absent. */ + public removeMOBreakCSS(): void { + document.getElementById('flutter-readium-mo-breaks')?.remove(); + } + /** * Find current page information, including physical page, css selector of the current position, and the nearest ToC element id. */ diff --git a/flutter_readium/web/src/ReadiumReader.ts b/flutter_readium/web/src/ReadiumReader.ts index ed23297e..50020790 100644 --- a/flutter_readium/web/src/ReadiumReader.ts +++ b/flutter_readium/web/src/ReadiumReader.ts @@ -21,7 +21,7 @@ import { FlutterAudioNavigator, setAudioEmissionsEnabled, seekAudioAndResume } f import { FlutterTTSNavigator } from "./navigators/FlutterTTSNavigator"; import { initializeMediaOverlayNavigator, initializeGuidedNavigationNavigator } from "./navigators/FlutterMediaOverlayNavigator"; // Preferences -import { setEpubPreferencesFromString } from "./preferences/FlutterEpubPreferences"; +import { setEpubPreferencesFromString, pluginPrefsFromJson } from "./preferences/FlutterEpubPreferences"; import { ttsPreferencesFromJson } from "./preferences/FlutterTTSPreferences"; import { applyAudioPreferences } from "./preferences/FlutterAudioPreferences"; // Sync narration @@ -64,6 +64,8 @@ class _ReadiumReader { * preference surface. Passed to the TTS engine on enable and on every change. */ private _disableSynchronization = false; + /** Mirrors `EPUBPreferences.preventMOColumnBreaks`. Defaults to `true`. */ + private _preventMOColumnBreaks = true; /** Last visual sync locator deferred while synchronization was disabled. */ private _lastDeferredSyncLocator: Locator | null = null; /** Segment duration paired with `_lastDeferredSyncLocator` when available. */ @@ -292,7 +294,13 @@ class _ReadiumReader { this._nav = nav; this._bridge.emitReaderStatus(ReadiumReaderStatus.ready); }, - (positions) => { this._positions = positions; } + (positions) => { this._positions = positions; }, + (wnd) => { + // Re-inject MO column-break CSS into freshly-loaded frames when MO is active. + if (this._audioNav && this._preventMOColumnBreaks) { + (wnd as any).flutterReadium?.injectMOBreakCSS?.(); + } + } ); } else { log.info("Publication conforms to WebPub profile"); @@ -327,7 +335,7 @@ class _ReadiumReader { // navigator's preferences (the web navigator doesn't expose this toggle). try { const wasSyncDisabled = this._disableSynchronization; - const parsed = JSON.parse(newPreferencesString) as { disableSynchronization?: boolean }; + const parsed = JSON.parse(newPreferencesString) as Record; this._disableSynchronization = parsed.disableSynchronization === true; this._ttsEngine?.setSyncEnabled(!this._disableSynchronization); if (wasSyncDisabled && !this._disableSynchronization && this._lastDeferredSyncLocator) { @@ -339,6 +347,15 @@ class _ReadiumReader { this._lastMediaOverlayLocatorKey = null; this._syncVisualToMediaOverlayLocator(deferredLocator, "MediaOverlay (resume sync)", deferredDurationMs); } + const { preventMOColumnBreaks } = pluginPrefsFromJson(parsed); + if (preventMOColumnBreaks !== this._preventMOColumnBreaks && this._audioNav) { + if (preventMOColumnBreaks) { + this._injectMOBreakCSSOnIframes(); + } else { + this._removeMOBreakCSSOnIframes(); + } + } + this._preventMOColumnBreaks = preventMOColumnBreaks; } catch (_) { // Ignore parse errors — setEpubPreferencesFromString will surface them. } @@ -495,11 +512,29 @@ class _ReadiumReader { public stop(): void { log.debug("stop"); if (this._ttsEngine) { this._ttsEngine.stop(); return; } + const wasMO = this._hasSyncNarration || this._hasGuidedNavigation; this._audioNav?.stop(); // Clear Media Overlay / Guided Navigation utterance decoration when narration stops. - if ((this._hasSyncNarration || this._hasGuidedNavigation) && this._nav) { + if (wasMO && this._nav) { this._lastMediaOverlayLocatorKey = null; this.applyDecorations("media_overlay_utterance", "[]"); + this._removeMOBreakCSSOnIframes(); + } + } + + /** Inject MO column-break CSS into all loaded EPUB iframes via the helper script. */ + private _injectMOBreakCSSOnIframes(): void { + if (!this._nav) return; + for (const wnd of navIframeWindows(this._nav)) { + (wnd as any).flutterReadium?.injectMOBreakCSS?.(); + } + } + + /** Remove MO column-break CSS from all loaded EPUB iframes via the helper script. */ + private _removeMOBreakCSSOnIframes(): void { + if (!this._nav) return; + for (const wnd of navIframeWindows(this._nav)) { + (wnd as any).flutterReadium?.removeMOBreakCSS?.(); } } @@ -825,6 +860,7 @@ class _ReadiumReader { (textLocator, durationMs) => this._syncVisualToMediaOverlayLocator(textLocator, "GuidedNavigation", durationMs) ); (this._audioNav as AudioNavigator | undefined)?.play(); + if (this._preventMOColumnBreaks) this._injectMOBreakCSSOnIframes(); return; } @@ -839,6 +875,7 @@ class _ReadiumReader { (textLocator, durationMs) => this._syncVisualToMediaOverlayLocator(textLocator, "MediaOverlay", durationMs) ); (this._audioNav as AudioNavigator | undefined)?.play(); + if (this._preventMOColumnBreaks) this._injectMOBreakCSSOnIframes(); return; } diff --git a/flutter_readium/web/src/navigators/FlutterEpubNavigator.ts b/flutter_readium/web/src/navigators/FlutterEpubNavigator.ts index ddf9b058..13a3bc5b 100644 --- a/flutter_readium/web/src/navigators/FlutterEpubNavigator.ts +++ b/flutter_readium/web/src/navigators/FlutterEpubNavigator.ts @@ -56,7 +56,8 @@ export class FlutterEpubNavigator { initialPosition: Locator | undefined, preferencesJsonString: string, setNav: (nav: EpubNavigator | WebPubNavigator) => void, - setPositions?: (positions: Locator[]) => void + setPositions?: (positions: Locator[]) => void, + onFrameLoaded?: (wnd: Window) => void, ): Promise { log.info("Initializing EpubNavigator"); let positions = await publication.positionsFromManifest(); @@ -131,6 +132,7 @@ export class FlutterEpubNavigator { }) .filter((id) => id.length > 0); injectFlutterReadiumHelperScripts(frameManager.window, tocFragmentIds); + onFrameLoaded?.(frameManager.window); } } ); diff --git a/flutter_readium/web/src/preferences/FlutterEpubPreferences.ts b/flutter_readium/web/src/preferences/FlutterEpubPreferences.ts index a4229ef0..fe70ff5c 100644 --- a/flutter_readium/web/src/preferences/FlutterEpubPreferences.ts +++ b/flutter_readium/web/src/preferences/FlutterEpubPreferences.ts @@ -183,11 +183,24 @@ export function epubPreferencesFromJson( // on native via custom CSS variables. Not wired through on web yet — would require // custom CSS injection into the EPUB iframe. // - disableSynchronization: handled by ReadiumReader (plugin state), not navigator prefs. + // - preventMOColumnBreaks: handled by ReadiumReader (plugin state), not navigator prefs. // --------------------------------------------------------------------------- return normalizeTypes(out); } +/** + * Extract plugin-owned fields from a raw Dart EPUBPreferences JSON object. + * These fields are not forwarded to the navigator but drive plugin-side behaviour. + */ +export function pluginPrefsFromJson(prefs: Record): { + preventMOColumnBreaks: boolean; +} { + return { + preventMOColumnBreaks: prefs.preventMOColumnBreaks !== false, + }; +} + export function initializeEpubPreferencesFromString( preferencesString: string ): IEpubPreferences { From 3dc8e37daccfe369c13a218520539835d2af93d7 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Tue, 23 Jun 2026 17:23:02 +0200 Subject: [PATCH 3/3] fix(web): inject MO column-break CSS directly into iframe DOM Bypass window.flutterReadium for CSS injection on web. The helper script initialises window.flutterReadium inside requestAnimationFrame, so it doesn't exist yet when frameLoaded fires synchronously. Direct DOM manipulation is race-free and makes the rAF timing irrelevant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- flutter_readium/web/src/ReadiumReader.ts | 14 +++++++++----- flutter_readium/web/src/utils/iframeInjection.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/flutter_readium/web/src/ReadiumReader.ts b/flutter_readium/web/src/ReadiumReader.ts index 50020790..712813a4 100644 --- a/flutter_readium/web/src/ReadiumReader.ts +++ b/flutter_readium/web/src/ReadiumReader.ts @@ -29,6 +29,8 @@ import { SyncNarrationItem, detectSyncNarration, textLocatorToAudioLocator } fro import { detectGuidedNavigation } from "./mediaoverlay/guidedNavigation"; // Decoration overrides (for comic/visual sync) import { navIframeWindows } from "./decorations/decorationFrameUtils"; +// Iframe injection utilities +import { injectMOBreakCSSIntoWindow } from "./utils/iframeInjection"; const log = createLogger("Reader"); @@ -297,8 +299,10 @@ class _ReadiumReader { (positions) => { this._positions = positions; }, (wnd) => { // Re-inject MO column-break CSS into freshly-loaded frames when MO is active. + // Use direct DOM manipulation — window.flutterReadium is deferred by rAF + // inside the helper script and may not exist yet at frameLoaded time. if (this._audioNav && this._preventMOColumnBreaks) { - (wnd as any).flutterReadium?.injectMOBreakCSS?.(); + injectMOBreakCSSIntoWindow(wnd); } } ); @@ -522,19 +526,19 @@ class _ReadiumReader { } } - /** Inject MO column-break CSS into all loaded EPUB iframes via the helper script. */ + /** Inject MO column-break CSS into all loaded EPUB iframes directly via the DOM. */ private _injectMOBreakCSSOnIframes(): void { if (!this._nav) return; for (const wnd of navIframeWindows(this._nav)) { - (wnd as any).flutterReadium?.injectMOBreakCSS?.(); + injectMOBreakCSSIntoWindow(wnd); } } - /** Remove MO column-break CSS from all loaded EPUB iframes via the helper script. */ + /** Remove MO column-break CSS from all loaded EPUB iframes directly via the DOM. */ private _removeMOBreakCSSOnIframes(): void { if (!this._nav) return; for (const wnd of navIframeWindows(this._nav)) { - (wnd as any).flutterReadium?.removeMOBreakCSS?.(); + wnd.document.getElementById("flutter-readium-mo-breaks")?.remove(); } } diff --git a/flutter_readium/web/src/utils/iframeInjection.ts b/flutter_readium/web/src/utils/iframeInjection.ts index 07c4c420..c4c535d9 100644 --- a/flutter_readium/web/src/utils/iframeInjection.ts +++ b/flutter_readium/web/src/utils/iframeInjection.ts @@ -37,6 +37,19 @@ async function _fetchHelperAssets(): Promise<{ js: string; css: string } | null> } } +/** + * Inject the MO column-break style tag directly into an iframe window's DOM. + * Idempotent — does nothing if the tag is already present. + */ +export function injectMOBreakCSSIntoWindow(wnd: Window): void { + const doc = wnd.document; + if (doc.getElementById("flutter-readium-mo-breaks")) return; + const style = doc.createElement("style"); + style.id = "flutter-readium-mo-breaks"; + style.textContent = "p { break-inside: avoid !important }"; + doc.head.appendChild(style); +} + /** * Injects the Flutter Readium helper bundle (JS + CSS) and a bootstrap script * into a freshly-loaded EPUB iframe, mirroring what native iOS / Android do via