From 4ad834c5d9afa70717ccfdc639f4fb1fb453807b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Fri, 26 Jun 2026 13:42:46 +0200 Subject: [PATCH 1/9] WIP: wrap html resource iterator --- .../dk/nota/flutterreadium/ReadiumReader.kt | 10 ++ .../navigators/AudiobookNavigator.kt | 29 +++-- .../navigators/EpubNavigator.kt | 6 +- ...PageBreakSkippingContentIteratorFactory.kt | 118 ++++++++++++++++++ 4 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt 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..a441100e 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 @@ -18,6 +18,7 @@ import dk.nota.flutterreadium.models.ReadiumTimebasedState import dk.nota.flutterreadium.navigators.AudiobookNavigator import dk.nota.flutterreadium.navigators.EpubNavigator import dk.nota.flutterreadium.navigators.FlutterVisualNavigator +import dk.nota.flutterreadium.navigators.PageBreakSkippingContentIteratorFactory import dk.nota.flutterreadium.navigators.PdfNavigator import dk.nota.flutterreadium.navigators.SyncAudiobookNavigator import dk.nota.flutterreadium.navigators.TTSNavigator @@ -47,6 +48,9 @@ import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.LocatorCollection import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.html.cssSelector +import org.readium.r2.shared.publication.services.content.DefaultContentService +import org.readium.r2.shared.publication.services.content.content +import org.readium.r2.shared.publication.services.content.contentServiceFactory import org.readium.r2.shared.publication.services.search.SearchService import org.readium.r2.shared.publication.services.search.search import org.readium.r2.shared.util.AbsoluteUrl @@ -455,6 +459,12 @@ object ReadiumReader : publicationOpener .open(asset, allowUserInteraction = true, onCreatePublication = { container = transformingContainerFactory?.let { it(container) } ?: container + if (manifest.conformsTo(Publication.Profile.EPUB)) { + servicesBuilder.contentServiceFactory = + DefaultContentService.createFactory( + listOf(PageBreakSkippingContentIteratorFactory()), + ) + } }) .getOrElse { err: OpenError -> fun unwrapCause(e: org.readium.r2.shared.util.Error?): String = diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/AudiobookNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/AudiobookNavigator.kt index ffcfda14..9222ad7d 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/AudiobookNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/AudiobookNavigator.kt @@ -87,7 +87,7 @@ open class AudiobookNavigator( if (publication.readingOrder.any { it.duration == 0.0 }) { PluginLog.e( TAG, - "::initNavigator - has at least one readium order item with duration = 0" + "::initNavigator - has at least one readium order item with duration = 0", ) throw Exception("Publication has at least one readium order item with duration = 0") } @@ -144,7 +144,7 @@ open class AudiobookNavigator( is AudioNavigator.State.Ended -> { PluginLog.d( TAG, - "::initNavigator - playback ended, stopping navigator." + "::initNavigator - playback ended, stopping navigator.", ) stopMediaServiceFacade() } @@ -156,7 +156,6 @@ open class AudiobookNavigator( } }.launchIn(this@AudiobookNavigator) } - } override suspend fun play(fromLocator: Locator?) { @@ -221,7 +220,7 @@ open class AudiobookNavigator( if (itemIndex == null) { PluginLog.e( TAG, - "::goToLocator - ${locator.href} not found in navigator's readingOrder" + "::goToLocator - ${locator.href} not found in navigator's readingOrder", ) return@withMainContext } @@ -231,7 +230,7 @@ open class AudiobookNavigator( if (timeOffset == null) { PluginLog.w( TAG, - "::goToLocator - couldn't find timeOffset from starting file over." + "::goToLocator - couldn't find timeOffset from starting file over.", ) } navigator.skipTo(itemIndex, timeOffset ?: Duration.ZERO) @@ -252,7 +251,7 @@ open class AudiobookNavigator( if (progression !in 0.0..1.0) { PluginLog.d( TAG, - "::seekToProgression - progression $progression is not between 0.0 and 1.0" + "::seekToProgression - progression $progression is not between 0.0 and 1.0", ) return false } @@ -265,12 +264,16 @@ open class AudiobookNavigator( // Find duration of the current item. // First try to get it from the player, because it is more precise, if that fails, get it from the navigator's reading order. - val duration = player.duration.milliseconds.inWholeSeconds.takeIf { - // player.duration is -9223372036854775 when the duration is unknown, so we check if it's a positive number before using - it > 0 - } - ?: navigator.readingOrder.items.firstOrNull { it.href == currentLocator.href }?.duration?.inWholeSeconds - ?: 0 + val duration = + player.duration.milliseconds.inWholeSeconds.takeIf { + // player.duration is -9223372036854775 when the duration is unknown, so we check if it's a positive number before using + it > 0 + } + ?: navigator.readingOrder.items + .firstOrNull { it.href == currentLocator.href } + ?.duration + ?.inWholeSeconds + ?: 0 PluginLog.w(TAG, "::seekToProgression - couldn't find duration of current item, defaulting to 0") @@ -316,7 +319,7 @@ open class AudiobookNavigator( } catch (e: Exception) { PluginLog.e( TAG, - "::navigatorWithOpenMediaSession - failed to open MediaSession: $e" + "::navigatorWithOpenMediaSession - failed to open MediaSession: $e", ) } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/EpubNavigator.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/EpubNavigator.kt index f915d8a2..e9368a56 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/EpubNavigator.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/EpubNavigator.kt @@ -501,7 +501,11 @@ class EpubNavigator : */ private var lastSyncSegmentDuration: Double? = null - suspend fun syncToLocator(locator: Locator, animated: Boolean, segmentDuration: Double?) { + suspend fun syncToLocator( + locator: Locator, + animated: Boolean, + segmentDuration: Double?, + ) { if (preferences.disableSynchronization == true) { lastSyncLocator = locator lastSyncSegmentDuration = segmentDuration diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt new file mode 100644 index 00000000..e8c4b737 --- /dev/null +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt @@ -0,0 +1,118 @@ +package dk.nota.flutterreadium.navigators + +import org.readium.r2.shared.ExperimentalReadiumApi +import org.readium.r2.shared.publication.Locator +import org.readium.r2.shared.publication.Manifest +import org.readium.r2.shared.publication.PublicationServicesHolder +import org.readium.r2.shared.publication.html.cssSelector +import org.readium.r2.shared.publication.services.content.Content +import org.readium.r2.shared.publication.services.content.iterators.HtmlResourceContentIterator +import org.readium.r2.shared.publication.services.content.iterators.ResourceContentIteratorFactory +import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.resource.Resource + +/** + * Wraps [HtmlResourceContentIterator.Factory] and filters out EPUB page-break elements so TTS + * never reads out page numbers. + * + * Page-break elements are identified by matching the [Content.TextElement] CSS selector against + * the fragment IDs declared in the publication's pageList nav. Block-level page-break elements + * (the typical DAISY/Nordic EPUB3 form) carry the element's own ID as their CSS selector, so + * the match is exact. Inline spans inside a paragraph are not filtered (their text is merged + * into the parent block's element and not individually addressable at this level). + */ +@OptIn(ExperimentalReadiumApi::class) +class PageBreakSkippingContentIteratorFactory( + private val delegate: ResourceContentIteratorFactory = HtmlResourceContentIterator.Factory(), +) : ResourceContentIteratorFactory { + override suspend fun create( + manifest: Manifest, + servicesHolder: PublicationServicesHolder, + readingOrderIndex: Int, + resource: Resource, + mediaType: MediaType, + locator: Locator, + ): Content.Iterator? { + val inner = + delegate.create(manifest, servicesHolder, readingOrderIndex, resource, mediaType, locator) + ?: return null + + val pageBreakIds = manifest.pageBreakIds() + return if (pageBreakIds.isEmpty()) inner else PageBreakFilteringIterator(inner, pageBreakIds) + } + + private fun Manifest.pageBreakIds(): Set = + subcollections["pageList"] + ?.firstOrNull() + ?.links + ?.mapNotNull { link -> + link.href + .toString() + .substringAfter("#", "") + .takeIf { it.isNotBlank() } + }?.toSet() + ?: emptySet() +} + +@OptIn(ExperimentalReadiumApi::class) +private class PageBreakFilteringIterator( + private val delegate: Content.Iterator, + private val pageBreakIds: Set, +) : Content.Iterator { + private data class Pending( + val element: Content.Element, + val forward: Boolean, + ) + + private var pending: Pending? = null + + override suspend fun hasNext(): Boolean { + pending?.let { if (it.forward) return true } + pending = null + while (delegate.hasNext()) { + val element = delegate.next() + if (!element.isPageBreak()) { + pending = Pending(element, forward = true) + return true + } + } + return false + } + + override fun next(): Content.Element = + pending + ?.takeIf { it.forward } + ?.element + ?.also { pending = null } + ?: throw IllegalStateException( + "Called next() without a successful call to hasNext() first", + ) + + override suspend fun hasPrevious(): Boolean { + pending?.let { if (!it.forward) return true } + pending = null + while (delegate.hasPrevious()) { + val element = delegate.previous() + if (!element.isPageBreak()) { + pending = Pending(element, forward = false) + return true + } + } + return false + } + + override fun previous(): Content.Element = + pending + ?.takeIf { !it.forward } + ?.element + ?.also { pending = null } + ?: throw IllegalStateException( + "Called previous() without a successful call to hasPrevious() first", + ) + + private fun Content.Element.isPageBreak(): Boolean { + if (this !is Content.TextElement) return false + val selector = locator.locations.cssSelector ?: return false + return selector.startsWith("#") && selector.drop(1) in pageBreakIds + } +} From 2abdaaf4af432b18a141f15048e7122485b82d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Fri, 26 Jun 2026 17:46:41 +0200 Subject: [PATCH 2/9] feat(TTS): Skip page breaks --- .../flutterreadium/FlutterTtsPreferences.kt | 5 ++ .../dk/nota/flutterreadium/ReadiumReader.kt | 9 ++- ...PageBreakSkippingContentIteratorFactory.kt | 57 +++++++++++++++++-- .../src/reader/reader_tts_preferences.dart | 14 +++-- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterTtsPreferences.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterTtsPreferences.kt index 2754bb3e..5b3cff84 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterTtsPreferences.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterTtsPreferences.kt @@ -19,6 +19,7 @@ data class FlutterTtsPreferences( val speed: Double? = null, val voices: Map? = null, val controlPanelInfoType: ControlPanelInfoType? = ControlPanelInfoType.STANDARD, + val skipPageBreaks: Boolean? = null, ) { /** * Convert to AndroidTtsPreferences. @@ -48,6 +49,7 @@ data class FlutterTtsPreferences( speed = other.speed ?: speed, voices = other.voices ?: voices, controlPanelInfoType = other.controlPanelInfoType ?: controlPanelInfoType, + skipPageBreaks = other.skipPageBreaks ?: skipPageBreaks, ) companion object { @@ -80,6 +82,7 @@ data class FlutterTtsPreferences( "standard", ), ), + skipPageBreaks = if (jsonObject.has("skipPageBreaks")) jsonObject.getBoolean("skipPageBreaks") else null, ) } @@ -97,6 +100,7 @@ data class FlutterTtsPreferences( jsonObject.put("voices", voicesJson) } jsonObject.put("controlPanelInfoType", preferences.controlPanelInfoType?.toString()) + preferences.skipPageBreaks?.let { jsonObject.put("skipPageBreaks", it) } return jsonObject } @@ -138,6 +142,7 @@ data class FlutterTtsPreferences( ControlPanelInfoType.fromString( ttsPrefs?.get("controlPanelInfoType") as? String ?: "standard", ), + skipPageBreaks = ttsPrefs?.get("skipPageBreaks") as? Boolean, ) } } 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 a441100e..1463e338 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 @@ -165,6 +165,8 @@ object ReadiumReader : private var ttsNavigator: TTSNavigator? = null + private var pageBreakIteratorFactory: PageBreakSkippingContentIteratorFactory? = null + private var audiobookNavigator: AudiobookNavigator? = null private var syncAudiobookNavigator: SyncAudiobookNavigator? = null @@ -460,9 +462,11 @@ object ReadiumReader : .open(asset, allowUserInteraction = true, onCreatePublication = { container = transformingContainerFactory?.let { it(container) } ?: container if (manifest.conformsTo(Publication.Profile.EPUB)) { + val factory = PageBreakSkippingContentIteratorFactory() + pageBreakIteratorFactory = factory servicesBuilder.contentServiceFactory = DefaultContentService.createFactory( - listOf(PageBreakSkippingContentIteratorFactory()), + listOf(factory), ) } }) @@ -681,6 +685,7 @@ object ReadiumReader : _currentPublication?.close() _currentPublication = null + pageBreakIteratorFactory = null currentPublicationCssSelectorMap = null state.clear() @@ -1039,6 +1044,7 @@ object ReadiumReader : suspend fun ttsEnable(ttsPrefs: FlutterTtsPreferences) { currentPublication?.let { + pageBreakIteratorFactory?.skipPageBreaks = ttsPrefs.skipPageBreaks ?: true ttsNavigator = TTSNavigator(it, this@ReadiumReader, currentTextLocator.value, ttsPrefs).apply { initNavigator() @@ -1047,6 +1053,7 @@ object ReadiumReader : } suspend fun ttsSetPreferences(ttsPrefs: FlutterTtsPreferences) { + pageBreakIteratorFactory?.skipPageBreaks = ttsPrefs.skipPageBreaks ?: true ttsNavigator?.updatePreferences(ttsPrefs) ?: throw Exception("TTS is not enabled, can't set preferences") } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt index e8c4b737..8a7b61dc 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt @@ -1,6 +1,7 @@ package dk.nota.flutterreadium.navigators import org.readium.r2.shared.ExperimentalReadiumApi +import org.readium.r2.shared.publication.LocalizedString import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Manifest import org.readium.r2.shared.publication.PublicationServicesHolder @@ -12,17 +13,23 @@ import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.resource.Resource /** - * Wraps [HtmlResourceContentIterator.Factory] and filters out EPUB page-break elements so TTS - * never reads out page numbers. + * Wraps [HtmlResourceContentIterator.Factory] and handles EPUB page-break elements for TTS. * * Page-break elements are identified by matching the [Content.TextElement] CSS selector against * the fragment IDs declared in the publication's pageList nav. Block-level page-break elements * (the typical DAISY/Nordic EPUB3 form) carry the element's own ID as their CSS selector, so * the match is exact. Inline spans inside a paragraph are not filtered (their text is merged * into the parent block's element and not individually addressable at this level). + * + * When [skipPageBreaks] is `true` (default), page-break elements are removed from the iterator. + * When `false`, they are kept but the label text from the original element is rewritten to a + * localized string (e.g. "Page 42" or "side 42"), derived from the publication's declared + * language. [skipPageBreaks] can be changed at any time; the change takes effect on the next + * [Content.Iterator.hasNext] / [Content.Iterator.hasPrevious] call. */ @OptIn(ExperimentalReadiumApi::class) class PageBreakSkippingContentIteratorFactory( + var skipPageBreaks: Boolean = true, private val delegate: ResourceContentIteratorFactory = HtmlResourceContentIterator.Factory(), ) : ResourceContentIteratorFactory { override suspend fun create( @@ -38,7 +45,8 @@ class PageBreakSkippingContentIteratorFactory( ?: return null val pageBreakIds = manifest.pageBreakIds() - return if (pageBreakIds.isEmpty()) inner else PageBreakFilteringIterator(inner, pageBreakIds) + if (pageBreakIds.isEmpty()) return inner + return PageBreakHandlingIterator(inner, pageBreakIds, ::skipPageBreaks, manifest.pageLabel()) } private fun Manifest.pageBreakIds(): Set = @@ -52,12 +60,33 @@ class PageBreakSkippingContentIteratorFactory( .takeIf { it.isNotBlank() } }?.toSet() ?: emptySet() + + private fun Manifest.pageLabel(): ((String) -> String)? { + val format = + PAGE_LABEL_FORMATS.getOrFallback(metadata.languages.firstOrNull()) ?: return null + return { label -> format.string.replace("{label}", label) } + } + + companion object { + private val PAGE_LABEL_FORMATS = + LocalizedString.fromStrings( + mapOf( + "en" to "Page {label}", + "da" to "side {label}", + "sv" to "sida {label}", + "no" to "side {label}", + "is" to "{label}. síða", + ), + ) + } } @OptIn(ExperimentalReadiumApi::class) -private class PageBreakFilteringIterator( +private class PageBreakHandlingIterator( private val delegate: Content.Iterator, private val pageBreakIds: Set, + private val shouldPageElementsSkip: () -> Boolean, + private val pageLabel: ((String) -> String)?, ) : Content.Iterator { private data class Pending( val element: Content.Element, @@ -71,7 +100,7 @@ private class PageBreakFilteringIterator( pending = null while (delegate.hasNext()) { val element = delegate.next() - if (!element.isPageBreak()) { + if (!shouldPageElementsSkip() || !element.isPageBreak()) { pending = Pending(element, forward = true) return true } @@ -84,6 +113,7 @@ private class PageBreakFilteringIterator( ?.takeIf { it.forward } ?.element ?.also { pending = null } + ?.transform() ?: throw IllegalStateException( "Called next() without a successful call to hasNext() first", ) @@ -93,7 +123,7 @@ private class PageBreakFilteringIterator( pending = null while (delegate.hasPrevious()) { val element = delegate.previous() - if (!element.isPageBreak()) { + if (!shouldPageElementsSkip() || !element.isPageBreak()) { pending = Pending(element, forward = false) return true } @@ -106,10 +136,25 @@ private class PageBreakFilteringIterator( ?.takeIf { !it.forward } ?.element ?.also { pending = null } + ?.transform() ?: throw IllegalStateException( "Called previous() without a successful call to hasPrevious() first", ) + private fun Content.Element.transform(): Content.Element { + if (shouldPageElementsSkip() || !isPageBreak()) return this + val textElement = this as Content.TextElement + val rawLabel = textElement.segments.joinToString("") { it.text }.trim() + if (rawLabel.isEmpty()) return this + val label = pageLabel?.let { it(rawLabel) } ?: rawLabel + return textElement.copy( + segments = + textElement.segments.mapIndexed { i, seg -> + if (i == 0) seg.copy(text = label) else seg.copy(text = "") + }, + ) + } + private fun Content.Element.isPageBreak(): Boolean { if (this !is Content.TextElement) return false val selector = locator.locations.cssSelector ?: return false diff --git a/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart b/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart index fb97c8c5..6059b27e 100644 --- a/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart +++ b/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart @@ -26,6 +26,7 @@ class TTSPreferences with EquatableMixin implements JSONable { 'languageOverride', remove: true, ); + final skipPageBreaks = jsonObject.remove('skipPageBreaks') as bool?; final controlPanelInfoTypeStr = jsonObject.optNullableString( 'controlPanelInfoType', remove: true, @@ -50,6 +51,7 @@ class TTSPreferences with EquatableMixin implements JSONable { voices: voices, languageOverride: languageOverride, controlPanelInfoType: controlPanelInfoType ?? ControlPanelInfoType.standard, + skipPageBreaks: skipPageBreaks, ); } @@ -60,6 +62,7 @@ class TTSPreferences with EquatableMixin implements JSONable { this.voices = const {}, this.languageOverride, this.controlPanelInfoType, + this.skipPageBreaks, }); /// The speech rate (speed) for text-to-speech. A value of 1.0 is the normal speed, less than 1.0 is slower, and greater than 1.0 is faster. @@ -80,6 +83,10 @@ class TTSPreferences with EquatableMixin implements JSONable { /// Control panel info type to determine what information is sent to the control panel during TTS playback. final ControlPanelInfoType? controlPanelInfoType; + /// Whether to skip page-break elements during TTS playback. When false, page numbers are read + /// aloud with a localized prefix (e.g. "Page 42"). Defaults to true (skip). + final bool? skipPageBreaks; + @override Map toJson() => {} ..putOpt('speed', speed) @@ -87,10 +94,8 @@ class TTSPreferences with EquatableMixin implements JSONable { ..putOpt('voiceIdentifier', voiceIdentifier) ..putMapIfNotEmpty('voices', voices) ..putOpt('languageOverride', languageOverride) - ..putOpt( - 'controlPanelInfoType', - controlPanelInfoType?.toString().split('.').last, - ); + ..putOpt('controlPanelInfoType', controlPanelInfoType?.toString().split('.').last) + ..putOpt('skipPageBreaks', skipPageBreaks); @override List get props => [ @@ -100,5 +105,6 @@ class TTSPreferences with EquatableMixin implements JSONable { voices, languageOverride, controlPanelInfoType, + skipPageBreaks, ]; } From d0b9c2c4d1418daec42af0af9ccd057a596346ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Mon, 29 Jun 2026 09:54:34 +0200 Subject: [PATCH 3/9] start iOS skip page number TTS --- flutter_readium/CHANGELOG.md | 9 ++ .../FlutterReadiumPlugin.swift | 13 ++ .../model/FlutterTTSPreferences.swift | 10 +- ...eBreakSkippingContentIteratorFactory.swift | 131 ++++++++++++++++++ .../CHANGELOG.md | 5 + 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/PageBreakSkippingContentIteratorFactory.swift diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index 82d784f7..a1ad89eb 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -5,6 +5,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added + +- **`TTSPreferences.skipPageBreaks`** — controls whether EPUB page-break elements (DAISY/Nordic + EPUB3 `epub:type="pagebreak"`) are read aloud during TTS. When `true` (default), page-break + elements are silently skipped. When `false`, page numbers are read with a localized prefix + (e.g. "Page 42" / "side 42"), derived from the publication's declared language (supports + English, Danish, Swedish, Norwegian, Icelandic; falls back to the raw label otherwise). + Supported on Android and iOS. + ### Changed (breaking) - **`EPUBPreferences.fontSize` is now a `double` ratio** (`1.0` = default, `1.5` = 150%) 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 1193209f..ce4d9c35 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -38,6 +38,7 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin /// For EPUB profile, maps document path to a list of all the cssSelectors in the document. /// This is used to find the current toc item. private var currentPublicationCssSelectorMap: [String: [String]]? + private var pageBreakIteratorFactory: PageBreakSkippingContentIteratorFactory? lazy var fallbackChapterTitle: LocalizedString = LocalizedString.localized([ "en": "Chapter", @@ -206,6 +207,7 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin Task { @MainActor in // Start TTS from the reader's current location + self.pageBreakIteratorFactory?.skipPageBreaks = ttsPrefs.skipPageBreaks ?? true let currentLocation = self.currentReaderView?.getCurrentLocation() self.timebasedNavigator = FlutterTTSNavigator(publication: publication, preferences: ttsPrefs, initialLocator: currentLocation) self.timebasedNavigator?.listener = self @@ -272,6 +274,7 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin } do { let ttsPrefs = try TTSPreferences(fromMap: args!) + pageBreakIteratorFactory?.skipPageBreaks = ttsPrefs.skipPageBreaks ?? true ttsNavigator.ttsSetPreferences(prefs: ttsPrefs) result(nil) } catch { @@ -630,9 +633,18 @@ extension FlutterReadiumPlugin { asset = try await sharedReadium.assetRetriever!.retrieve(url: url).get() } + let factory = PageBreakSkippingContentIteratorFactory() + self.pageBreakIteratorFactory = factory let publication = try await sharedReadium.publicationOpener!.open( asset: asset, allowUserInteraction: allowUserInteraction, + onCreatePublication: { _, _, services in + services.setContentServiceFactory( + DefaultContentService.makeFactory( + resourceContentIteratorFactories: [factory] + ) + ) + }, sender: sender ).get() @@ -677,6 +689,7 @@ extension FlutterReadiumPlugin { currentPublication = nil currentPublicationUrlStr = nil currentPublicationCssSelectorMap = [:] + pageBreakIteratorFactory = nil // Clear the stream buffers so that a subscriber opening the next publication // never receives a stale locator or status from this closed publication. textLocatorStreamHandler?.clearBuffer() diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterTTSPreferences.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterTTSPreferences.swift index 61661483..d0b2c497 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterTTSPreferences.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterTTSPreferences.swift @@ -19,18 +19,23 @@ public struct TTSPreferences { public var controlPanelInfoType: ControlPanelInfoType? + /// Whether to skip page-break elements during TTS. When nil, defaults to true (skip). + public var skipPageBreaks: Bool? + public init( rate: Float? = nil, pitch: Float? = nil, overrideLanguage: Language? = nil, voiceIdentifier: String? = nil, - controlPanelInfoType: ControlPanelInfoType = .standard + controlPanelInfoType: ControlPanelInfoType = .standard, + skipPageBreaks: Bool? = nil ) { self.rate = rate self.pitch = pitch self.overrideLanguage = overrideLanguage self.voiceIdentifier = voiceIdentifier self.controlPanelInfoType = controlPanelInfoType + self.skipPageBreaks = skipPageBreaks } init(fromMap jsonMap: Dictionary) throws { @@ -43,6 +48,7 @@ public struct TTSPreferences { let controlPanelInfoTypeStr = map["controlPanelInfoType"] as? String let mapControlPanelInfoType = ControlPanelInfoType(from: controlPanelInfoTypeStr) + let skipPageBreaks = map["skipPageBreaks"] as? Bool /// Rate is normalized on iOS, since AVSpeechUtterance has a default rate of 0.5 (see AVSpeechUtteranceDefaultSpeechRate) /// Rate is also clamped between allowed values. let avRate = clamp(Float(rate) * AVSpeechUtteranceDefaultSpeechRate, @@ -52,6 +58,6 @@ public struct TTSPreferences { let avPitch = clamp(Float(pitch), minValue: 0.5, maxValue: 2.0) - self.init(rate: avRate, pitch: avPitch, overrideLanguage: overrideLanguage, voiceIdentifier: voiceIdentifier, controlPanelInfoType: mapControlPanelInfoType) + self.init(rate: avRate, pitch: avPitch, overrideLanguage: overrideLanguage, voiceIdentifier: voiceIdentifier, controlPanelInfoType: mapControlPanelInfoType, skipPageBreaks: skipPageBreaks) } } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/PageBreakSkippingContentIteratorFactory.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/PageBreakSkippingContentIteratorFactory.swift new file mode 100644 index 00000000..0da373b6 --- /dev/null +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/PageBreakSkippingContentIteratorFactory.swift @@ -0,0 +1,131 @@ +import Foundation +import ReadiumShared + +/// Wraps `HTMLResourceContentIterator.Factory` and handles EPUB page-break elements for TTS. +/// +/// When `skipPageBreaks` is `true` (default), page-break elements are silently skipped. +/// When `false`, the raw label text from the element is rewritten to a localized string +/// (e.g. "Page 42" / "side 42"), derived from the publication's declared language. +/// +/// Page-break elements are identified by matching the element's CSS selector against +/// the fragment IDs declared in the publication's pageList nav. +final class PageBreakSkippingContentIteratorFactory: ResourceContentIteratorFactory { + var skipPageBreaks: Bool = true + + private let delegate: ResourceContentIteratorFactory + + init(delegate: ResourceContentIteratorFactory = HTMLResourceContentIterator.Factory()) { + self.delegate = delegate + } + + func make( + publication: Publication, + readingOrderIndex: Int, + resource: Resource, + locator: Locator + ) -> ContentIterator? { + guard let inner = delegate.make( + publication: publication, + readingOrderIndex: readingOrderIndex, + resource: resource, + locator: locator + ) else { return nil } + + let pageBreakIds = publication.manifest.pageBreakIds() + guard !pageBreakIds.isEmpty else { return inner } + + return PageBreakHandlingIterator( + delegate: inner, + pageBreakIds: pageBreakIds, + shouldSkip: { [weak self] in self?.skipPageBreaks ?? true }, + pageLabel: publication.manifest.pageLabel() + ) + } +} + +private extension Manifest { + func pageBreakIds() -> Set { + guard let links = subcollections["pageList"]?.first?.links else { + return [] + } + return Set(links.compactMap { link -> String? in + guard let fragment = URL(string: link.href)?.fragment, + !fragment.isEmpty else { return nil } + return fragment + }) + } + + static let pageLabelFormats: [String: String] = [ + "en": "Page {label}", + "da": "side {label}", + "sv": "sida {label}", + "no": "side {label}", + "is": "{label}. síða", + ] + + func pageLabel() -> ((String) -> String)? { + guard let lang = metadata.languages.first else { return nil } + let base = lang.components(separatedBy: "-").first?.lowercased() ?? lang.lowercased() + guard let format = Self.pageLabelFormats[base] else { return nil } + return { label in format.replacingOccurrences(of: "{label}", with: label) } + } +} + +private final class PageBreakHandlingIterator: ContentIterator { + private let delegate: ContentIterator + private let pageBreakIds: Set + private let shouldSkip: () -> Bool + private let pageLabel: ((String) -> String)? + + init( + delegate: ContentIterator, + pageBreakIds: Set, + shouldSkip: @escaping () -> Bool, + pageLabel: ((String) -> String)? + ) { + self.delegate = delegate + self.pageBreakIds = pageBreakIds + self.shouldSkip = shouldSkip + self.pageLabel = pageLabel + } + + func next() async throws -> ContentElement? { + while let element = try await delegate.next() { + if shouldSkip() && isPageBreak(element) { continue } + return transform(element) + } + return nil + } + + func previous() async throws -> ContentElement? { + while let element = try await delegate.previous() { + if shouldSkip() && isPageBreak(element) { continue } + return transform(element) + } + return nil + } + + private func transform(_ element: ContentElement) -> ContentElement { + guard !shouldSkip(), let textElement = element as? TextContentElement else { return element } + guard isPageBreak(element) else { return element } + guard let rawLabel = textElement.text?.trimmingCharacters(in: .whitespaces), + !rawLabel.isEmpty else { return element } + let label = pageLabel?(rawLabel) ?? rawLabel + let newSegments = textElement.segments.enumerated().map { i, seg in + TextContentElement.Segment(locator: seg.locator, text: i == 0 ? label : "", attributes: seg.attributes) + } + return TextContentElement( + locator: textElement.locator, + role: textElement.role, + segments: newSegments, + attributes: textElement.attributes + ) + } + + private func isPageBreak(_ element: ContentElement) -> Bool { + guard let textElement = element as? TextContentElement else { return false } + guard let selector = textElement.locator.locations.cssSelector, + selector.hasPrefix("#") else { return false } + return pageBreakIds.contains(String(selector.dropFirst())) + } +} diff --git a/flutter_readium_platform_interface/CHANGELOG.md b/flutter_readium_platform_interface/CHANGELOG.md index f61ba983..3f6d3447 100644 --- a/flutter_readium_platform_interface/CHANGELOG.md +++ b/flutter_readium_platform_interface/CHANGELOG.md @@ -5,6 +5,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added + +- **`TTSPreferences.skipPageBreaks`** (`bool?`) — opt in/out of reading EPUB page-break elements + aloud during TTS playback. + ### Fixed - `EpubColumnCount` now serializes to Readium's canonical values (`auto`/`1`/`2`) instead of From c8a22b1e08040f0e67f9b410370c3f511070b345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Mon, 29 Jun 2026 11:32:08 +0200 Subject: [PATCH 4/9] TTSPreferences.skipPageBreaks is now TTSPreferences.pageBreakBehavior --- flutter_readium/CHANGELOG.md | 12 +++--- .../flutterreadium/FlutterTtsPreferences.kt | 10 ++--- .../nota/flutterreadium/PageBreakBehavior.kt | 25 +++++++++++ .../dk/nota/flutterreadium/ReadiumReader.kt | 4 +- ...PageBreakSkippingContentIteratorFactory.kt | 25 ++++++----- .../FlutterReadiumPlugin.swift | 4 +- .../model/FlutterTTSPreferences.swift | 11 +++-- ...eBreakSkippingContentIteratorFactory.swift | 43 +++++++++++++------ .../CHANGELOG.md | 6 ++- .../src/reader/reader_tts_preferences.dart | 34 +++++++++++---- 10 files changed, 121 insertions(+), 53 deletions(-) create mode 100644 flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PageBreakBehavior.kt diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index a1ad89eb..348e9e50 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -7,12 +7,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- **`TTSPreferences.skipPageBreaks`** — controls whether EPUB page-break elements (DAISY/Nordic - EPUB3 `epub:type="pagebreak"`) are read aloud during TTS. When `true` (default), page-break - elements are silently skipped. When `false`, page numbers are read with a localized prefix - (e.g. "Page 42" / "side 42"), derived from the publication's declared language (supports - English, Danish, Swedish, Norwegian, Icelandic; falls back to the raw label otherwise). - Supported on Android and iOS. +- **`TTSPreferences.pageBreakBehavior`** — controls how EPUB page-break elements (DAISY/Nordic + EPUB3 `epub:type="pagebreak"`) are handled during TTS. Accepts a `PageBreakBehavior` enum: + `readAsIs` (default — raw label text spoken unchanged), `prefixLabel` (label rewritten with a + localized prefix, e.g. "Page 42" / "side 42"; supports English, Danish, Swedish, Norwegian, + Icelandic; falls back to the raw label otherwise), `skip` (element filtered out entirely). + Replaces the previous `skipPageBreaks` bool. Supported on Android and iOS. ### Changed (breaking) diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterTtsPreferences.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterTtsPreferences.kt index 5b3cff84..d7c1bd22 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterTtsPreferences.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/FlutterTtsPreferences.kt @@ -19,7 +19,7 @@ data class FlutterTtsPreferences( val speed: Double? = null, val voices: Map? = null, val controlPanelInfoType: ControlPanelInfoType? = ControlPanelInfoType.STANDARD, - val skipPageBreaks: Boolean? = null, + val pageBreakBehavior: PageBreakBehavior? = null, ) { /** * Convert to AndroidTtsPreferences. @@ -49,7 +49,7 @@ data class FlutterTtsPreferences( speed = other.speed ?: speed, voices = other.voices ?: voices, controlPanelInfoType = other.controlPanelInfoType ?: controlPanelInfoType, - skipPageBreaks = other.skipPageBreaks ?: skipPageBreaks, + pageBreakBehavior = other.pageBreakBehavior ?: pageBreakBehavior, ) companion object { @@ -82,7 +82,7 @@ data class FlutterTtsPreferences( "standard", ), ), - skipPageBreaks = if (jsonObject.has("skipPageBreaks")) jsonObject.getBoolean("skipPageBreaks") else null, + pageBreakBehavior = PageBreakBehavior.fromString(jsonObject.optString("pageBreakBehavior").ifEmpty { null }), ) } @@ -100,7 +100,7 @@ data class FlutterTtsPreferences( jsonObject.put("voices", voicesJson) } jsonObject.put("controlPanelInfoType", preferences.controlPanelInfoType?.toString()) - preferences.skipPageBreaks?.let { jsonObject.put("skipPageBreaks", it) } + preferences.pageBreakBehavior?.let { jsonObject.put("pageBreakBehavior", PageBreakBehavior.toString(it)) } return jsonObject } @@ -142,7 +142,7 @@ data class FlutterTtsPreferences( ControlPanelInfoType.fromString( ttsPrefs?.get("controlPanelInfoType") as? String ?: "standard", ), - skipPageBreaks = ttsPrefs?.get("skipPageBreaks") as? Boolean, + pageBreakBehavior = PageBreakBehavior.fromString(ttsPrefs?.get("pageBreakBehavior") as? String), ) } } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PageBreakBehavior.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PageBreakBehavior.kt new file mode 100644 index 00000000..658476ce --- /dev/null +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PageBreakBehavior.kt @@ -0,0 +1,25 @@ +package dk.nota.flutterreadium + +enum class PageBreakBehavior { + READ_AS_IS, + PREFIX_LABEL, + SKIP, + ; + + companion object { + fun fromString(value: String?): PageBreakBehavior? = + when (value?.lowercase()) { + "readasis" -> READ_AS_IS + "prefixlabel" -> PREFIX_LABEL + "skip" -> SKIP + else -> null + } + + fun toString(b: PageBreakBehavior): String = + when (b) { + READ_AS_IS -> "readAsIs" + PREFIX_LABEL -> "prefixLabel" + SKIP -> "skip" + } + } +} 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 1463e338..b8a4738a 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 @@ -1044,7 +1044,7 @@ object ReadiumReader : suspend fun ttsEnable(ttsPrefs: FlutterTtsPreferences) { currentPublication?.let { - pageBreakIteratorFactory?.skipPageBreaks = ttsPrefs.skipPageBreaks ?: true + pageBreakIteratorFactory?.pageBreakBehavior = ttsPrefs.pageBreakBehavior ?: PageBreakBehavior.READ_AS_IS ttsNavigator = TTSNavigator(it, this@ReadiumReader, currentTextLocator.value, ttsPrefs).apply { initNavigator() @@ -1053,7 +1053,7 @@ object ReadiumReader : } suspend fun ttsSetPreferences(ttsPrefs: FlutterTtsPreferences) { - pageBreakIteratorFactory?.skipPageBreaks = ttsPrefs.skipPageBreaks ?: true + pageBreakIteratorFactory?.pageBreakBehavior = ttsPrefs.pageBreakBehavior ?: PageBreakBehavior.READ_AS_IS ttsNavigator?.updatePreferences(ttsPrefs) ?: throw Exception("TTS is not enabled, can't set preferences") } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt index 8a7b61dc..fe9a188f 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/navigators/PageBreakSkippingContentIteratorFactory.kt @@ -1,5 +1,6 @@ package dk.nota.flutterreadium.navigators +import dk.nota.flutterreadium.PageBreakBehavior import org.readium.r2.shared.ExperimentalReadiumApi import org.readium.r2.shared.publication.LocalizedString import org.readium.r2.shared.publication.Locator @@ -21,15 +22,18 @@ import org.readium.r2.shared.util.resource.Resource * the match is exact. Inline spans inside a paragraph are not filtered (their text is merged * into the parent block's element and not individually addressable at this level). * - * When [skipPageBreaks] is `true` (default), page-break elements are removed from the iterator. - * When `false`, they are kept but the label text from the original element is rewritten to a - * localized string (e.g. "Page 42" or "side 42"), derived from the publication's declared - * language. [skipPageBreaks] can be changed at any time; the change takes effect on the next + * [pageBreakBehavior] controls how page-break elements are handled: + * - [PageBreakBehavior.READ_AS_IS] (default) — element passes through unchanged. + * - [PageBreakBehavior.PREFIX_LABEL] — label text is rewritten to a localized string + * (e.g. "Page 42"), derived from the publication's declared language. + * - [PageBreakBehavior.SKIP] — element is removed from the iterator. + * + * [pageBreakBehavior] can be changed at any time; the change takes effect on the next * [Content.Iterator.hasNext] / [Content.Iterator.hasPrevious] call. */ @OptIn(ExperimentalReadiumApi::class) class PageBreakSkippingContentIteratorFactory( - var skipPageBreaks: Boolean = true, + var pageBreakBehavior: PageBreakBehavior = PageBreakBehavior.READ_AS_IS, private val delegate: ResourceContentIteratorFactory = HtmlResourceContentIterator.Factory(), ) : ResourceContentIteratorFactory { override suspend fun create( @@ -46,7 +50,7 @@ class PageBreakSkippingContentIteratorFactory( val pageBreakIds = manifest.pageBreakIds() if (pageBreakIds.isEmpty()) return inner - return PageBreakHandlingIterator(inner, pageBreakIds, ::skipPageBreaks, manifest.pageLabel()) + return PageBreakHandlingIterator(inner, pageBreakIds, ::pageBreakBehavior, manifest.pageLabel()) } private fun Manifest.pageBreakIds(): Set = @@ -85,7 +89,7 @@ class PageBreakSkippingContentIteratorFactory( private class PageBreakHandlingIterator( private val delegate: Content.Iterator, private val pageBreakIds: Set, - private val shouldPageElementsSkip: () -> Boolean, + private val pageBreakBehavior: () -> PageBreakBehavior, private val pageLabel: ((String) -> String)?, ) : Content.Iterator { private data class Pending( @@ -100,7 +104,7 @@ private class PageBreakHandlingIterator( pending = null while (delegate.hasNext()) { val element = delegate.next() - if (!shouldPageElementsSkip() || !element.isPageBreak()) { + if (pageBreakBehavior() != PageBreakBehavior.SKIP || !element.isPageBreak()) { pending = Pending(element, forward = true) return true } @@ -123,7 +127,7 @@ private class PageBreakHandlingIterator( pending = null while (delegate.hasPrevious()) { val element = delegate.previous() - if (!shouldPageElementsSkip() || !element.isPageBreak()) { + if (pageBreakBehavior() != PageBreakBehavior.SKIP || !element.isPageBreak()) { pending = Pending(element, forward = false) return true } @@ -142,7 +146,8 @@ private class PageBreakHandlingIterator( ) private fun Content.Element.transform(): Content.Element { - if (shouldPageElementsSkip() || !isPageBreak()) return this + if (!isPageBreak()) return this + if (pageBreakBehavior() != PageBreakBehavior.PREFIX_LABEL) return this val textElement = this as Content.TextElement val rawLabel = textElement.segments.joinToString("") { it.text }.trim() if (rawLabel.isEmpty()) return this 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 ce4d9c35..b9c8d856 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -207,7 +207,7 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin Task { @MainActor in // Start TTS from the reader's current location - self.pageBreakIteratorFactory?.skipPageBreaks = ttsPrefs.skipPageBreaks ?? true + self.pageBreakIteratorFactory?.pageBreakBehavior = ttsPrefs.pageBreakBehavior ?? .readAsIs let currentLocation = self.currentReaderView?.getCurrentLocation() self.timebasedNavigator = FlutterTTSNavigator(publication: publication, preferences: ttsPrefs, initialLocator: currentLocation) self.timebasedNavigator?.listener = self @@ -274,7 +274,7 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin } do { let ttsPrefs = try TTSPreferences(fromMap: args!) - pageBreakIteratorFactory?.skipPageBreaks = ttsPrefs.skipPageBreaks ?? true + pageBreakIteratorFactory?.pageBreakBehavior = ttsPrefs.pageBreakBehavior ?? .readAsIs ttsNavigator.ttsSetPreferences(prefs: ttsPrefs) result(nil) } catch { diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterTTSPreferences.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterTTSPreferences.swift index d0b2c497..35e9043c 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterTTSPreferences.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/FlutterTTSPreferences.swift @@ -19,8 +19,7 @@ public struct TTSPreferences { public var controlPanelInfoType: ControlPanelInfoType? - /// Whether to skip page-break elements during TTS. When nil, defaults to true (skip). - public var skipPageBreaks: Bool? + public var pageBreakBehavior: PageBreakBehavior? public init( rate: Float? = nil, @@ -28,14 +27,14 @@ public struct TTSPreferences { overrideLanguage: Language? = nil, voiceIdentifier: String? = nil, controlPanelInfoType: ControlPanelInfoType = .standard, - skipPageBreaks: Bool? = nil + pageBreakBehavior: PageBreakBehavior? = nil ) { self.rate = rate self.pitch = pitch self.overrideLanguage = overrideLanguage self.voiceIdentifier = voiceIdentifier self.controlPanelInfoType = controlPanelInfoType - self.skipPageBreaks = skipPageBreaks + self.pageBreakBehavior = pageBreakBehavior } init(fromMap jsonMap: Dictionary) throws { @@ -48,7 +47,7 @@ public struct TTSPreferences { let controlPanelInfoTypeStr = map["controlPanelInfoType"] as? String let mapControlPanelInfoType = ControlPanelInfoType(from: controlPanelInfoTypeStr) - let skipPageBreaks = map["skipPageBreaks"] as? Bool + let pageBreakBehavior = PageBreakBehavior(from: map["pageBreakBehavior"] as? String) /// Rate is normalized on iOS, since AVSpeechUtterance has a default rate of 0.5 (see AVSpeechUtteranceDefaultSpeechRate) /// Rate is also clamped between allowed values. let avRate = clamp(Float(rate) * AVSpeechUtteranceDefaultSpeechRate, @@ -58,6 +57,6 @@ public struct TTSPreferences { let avPitch = clamp(Float(pitch), minValue: 0.5, maxValue: 2.0) - self.init(rate: avRate, pitch: avPitch, overrideLanguage: overrideLanguage, voiceIdentifier: voiceIdentifier, controlPanelInfoType: mapControlPanelInfoType, skipPageBreaks: skipPageBreaks) + self.init(rate: avRate, pitch: avPitch, overrideLanguage: overrideLanguage, voiceIdentifier: voiceIdentifier, controlPanelInfoType: mapControlPanelInfoType, pageBreakBehavior: pageBreakBehavior) } } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/PageBreakSkippingContentIteratorFactory.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/PageBreakSkippingContentIteratorFactory.swift index 0da373b6..1f2686c9 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/PageBreakSkippingContentIteratorFactory.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/PageBreakSkippingContentIteratorFactory.swift @@ -1,16 +1,35 @@ import Foundation import ReadiumShared +public enum PageBreakBehavior { + /// Pass page-break elements through unchanged — the raw label text is spoken. + case readAsIs + /// Rewrite the label with a localized prefix, e.g. "Page 42" or "side 42". + case prefixLabel + /// Filter page-break elements out of the iterator entirely. + case skip + + public init(from string: String?) { + switch string?.lowercased() { + case "readasis": self = .readAsIs + case "prefixlabel": self = .prefixLabel + case "skip": self = .skip + default: self = .readAsIs + } + } +} + /// Wraps `HTMLResourceContentIterator.Factory` and handles EPUB page-break elements for TTS. /// -/// When `skipPageBreaks` is `true` (default), page-break elements are silently skipped. -/// When `false`, the raw label text from the element is rewritten to a localized string -/// (e.g. "Page 42" / "side 42"), derived from the publication's declared language. +/// `pageBreakBehavior` controls how page-break elements are handled: +/// - `.readAsIs` (default) — element passes through unchanged. +/// - `.prefixLabel` — label text is rewritten to a localized string (e.g. "Page 42"). +/// - `.skip` — element is filtered out entirely. /// /// Page-break elements are identified by matching the element's CSS selector against /// the fragment IDs declared in the publication's pageList nav. final class PageBreakSkippingContentIteratorFactory: ResourceContentIteratorFactory { - var skipPageBreaks: Bool = true + var pageBreakBehavior: PageBreakBehavior = .readAsIs private let delegate: ResourceContentIteratorFactory @@ -37,7 +56,7 @@ final class PageBreakSkippingContentIteratorFactory: ResourceContentIteratorFact return PageBreakHandlingIterator( delegate: inner, pageBreakIds: pageBreakIds, - shouldSkip: { [weak self] in self?.skipPageBreaks ?? true }, + pageBreakBehavior: { [weak self] in self?.pageBreakBehavior ?? .readAsIs }, pageLabel: publication.manifest.pageLabel() ) } @@ -74,24 +93,24 @@ private extension Manifest { private final class PageBreakHandlingIterator: ContentIterator { private let delegate: ContentIterator private let pageBreakIds: Set - private let shouldSkip: () -> Bool + private let pageBreakBehavior: () -> PageBreakBehavior private let pageLabel: ((String) -> String)? init( delegate: ContentIterator, pageBreakIds: Set, - shouldSkip: @escaping () -> Bool, + pageBreakBehavior: @escaping () -> PageBreakBehavior, pageLabel: ((String) -> String)? ) { self.delegate = delegate self.pageBreakIds = pageBreakIds - self.shouldSkip = shouldSkip + self.pageBreakBehavior = pageBreakBehavior self.pageLabel = pageLabel } func next() async throws -> ContentElement? { while let element = try await delegate.next() { - if shouldSkip() && isPageBreak(element) { continue } + if pageBreakBehavior() == .skip && isPageBreak(element) { continue } return transform(element) } return nil @@ -99,15 +118,15 @@ private final class PageBreakHandlingIterator: ContentIterator { func previous() async throws -> ContentElement? { while let element = try await delegate.previous() { - if shouldSkip() && isPageBreak(element) { continue } + if pageBreakBehavior() == .skip && isPageBreak(element) { continue } return transform(element) } return nil } private func transform(_ element: ContentElement) -> ContentElement { - guard !shouldSkip(), let textElement = element as? TextContentElement else { return element } - guard isPageBreak(element) else { return element } + guard isPageBreak(element), pageBreakBehavior() == .prefixLabel, + let textElement = element as? TextContentElement else { return element } guard let rawLabel = textElement.text?.trimmingCharacters(in: .whitespaces), !rawLabel.isEmpty else { return element } let label = pageLabel?(rawLabel) ?? rawLabel diff --git a/flutter_readium_platform_interface/CHANGELOG.md b/flutter_readium_platform_interface/CHANGELOG.md index 3f6d3447..b6f7be18 100644 --- a/flutter_readium_platform_interface/CHANGELOG.md +++ b/flutter_readium_platform_interface/CHANGELOG.md @@ -7,8 +7,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- **`TTSPreferences.skipPageBreaks`** (`bool?`) — opt in/out of reading EPUB page-break elements - aloud during TTS playback. +- **`TTSPreferences.pageBreakBehavior`** (`PageBreakBehavior?`) — controls how EPUB page-break + elements are handled during TTS playback. Values: `readAsIs` (default — raw label spoken as-is), + `prefixLabel` (label rewritten with a localized prefix, e.g. "Page 42"), `skip` (element + filtered out entirely). Replaces the previous `skipPageBreaks` bool. ### Fixed diff --git a/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart b/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart index 6059b27e..e9d7981b 100644 --- a/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart +++ b/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; @@ -5,6 +6,21 @@ import '../utils/jsonable.dart'; import '../utils/readium_log.dart'; import 'reader_audio_preferences.dart'; +enum PageBreakBehavior { + /// Pass page-break elements through unchanged — the raw label text is spoken. + readAsIs, + + /// Rewrite the label with a localized prefix, e.g. "Page 42" or "side 42". + prefixLabel, + + /// Filter page-break elements out of the iterator entirely. + skip; + + static PageBreakBehavior? optFromString(final String? value) => PageBreakBehavior.values.firstWhereOrNull( + (e) => e.name.toLowerCase() == value?.toLowerCase(), + ); +} + @immutable class TTSPreferences with EquatableMixin implements JSONable { factory TTSPreferences.fromJson(final Map json) { @@ -26,7 +42,9 @@ class TTSPreferences with EquatableMixin implements JSONable { 'languageOverride', remove: true, ); - final skipPageBreaks = jsonObject.remove('skipPageBreaks') as bool?; + final pageBreakBehavior = PageBreakBehavior.optFromString( + jsonObject.remove('pageBreakBehavior') as String?, + ); final controlPanelInfoTypeStr = jsonObject.optNullableString( 'controlPanelInfoType', remove: true, @@ -51,7 +69,7 @@ class TTSPreferences with EquatableMixin implements JSONable { voices: voices, languageOverride: languageOverride, controlPanelInfoType: controlPanelInfoType ?? ControlPanelInfoType.standard, - skipPageBreaks: skipPageBreaks, + pageBreakBehavior: pageBreakBehavior, ); } @@ -62,7 +80,7 @@ class TTSPreferences with EquatableMixin implements JSONable { this.voices = const {}, this.languageOverride, this.controlPanelInfoType, - this.skipPageBreaks, + this.pageBreakBehavior, }); /// The speech rate (speed) for text-to-speech. A value of 1.0 is the normal speed, less than 1.0 is slower, and greater than 1.0 is faster. @@ -83,9 +101,9 @@ class TTSPreferences with EquatableMixin implements JSONable { /// Control panel info type to determine what information is sent to the control panel during TTS playback. final ControlPanelInfoType? controlPanelInfoType; - /// Whether to skip page-break elements during TTS playback. When false, page numbers are read - /// aloud with a localized prefix (e.g. "Page 42"). Defaults to true (skip). - final bool? skipPageBreaks; + /// How page-break elements are handled during TTS playback. + /// When null, defaults to [PageBreakBehavior.readAsIs]. + final PageBreakBehavior? pageBreakBehavior; @override Map toJson() => {} @@ -95,7 +113,7 @@ class TTSPreferences with EquatableMixin implements JSONable { ..putMapIfNotEmpty('voices', voices) ..putOpt('languageOverride', languageOverride) ..putOpt('controlPanelInfoType', controlPanelInfoType?.toString().split('.').last) - ..putOpt('skipPageBreaks', skipPageBreaks); + ..putOpt('pageBreakBehavior', pageBreakBehavior?.name); @override List get props => [ @@ -105,6 +123,6 @@ class TTSPreferences with EquatableMixin implements JSONable { voices, languageOverride, controlPanelInfoType, - skipPageBreaks, + pageBreakBehavior, ]; } From fa485a9835999c6f3819bdf8ff2d0ace54fdb4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Mon, 29 Jun 2026 12:55:04 +0200 Subject: [PATCH 5/9] init web support for page-break --- flutter_readium/CHANGELOG.md | 2 +- .../example/lib/pages/bookshelf.page.dart | 3 + .../web/src/navigators/FlutterTTSNavigator.ts | 90 +++++++++++++++++-- .../src/preferences/FlutterTTSPreferences.ts | 10 ++- .../CHANGELOG.md | 2 +- 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index 348e9e50..2460f50c 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -12,7 +12,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). `readAsIs` (default — raw label text spoken unchanged), `prefixLabel` (label rewritten with a localized prefix, e.g. "Page 42" / "side 42"; supports English, Danish, Swedish, Norwegian, Icelandic; falls back to the raw label otherwise), `skip` (element filtered out entirely). - Replaces the previous `skipPageBreaks` bool. Supported on Android and iOS. + Replaces the previous `skipPageBreaks` bool. Supported on Android, iOS and web. ### Changed (breaking) diff --git a/flutter_readium/example/lib/pages/bookshelf.page.dart b/flutter_readium/example/lib/pages/bookshelf.page.dart index a617f70b..e0eab2b6 100644 --- a/flutter_readium/example/lib/pages/bookshelf.page.dart +++ b/flutter_readium/example/lib/pages/bookshelf.page.dart @@ -194,6 +194,9 @@ class BookshelfPageState extends State { String _bookFormatFromConformsTo(Publication pub) { if (pub.conformsToReadiumEbook) { + if (pub.isAudioBook) { + return 'Narrated Ebook'; + } return 'Ebook'; } else if (pub.conformsToReadiumAudiobook) { return 'Audiobook'; diff --git a/flutter_readium/web/src/navigators/FlutterTTSNavigator.ts b/flutter_readium/web/src/navigators/FlutterTTSNavigator.ts index 8b3266e0..d71bb6ab 100644 --- a/flutter_readium/web/src/navigators/FlutterTTSNavigator.ts +++ b/flutter_readium/web/src/navigators/FlutterTTSNavigator.ts @@ -21,8 +21,10 @@ import { HTMLResourceContentIterator, Link, Locator, + Manifest, PublicationContentIterator, TextElement, + TextSegment, } from "@readium/shared"; import { ReadiumPublication } from "../utils/ReadiumExtensions"; import { createLogger } from "../utils/ReadiumPluginLogger"; @@ -90,6 +92,63 @@ function emitLocator(locator: Locator) { window.updateTextLocator?.(JSON.stringify(normalizeLocatorJson(locator))); } +// --------------------------------------------------------------------------- +// Page-break helpers +// --------------------------------------------------------------------------- + +const PAGE_LABEL_FORMATS: Record = { + en: "Page {label}", + da: "side {label}", + no: "side {label}", + sv: "sida {label}", + is: "{label}. síða", +}; + +function pageBreakIdsFromManifest(manifest: Manifest): Set { + const pageList = manifest.subcollections?.get("pageList"); + if (!pageList || pageList.length === 0) return new Set(); + const ids = new Set(); + for (const link of pageList[0].links.items) { + try { + const hash = new URL(link.href, "http://localhost").hash; + if (hash.startsWith("#") && hash.length > 1) ids.add(hash.slice(1)); + } catch { + // ignore malformed hrefs + } + } + return ids; +} + +function makePageLabelFormatter( + manifest: Manifest +): ((label: string) => string) | null { + const lang = manifest.metadata.languages?.[0]; + if (!lang) return null; + const base = lang.split("-")[0].toLowerCase(); + const format = PAGE_LABEL_FORMATS[base]; + if (!format) return null; + return (label) => format.replace("{label}", label); +} + +function isPageBreak(element: TextElement, ids: Set): boolean { + if (ids.size === 0) return false; + const selector = element.locator.locations.getCssSelector(); + return !!selector && selector.startsWith("#") && ids.has(selector.slice(1)); +} + +function rewriteAsPageLabel( + element: TextElement, + formatter: ((label: string) => string) | null +): TextElement { + const rawLabel = element.text?.trim() ?? ""; + if (!rawLabel) return element; + const label = formatter ? formatter(rawLabel) : rawLabel; + const newSegments = element.segments.map((seg, i) => + new TextSegment(seg.locator, i === 0 ? label : "", seg._attributes) + ); + return new TextElement(element.locator, element.role, newSegments, element._attributes); +} + export class FlutterTTSNavigator { private readonly _nav: AnyNavigator; private readonly _publication: ReadiumPublication; @@ -98,7 +157,7 @@ export class FlutterTTSNavigator { * When true (default), each utterance scrolls the visual navigator to the * spoken paragraph. Mirrors `EPUBPreferences.disableSynchronization` on native * — see kotlin-toolkit's gate at ReadiumReader.kt:1420 for the equivalent - * behaviour on Android. + * behavior on Android. */ private _syncEnabled: boolean; @@ -133,6 +192,9 @@ export class FlutterTTSNavigator { */ private _onApplyDecorations: ((group: string, decorationsJson: string) => void) | null; + private readonly _pageBreakIds: Set; + private readonly _pageLabel: ((label: string) => string) | null; + constructor( nav: AnyNavigator, publication: ReadiumPublication, @@ -150,6 +212,8 @@ export class FlutterTTSNavigator { this._rangeStyle = rangeStyle; this._onApplyDecorations = onApplyDecorations; this._flatToc = flattenToc(publication.manifest.toc?.items ?? []); + this._pageBreakIds = pageBreakIdsFromManifest(publication.manifest); + this._pageLabel = makePageLabelFormatter(publication.manifest); // Hard-reset Chrome's speechSynthesis on construction. Leftover state from // a previous publication (or a wedge that survived a page navigation) can // prevent the very first speak() of the new session from dispatching @@ -342,8 +406,16 @@ export class FlutterTTSNavigator { return; } - this._currentElement = element; - this._speakElement(element); + if (this._prefs.pageBreakBehavior === "skip" && isPageBreak(element, this._pageBreakIds)) { + await this._speakNext(); + return; + } + const finalElement = + this._prefs.pageBreakBehavior === "prefixLabel" && isPageBreak(element, this._pageBreakIds) + ? rewriteAsPageLabel(element, this._pageLabel) + : element; + this._currentElement = finalElement; + this._speakElement(finalElement); } private async _speakPrevious(): Promise { @@ -364,8 +436,16 @@ export class FlutterTTSNavigator { return; } - this._currentElement = element; - this._speakElement(element); + if (this._prefs.pageBreakBehavior === "skip" && isPageBreak(element, this._pageBreakIds)) { + await this._speakPrevious(); + return; + } + const finalElement = + this._prefs.pageBreakBehavior === "prefixLabel" && isPageBreak(element, this._pageBreakIds) + ? rewriteAsPageLabel(element, this._pageLabel) + : element; + this._currentElement = finalElement; + this._speakElement(finalElement); } private _speakElement(element: TextElement): void { diff --git a/flutter_readium/web/src/preferences/FlutterTTSPreferences.ts b/flutter_readium/web/src/preferences/FlutterTTSPreferences.ts index 07fed6e6..4d68ea30 100644 --- a/flutter_readium/web/src/preferences/FlutterTTSPreferences.ts +++ b/flutter_readium/web/src/preferences/FlutterTTSPreferences.ts @@ -13,6 +13,7 @@ export interface WebTTSPreferences { voice: SpeechSynthesisVoice | null; /** BCP-47 language override – applied when voice is null. */ lang: string | null; + pageBreakBehavior?: "readAsIs" | "prefixLabel" | "skip"; } const DEFAULT_RATE = 1.0; @@ -61,7 +62,14 @@ export function ttsPreferencesFromJson(json: Record): WebTTSPrefere voice = speechSynthesis.getVoices().find((v) => v.voiceURI === voiceId) ?? null; } - return { rate, pitch, voice, lang: langOverride }; + const pageBreakBehavior = + json.pageBreakBehavior === "readAsIs" || + json.pageBreakBehavior === "prefixLabel" || + json.pageBreakBehavior === "skip" + ? (json.pageBreakBehavior as WebTTSPreferences["pageBreakBehavior"]) + : undefined; + + return { rate, pitch, voice, lang: langOverride, pageBreakBehavior }; } /** diff --git a/flutter_readium_platform_interface/CHANGELOG.md b/flutter_readium_platform_interface/CHANGELOG.md index b6f7be18..c2e770cf 100644 --- a/flutter_readium_platform_interface/CHANGELOG.md +++ b/flutter_readium_platform_interface/CHANGELOG.md @@ -10,7 +10,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`TTSPreferences.pageBreakBehavior`** (`PageBreakBehavior?`) — controls how EPUB page-break elements are handled during TTS playback. Values: `readAsIs` (default — raw label spoken as-is), `prefixLabel` (label rewritten with a localized prefix, e.g. "Page 42"), `skip` (element - filtered out entirely). Replaces the previous `skipPageBreaks` bool. + filtered out entirely). ### Fixed From 32acd30273c869c729a7865b878ca7ec8e60e8d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Mon, 29 Jun 2026 13:05:01 +0200 Subject: [PATCH 6/9] label ebooks with media overlays or guided navigation as 'Ebook with audio' --- flutter_readium/example/lib/pages/bookshelf.page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter_readium/example/lib/pages/bookshelf.page.dart b/flutter_readium/example/lib/pages/bookshelf.page.dart index e0eab2b6..468f99a5 100644 --- a/flutter_readium/example/lib/pages/bookshelf.page.dart +++ b/flutter_readium/example/lib/pages/bookshelf.page.dart @@ -195,7 +195,7 @@ class BookshelfPageState extends State { String _bookFormatFromConformsTo(Publication pub) { if (pub.conformsToReadiumEbook) { if (pub.isAudioBook) { - return 'Narrated Ebook'; + return 'Ebook with audio'; } return 'Ebook'; } else if (pub.conformsToReadiumAudiobook) { From 347340eb5ccc997be02f8713cb8ba2342d3b3d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Mon, 29 Jun 2026 15:58:37 +0200 Subject: [PATCH 7/9] minor update to pagebreak css --- .../assets/_helper_scripts/src/FlutterReadiumTools.scss | 8 ++++++-- .../lib/src/reader/reader_tts_preferences.dart | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/flutter_readium/assets/_helper_scripts/src/FlutterReadiumTools.scss b/flutter_readium/assets/_helper_scripts/src/FlutterReadiumTools.scss index c1b749c6..badbad1a 100644 --- a/flutter_readium/assets/_helper_scripts/src/FlutterReadiumTools.scss +++ b/flutter_readium/assets/_helper_scripts/src/FlutterReadiumTools.scss @@ -1,12 +1,16 @@ -[type='pagebreak'] { +[type='pagebreak'], [epub\:type='pagebreak'] { border-top: 1px solid; display: inline-block; width: 100%; line-height: 100%; padding-top: 8px; + text-align: right; + + // --USER__paraSpacing overrides margin in readium-advanced-on margin-top: 40px; margin-bottom: 20px; - text-align: right; + + // Overridden in readium-advanced-on font-size: 98%; } diff --git a/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart b/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart index 217d0913..3f06773f 100644 --- a/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart +++ b/flutter_readium_platform_interface/lib/src/reader/reader_tts_preferences.dart @@ -19,6 +19,8 @@ enum PageBreakBehavior { static PageBreakBehavior? optFromString(final String? value) => PageBreakBehavior.values.firstWhereOrNull( (e) => e.name.toLowerCase() == value?.toLowerCase(), ); + + static PageBreakBehavior fromString(final String value) => optFromString(value) ?? readAsIs; } @immutable From 2bf82ece1474c44e0dbfe7a7f707eae859279a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Mon, 29 Jun 2026 16:16:35 +0200 Subject: [PATCH 8/9] fix: broke PDF rendering on iOS --- .vscode/settings.json | 6 +++++- .../flutter_readium/FlutterReadiumPlugin.swift | 16 +++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f6f3ad56..bd751403 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -67,6 +67,7 @@ "fimber", "Fimber", "flutterreadium", + "gradlew", "imgref", "jsonable", "kotlin", @@ -75,6 +76,7 @@ "Mantano", "mediatype", "microenterprise", + "Notalib", "NYPL", "opds", "Opds", @@ -92,8 +94,10 @@ "subcollections", "syncnarr", "textref", + "tokensave", "Transformability", - "videoref" + "videoref", + "Worktree" ], "java.configuration.updateBuildConfiguration": "interactive", "dart.mcpServer": true, 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 b9c8d856..af7cc6ce 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -633,17 +633,19 @@ extension FlutterReadiumPlugin { asset = try await sharedReadium.assetRetriever!.retrieve(url: url).get() } - let factory = PageBreakSkippingContentIteratorFactory() - self.pageBreakIteratorFactory = factory let publication = try await sharedReadium.publicationOpener!.open( asset: asset, allowUserInteraction: allowUserInteraction, - onCreatePublication: { _, _, services in - services.setContentServiceFactory( - DefaultContentService.makeFactory( - resourceContentIteratorFactories: [factory] + onCreatePublication: { manifest, _, services in + if manifest.conforms(to: .epub) { + let factory = PageBreakSkippingContentIteratorFactory() + self.pageBreakIteratorFactory = factory + services.setContentServiceFactory( + DefaultContentService.makeFactory( + resourceContentIteratorFactories: [factory] + ) ) - ) + } }, sender: sender ).get() From 31e3ab452aa0e6c063698d0aa07dcc7ad467d748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Sj=C3=B8gren?= Date: Mon, 29 Jun 2026 16:18:10 +0200 Subject: [PATCH 9/9] update CLAUDE.md require compilation check before marking something as done --- CLAUDE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d7e8568..848ebe58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,10 @@ When upgrading a toolkit, move all three platforms together where API surface ov - `bin/doctor` — verify toolchain. `bin/install` — full bootstrap after clone / dependency change. - **Before any PR:** `bin/format` + `bin/analyze` (both cover all three packages); fix everything they report. -- **After editing web TS (`flutter_readium/web/`):** `bin/typecheck`, then `bin/update_web_example`. Never hand-edit built JS. +- **Before declaring any Swift changes done:** run `flutter build ios --no-codesign` in `flutter_readium/example` and fix all errors. +- **Before declaring any Kotlin changes done:** run `./gradlew :flutter_readium:compileDebugKotlin` in `flutter_readium/example/android/` and fix all errors. +- **Before declaring any web TS changes done:** run `bin/typecheck`, then `bin/update_web_example`. Never hand-edit built JS. +- **Before declaring any `bin/` script changes done:** run `bash -n