diff --git a/.github/workflows/integration-test-ios.yml b/.github/workflows/integration-test-ios.yml index b7bd92bf..064e3d05 100644 --- a/.github/workflows/integration-test-ios.yml +++ b/.github/workflows/integration-test-ios.yml @@ -13,12 +13,13 @@ jobs: timeout-minutes: 30 env: - # Pin the simulator model for run-to-run determinism (the WebKit/WKWebView - # version that drives EPUB rendering tracks the runtime, and an unpinned - # "newest available" device silently changes with every runner-image bump). - # Selection falls back to the newest available iPhone if this exact model - # isn't present, so an image bump degrades gracefully instead of failing. - SIMULATOR_DEVICE: iPhone 17 Pro + # Pin the simulator model/runtime for run-to-run determinism and faster + # hosted-runner boots. The macos-26-arm64 image includes iPhone 16e only on + # the iOS 26.2 runtime, which is the smallest/newest-enough iPhone target + # listed in the runner image README. Selection still falls back to the + # newest available iPhone if this exact pairing disappears. + SIMULATOR_DEVICE: iPhone 16e + SIMULATOR_RUNTIME_VERSION: '26.2' steps: - uses: actions/checkout@v7 @@ -76,12 +77,12 @@ jobs: set -euo pipefail xcrun simctl list devices available - # Prefer $SIMULATOR_DEVICE on the newest runtime that offers it; - # otherwise fall back to the newest available iPhone of any model. + # Prefer $SIMULATOR_DEVICE on $SIMULATOR_RUNTIME_VERSION; otherwise + # fall back to the newest available iPhone of any model. # Tab-delimited so device names containing spaces survive `read`. IFS=$'\t' read -r UDID DEVICE_NAME RUNTIME < <( xcrun simctl list devices available --json \ - | jq -r --arg want "$SIMULATOR_DEVICE" ' + | jq -r --arg want "$SIMULATOR_DEVICE" --arg wantVersion "$SIMULATOR_RUNTIME_VERSION" ' [ .devices | to_entries[] | select(.key | startswith("com.apple.CoreSimulator.SimRuntime.iOS")) | .key as $rt | .value[] @@ -89,7 +90,7 @@ jobs: | {name: .name, udid: .udid, rt: $rt, ver: ($rt | capture("iOS-(?[0-9]+)-(?[0-9]+)") | [(.a | tonumber), (.b | tonumber)])} ] as $all - | ( ( [ $all[] | select(.name == $want) ] | sort_by(.ver) | last ) + | ( ( [ $all[] | select(.name == $want and (.ver | join(".") == $wantVersion)) ] | first ) // ( $all | sort_by(.ver) | last ) ) | "\(.udid)\t\(.name)\t\(.rt)"' ) @@ -97,8 +98,8 @@ jobs: if [ -z "${UDID:-}" ]; then echo "::error::No available iPhone simulator found"; exit 1 fi - if [ "$DEVICE_NAME" != "$SIMULATOR_DEVICE" ]; then - echo "::warning::Pinned device '$SIMULATOR_DEVICE' unavailable; fell back to '$DEVICE_NAME'" + if [ "$DEVICE_NAME" != "$SIMULATOR_DEVICE" ] || [[ "$RUNTIME" != *"iOS-${SIMULATOR_RUNTIME_VERSION/./-}" ]]; then + echo "::warning::Pinned simulator '$SIMULATOR_DEVICE' on iOS $SIMULATOR_RUNTIME_VERSION unavailable; fell back to '$DEVICE_NAME' ($RUNTIME)" fi echo "Selected simulator: $DEVICE_NAME ($RUNTIME) -> $UDID" diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index c0504199..59a19b29 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -79,6 +79,27 @@ 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). +- **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 + 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. --- @@ -87,7 +108,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 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/android/build.gradle b/flutter_readium/android/build.gradle index de3e8abe..8ebc9951 100644 --- a/flutter_readium/android/build.gradle +++ b/flutter_readium/android/build.gradle @@ -110,7 +110,5 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" implementation 'androidx.constraintlayout:constraintlayout:2.2.1' - testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" testImplementation 'junit:junit:4.13.2' - testImplementation 'org.mockito:mockito-core:5.14.2' } 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 eac2ef37..b03907bc 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 867d1cdd..9c120ae4 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 @@ -97,6 +97,11 @@ private val syncAudioNavigatorStateKey = "syncAudioState" private val epubNavigatorStateKey = "epubState" private val decorationStyleKey = "decorationStyle" +internal fun shouldInjectMOColumnBreakCss( + isMOActive: Boolean, + preventMOColumnBreaks: Boolean, +): Boolean = isMOActive && preventMOColumnBreaks + // TODO: Support custom headers and authentication header for content files. @ExperimentalCoroutinesApi @@ -193,6 +198,20 @@ 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 + + /** Whether MO page-change reinjection should run for the current reader state. */ + internal val shouldInjectMOColumnBreakCssOnPageChange: Boolean + get() = shouldInjectMOColumnBreakCss(isMOActive, _preventMOColumnBreaks) + private val timebasedNavigator: TimebasedNavigator<*>? get() = audiobookNavigator ?: syncAudiobookNavigator ?: ttsNavigator @@ -1290,12 +1309,16 @@ object ReadiumReader : audiobookNavigator = null } + val wasMOActive = isMOActive syncAudiobookNavigator?.apply { pause() dispose() syncAudiobookNavigator = null } + if (wasMOActive) { + epubEvaluateJavascript("window.flutterReadium.removeMOBreakCSS()") + } ttsNavigator?.apply { pause() @@ -1470,6 +1493,9 @@ object ReadiumReader : ).apply { initNavigator() } + if (_preventMOColumnBreaks) { + epubEvaluateJavascript("window.flutterReadium.injectMOBreakCSS()") + } currentTimebasedPublicationDurationMs = computePublicationDurationMs(ap.readingOrder.map { it.duration }) } } @@ -1549,6 +1575,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 42e80217..2f0ff5b5 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 @@ -210,6 +210,11 @@ class ReadiumReaderWidget( } emitOnPageChanged(pageIndex, totalPages, locator) + + // Re-inject MO column-break CSS for each freshly-loaded spine item. + if (ReadiumReader.shouldInjectMOColumnBreakCssOnPageChange) { + ReadiumReader.epubEvaluateJavascript("window.flutterReadium.injectMOBreakCSS()") + } } } diff --git a/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/FlutterEpubPreferencesTest.kt b/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/FlutterEpubPreferencesTest.kt index f4f6e87e..884ec910 100644 --- a/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/FlutterEpubPreferencesTest.kt +++ b/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/FlutterEpubPreferencesTest.kt @@ -1,8 +1,8 @@ package dk.nota.flutterreadium -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test @OptIn(org.readium.r2.shared.ExperimentalReadiumApi::class) internal class FlutterEpubPreferencesTest { diff --git a/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/FlutterReadiumPluginTest.kt b/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/FlutterReadiumPluginTest.kt index 9280cda6..330fe81f 100644 --- a/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/FlutterReadiumPluginTest.kt +++ b/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/FlutterReadiumPluginTest.kt @@ -2,10 +2,9 @@ package dk.nota.flutterreadium import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import org.junit.Assert.assertTrue import org.junit.Ignore -import org.mockito.Mockito -import kotlin.test.Test -import kotlin.test.assertTrue +import org.junit.Test /* * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. diff --git a/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/ReadiumReaderMOBreakPolicyTest.kt b/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/ReadiumReaderMOBreakPolicyTest.kt new file mode 100644 index 00000000..785a8d27 --- /dev/null +++ b/flutter_readium/android/src/test/kotlin/dk/nota/flutterreadium/ReadiumReaderMOBreakPolicyTest.kt @@ -0,0 +1,15 @@ +package dk.nota.flutterreadium + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ReadiumReaderMOBreakPolicyTest { + @Test + fun shouldInjectMOColumnBreakCss_returnsTrue_onlyWhenMOActiveAndPreferenceEnabled() { + assertTrue(shouldInjectMOColumnBreakCss(isMOActive = true, preventMOColumnBreaks = true)) + assertFalse(shouldInjectMOColumnBreakCss(isMOActive = true, preventMOColumnBreaks = false)) + assertFalse(shouldInjectMOColumnBreakCss(isMOActive = false, preventMOColumnBreaks = true)) + assertFalse(shouldInjectMOColumnBreakCss(isMOActive = false, preventMOColumnBreaks = false)) + } +} 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/assets/_helper_scripts/src/FlutterReadiumTools.ts b/flutter_readium/assets/_helper_scripts/src/FlutterReadiumTools.ts index bac955f4..b8f7bf1f 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/example/integration_test/plugin_integration_test.dart b/flutter_readium/example/integration_test/plugin_integration_test.dart index 356c805d..9a1d11e5 100644 --- a/flutter_readium/example/integration_test/plugin_integration_test.dart +++ b/flutter_readium/example/integration_test/plugin_integration_test.dart @@ -652,7 +652,7 @@ void main() { ); await expectLater( - reader.setEPUBPreferences(EPUBPreferences(fontSize: 200)), + reader.setEPUBPreferences(EPUBPreferences(fontSize: 2.0)), completes, reason: 'setEPUBPreferences should not throw', ); 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 5aa6db87..9e3571a4 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/EPUBReaderView.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/EPUBReaderView.swift @@ -24,6 +24,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? @@ -303,6 +305,9 @@ public class EPUBReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, E if let preferences = self.preferences { updateCustomPreferences(preferences) } + if shouldPreventColumnBreaks { + injectColumnBreakCSS() + } } emitOnPageChanged(locator: locator) } @@ -437,8 +442,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() } /// Resolves preferences against the publication's layout. The first-element top @@ -483,6 +490,33 @@ 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) { + // Delegates to the helper bundle (window.flutterReadium), matching Android. + // Optional-chained: no-op if called before the helper finishes initializing. + await self.readiumViewController.evaluateJavaScript("window.flutterReadium?.injectMOBreakCSS();") + } + } + + private func removeColumnBreakCSS() { + Task.detached(priority: .high) { + await self.readiumViewController.evaluateJavaScript("window.flutterReadium?.removeMOBreakCSS();") + } + } + 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 da0f2be9..f6c4b4be 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -314,10 +314,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) + } } // Reset narration sync state and return comic to full-page view. self.currentReaderView?.resetForNarrationStop() @@ -445,6 +449,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 778344c1..815de747 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/ReadiumReaderView.swift @@ -46,6 +46,10 @@ 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) /// Called when narration stops. Resets narration-sync state and, for comic /// pages, animates the view back to the full page so the user can browse freely. func resetForNarrationStop() 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/test/flutter_readium_test.dart b/flutter_readium/test/flutter_readium_test.dart index d7248572..8a2f1ed2 100644 --- a/flutter_readium/test/flutter_readium_test.dart +++ b/flutter_readium/test/flutter_readium_test.dart @@ -171,7 +171,7 @@ void main() { group('setDefaultPreferences', () { test('stores preferences on the platform', () { - final prefs = EPUBPreferences(fontSize: 150); + final prefs = EPUBPreferences(fontSize: 1.5); reader.setDefaultPreferences(prefs); expect(platform.defaultPreferences, prefs); }); diff --git a/flutter_readium/web/src/ReadiumReader.ts b/flutter_readium/web/src/ReadiumReader.ts index 36d0abca..cdc3e3fa 100644 --- a/flutter_readium/web/src/ReadiumReader.ts +++ b/flutter_readium/web/src/ReadiumReader.ts @@ -22,7 +22,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 @@ -37,6 +37,8 @@ import { 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"); @@ -111,6 +113,8 @@ class _ReadiumReader { * for panel-level state. */ private _narrationSyncEnabled = true; + /** 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. */ @@ -364,7 +368,15 @@ 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. + // 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) { + injectMOBreakCSSIntoWindow(wnd); + } + } ); } else if (this._publication.conformsToDivina) { log.info("Publication conforms to DiViNa profile (comic)"); @@ -423,6 +435,16 @@ class _ReadiumReader { if (prefSyncEnabled !== this._narrationSyncEnabled) { this._setNarrationSyncEnabled(prefSyncEnabled, "EPUBPreferences.disableSynchronization"); } + // Prevent MO column breaks is a custom preference, not part of upstream navigator. + 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. } @@ -699,7 +721,26 @@ class _ReadiumReader { if (this._hasSyncNarration || this._hasGuidedNavigation) { this._syncItems = []; this._lastMediaOverlayLocatorKey = null; - if (this._nav) this.applyDecorations("media_overlay_utterance", "[]"); + if (this._nav) { + this.applyDecorations("media_overlay_utterance", "[]"); + this._removeMOBreakCSSOnIframes(); + } + } + } + + /** 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)) { + injectMOBreakCSSIntoWindow(wnd); + } + } + + /** 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.document.getElementById("flutter-readium-mo-breaks")?.remove(); } } @@ -1089,6 +1130,7 @@ class _ReadiumReader { ? textLocatorToAudioLocator(this._syncItems, fromLocator) : undefined; await this._seekAudioAndResume(mappedStart ?? nav.currentLocator, true); + if (!this._comicNav && this._preventMOColumnBreaks) this._injectMOBreakCSSOnIframes(); } return; } @@ -1109,6 +1151,7 @@ class _ReadiumReader { ? textLocatorToAudioLocator(this._syncItems, fromLocator) : undefined; await this._seekAudioAndResume(mappedStart ?? nav.currentLocator, true); + if (this._preventMOColumnBreaks) this._injectMOBreakCSSOnIframes(); } return; } diff --git a/flutter_readium/web/src/__tests__/DecorationController.test.ts b/flutter_readium/web/src/__tests__/DecorationController.test.ts new file mode 100644 index 00000000..f690ee69 --- /dev/null +++ b/flutter_readium/web/src/__tests__/DecorationController.test.ts @@ -0,0 +1,79 @@ +import { Locator, LocatorLocations } from "@readium/shared"; +import { DecorationController } from "../decorations/DecorationController"; + +const mockSendDecorate = jest.fn(); +const mockNavIframeWindows = jest.fn((_nav: unknown): Window[] => []); +const mockRegisterPendingDecorationGroup = jest.fn( + (_iframes: Window[], _group: string, _isUnderline: boolean, _tint: string): void => {} +); +const mockSetSpotlightGroupOnIframes = jest.fn( + (_iframes: Window[], _group: string, _active: boolean): void => {} +); +const mockClearSpotlightState = jest.fn(); + +jest.mock("../decorations/decorationFrameUtils", () => ({ + UNDERLINE_GROUP_SUFFIX: "__underline", + SPOTLIGHT_GROUP_SUFFIX: "__spotlight", + sendDecorate: ( + nav: unknown, + group: string, + action: string, + decoration: unknown + ) => mockSendDecorate(nav, group, action, decoration), + navIframeWindows: (nav: unknown) => mockNavIframeWindows(nav), + registerPendingDecorationGroup: ( + iframes: Window[], + group: string, + isUnderline: boolean, + tint: string + ) => mockRegisterPendingDecorationGroup(iframes, group, isUnderline, tint), + setSpotlightGroupOnIframes: (iframes: Window[], group: string, active: boolean) => + mockSetSpotlightGroupOnIframes(iframes, group, active), + clearSpotlightState: () => mockClearSpotlightState(), +})); + +describe("DecorationController", () => { + beforeEach(() => { + mockSendDecorate.mockClear(); + mockNavIframeWindows.mockClear(); + mockRegisterPendingDecorationGroup.mockClear(); + mockSetSpotlightGroupOnIframes.mockClear(); + mockClearSpotlightState.mockClear(); + }); + + it("disables contrast enforcement for plugin-owned highlight tints", () => { + const controller = new DecorationController(); + const nav = {} as any; + const locator = new Locator({ + href: "chapter-1.xhtml", + type: "application/xhtml+xml", + locations: new LocatorLocations({ fragments: ["p1"] }), + }); + + controller.applyDecorations( + nav, + "tts_utterance", + JSON.stringify([ + { + id: "dec-1", + locator: locator.serialize(), + style: { style: "highlight", tint: "#ccfdff00" }, + }, + ]) + ); + + expect(mockSendDecorate).toHaveBeenCalledWith(nav, "tts_utterance", "clear", undefined); + expect(mockSendDecorate).toHaveBeenCalledWith( + nav, + "tts_utterance", + "add", + expect.objectContaining({ + id: "dec-1", + style: expect.objectContaining({ + tint: "#fdff00cc", + enforceContrast: false, + }), + }) + ); + }); +}); diff --git a/flutter_readium/web/src/decorations/DecorationController.ts b/flutter_readium/web/src/decorations/DecorationController.ts index 2fc77f79..b259a318 100644 --- a/flutter_readium/web/src/decorations/DecorationController.ts +++ b/flutter_readium/web/src/decorations/DecorationController.ts @@ -79,14 +79,20 @@ export class DecorationController { } for (const raw of decorationsRaw) { + const usesBoundsLayout = raw.style.style === "underline"; const targetGroup = this._subgroupFor(group, raw.style.style); + const decoration: Decoration = { id: raw.id, locator: Locator.deserialize(raw.locator)!, style: { tint: raw.style.tint, - layout: DecorationLayout.Bounds, width: DecorationWidth.Wrap, + // Underline decorations use bounds layout to avoid clipping the underline at the edges of the text. + ...(usesBoundsLayout ? { layout: DecorationLayout.Bounds } : {}), + // Keep plugin-selected decoration tints faithful on web instead of + // letting Readium adjust them to satisfy its contrast heuristic. + enforceContrast: false, }, }; sendDecorate(nav, targetGroup, "add", decoration); 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 da37be20..f1df8312 100644 --- a/flutter_readium/web/src/preferences/FlutterEpubPreferences.ts +++ b/flutter_readium/web/src/preferences/FlutterEpubPreferences.ts @@ -178,11 +178,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 { 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 diff --git a/flutter_readium_platform_interface/CHANGELOG.md b/flutter_readium_platform_interface/CHANGELOG.md index baccca5b..7b2ac2ab 100644 --- a/flutter_readium_platform_interface/CHANGELOG.md +++ b/flutter_readium_platform_interface/CHANGELOG.md @@ -52,6 +52,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 d2d4a88c..74f74a29 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. @@ -134,6 +135,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; + /// Readium's supported [fontSize] range (ratio; `1.0` = 100%). Matches /// swift-toolkit's `EPUBPreferencesEditor.fontSize.supportedRange` (`0.1...5.0`). /// @@ -247,6 +253,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, @@ -277,6 +288,7 @@ class EPUBPreferences with EquatableMixin implements JSONable { // ignore: deprecated_member_use_from_same_package disableSynchronization: disableSynchronization, firstElementTopMargin: firstElementTopMargin, + preventMOColumnBreaks: preventMOColumnBreaks, ); } @@ -309,7 +321,8 @@ class EPUBPreferences with EquatableMixin implements JSONable { ..put('blackAndWhiteComicMode', blackAndWhiteComicMode) // ignore: deprecated_member_use_from_same_package ..put('disableSynchronization', disableSynchronization) - ..putOpt('firstElementTopMargin', firstElementTopMargin); + ..putOpt('firstElementTopMargin', firstElementTopMargin) + ..put('preventMOColumnBreaks', preventMOColumnBreaks); EPUBPreferences copyWith({ Color? backgroundColor, @@ -339,6 +352,7 @@ class EPUBPreferences with EquatableMixin implements JSONable { bool? blackAndWhiteComicMode, bool? disableSynchronization, int? firstElementTopMargin, + bool? preventMOColumnBreaks, }) => EPUBPreferences( backgroundColor: backgroundColor ?? this.backgroundColor, columnCount: columnCount ?? this.columnCount, @@ -368,6 +382,7 @@ class EPUBPreferences with EquatableMixin implements JSONable { // ignore: deprecated_member_use_from_same_package disableSynchronization: disableSynchronization ?? this.disableSynchronization, firstElementTopMargin: firstElementTopMargin ?? this.firstElementTopMargin, + preventMOColumnBreaks: preventMOColumnBreaks ?? this.preventMOColumnBreaks, ); @override @@ -400,6 +415,7 @@ class EPUBPreferences with EquatableMixin implements JSONable { // ignore: deprecated_member_use_from_same_package disableSynchronization, firstElementTopMargin, + preventMOColumnBreaks, ]; } diff --git a/flutter_readium_platform_interface/test/models_test.dart b/flutter_readium_platform_interface/test/models_test.dart index 1e58ea59..d26e5c04 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,