Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"fimber",
"Fimber",
"flutterreadium",
"gradlew",
"imgref",
"jsonable",
"kotlin",
Expand All @@ -75,6 +76,7 @@
"Mantano",
"mediatype",
"microenterprise",
"Notalib",
"NYPL",
"opds",
"Opds",
Expand All @@ -92,8 +94,10 @@
"subcollections",
"syncnarr",
"textref",
"tokensave",
"Transformability",
"videoref"
"videoref",
"Worktree"
],
"java.configuration.updateBuildConfiguration": "interactive",
"dart.mcpServer": true,
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script>` for each edited script and fix any syntax errors.
- **Code research**: prefer `tokensave_*` MCP tools over grep/Explore (`.tokensave/`, gitignored; `tokensave sync` after pulling).
- **Branching workflow** — never commit to `main`, and never let a branch track `Notalib/flutter_readium`:
- Worktree branches created by agents often track upstream `main` — rename and re-track before committing: `git branch -m fix/short-slug && git push -u <fork> HEAD`.
Expand Down
9 changes: 9 additions & 0 deletions flutter_readium/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## Unreleased

### Added

- **`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.

## [0.1.1] - 2026-06-26

### Changed (breaking)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ data class FlutterTtsPreferences(
val speed: Double? = null,
val voices: Map<String, String>? = null,
val controlPanelInfoType: ControlPanelInfoType? = ControlPanelInfoType.STANDARD,
val pageBreakBehavior: PageBreakBehavior? = null,
val controlPanelTimebase: ControlPanelTimebase? = ControlPanelTimebase.CHAPTER,
) {
/**
Expand Down Expand Up @@ -51,6 +52,7 @@ data class FlutterTtsPreferences(
speed = other.speed ?: speed,
voices = other.voices ?: voices,
controlPanelInfoType = other.controlPanelInfoType ?: controlPanelInfoType,
pageBreakBehavior = other.pageBreakBehavior ?: pageBreakBehavior,
controlPanelTimebase = other.controlPanelTimebase ?: controlPanelTimebase,
)

Expand Down Expand Up @@ -84,6 +86,7 @@ data class FlutterTtsPreferences(
"standard",
),
),
pageBreakBehavior = PageBreakBehavior.fromString(jsonObject.optString("pageBreakBehavior").ifEmpty { null }),
controlPanelTimebase =
ControlPanelTimebase.fromString(
jsonObject.optString(
Expand All @@ -108,6 +111,7 @@ data class FlutterTtsPreferences(
jsonObject.put("voices", voicesJson)
}
jsonObject.put("controlPanelInfoType", preferences.controlPanelInfoType?.toString())
preferences.pageBreakBehavior?.let { jsonObject.put("pageBreakBehavior", PageBreakBehavior.toString(it)) }
jsonObject.put("controlPanelTimebase", preferences.controlPanelTimebase?.let(ControlPanelTimebase::toString))
return jsonObject
}
Expand Down Expand Up @@ -150,6 +154,7 @@ data class FlutterTtsPreferences(
ControlPanelInfoType.fromString(
ttsPrefs?.get("controlPanelInfoType") as? String ?: "standard",
),
pageBreakBehavior = PageBreakBehavior.fromString(ttsPrefs?.get("pageBreakBehavior") as? String),
controlPanelTimebase =
ControlPanelTimebase.fromString(
ttsPrefs?.get("controlPanelTimebase") as? String ?: DEFAULT_CONTENT_PANEL_TIMEBASE_STRING,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -161,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

Expand Down Expand Up @@ -459,6 +465,14 @@ object ReadiumReader :
publicationOpener
.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(factory),
)
}
})
.getOrElse { err: OpenError ->
fun unwrapCause(e: org.readium.r2.shared.util.Error?): String =
Expand Down Expand Up @@ -676,6 +690,7 @@ object ReadiumReader :

_currentPublication?.close()
_currentPublication = null
pageBreakIteratorFactory = null
currentPublicationCssSelectorMap = null

state.clear()
Expand Down Expand Up @@ -1040,6 +1055,7 @@ object ReadiumReader :

suspend fun ttsEnable(ttsPrefs: FlutterTtsPreferences) {
currentPublication?.let {
pageBreakIteratorFactory?.pageBreakBehavior = ttsPrefs.pageBreakBehavior ?: PageBreakBehavior.READ_AS_IS
ttsNavigator =
TTSNavigator(it, this@ReadiumReader, currentTextLocator.value, ttsPrefs).apply {
initNavigator()
Expand All @@ -1049,6 +1065,7 @@ object ReadiumReader :
}

suspend fun ttsSetPreferences(ttsPrefs: FlutterTtsPreferences) {
pageBreakIteratorFactory?.pageBreakBehavior = ttsPrefs.pageBreakBehavior ?: PageBreakBehavior.READ_AS_IS
ttsNavigator?.updatePreferences(ttsPrefs)
?: throw Exception("TTS is not enabled, can't set preferences")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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
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 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).
*
* [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 pageBreakBehavior: PageBreakBehavior = PageBreakBehavior.READ_AS_IS,
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()
if (pageBreakIds.isEmpty()) return inner
return PageBreakHandlingIterator(inner, pageBreakIds, ::pageBreakBehavior, manifest.pageLabel())
}

private fun Manifest.pageBreakIds(): Set<String> =
subcollections["pageList"]
?.firstOrNull()
?.links
?.mapNotNull { link ->
link.href
.toString()
.substringAfter("#", "")
.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 PageBreakHandlingIterator(
private val delegate: Content.Iterator,
private val pageBreakIds: Set<String>,
private val pageBreakBehavior: () -> PageBreakBehavior,
private val pageLabel: ((String) -> String)?,
) : 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 (pageBreakBehavior() != PageBreakBehavior.SKIP || !element.isPageBreak()) {
pending = Pending(element, forward = true)
return true
}
}
return false
}

override fun next(): Content.Element =
pending
?.takeIf { it.forward }
?.element
?.also { pending = null }
?.transform()
?: 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 (pageBreakBehavior() != PageBreakBehavior.SKIP || !element.isPageBreak()) {
pending = Pending(element, forward = false)
return true
}
}
return false
}

override fun previous(): Content.Element =
pending
?.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 (!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
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
return selector.startsWith("#") && selector.drop(1) in pageBreakIds
}
}
Original file line number Diff line number Diff line change
@@ -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%;
}

Expand Down
3 changes: 3 additions & 0 deletions flutter_readium/example/lib/pages/bookshelf.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ class BookshelfPageState extends State<BookshelfPage> {

String _bookFormatFromConformsTo(Publication pub) {
if (pub.conformsToReadiumEbook) {
if (pub.isAudioBook) {
return 'Ebook with audio';
}
return 'Ebook';
} else if (pub.conformsToReadiumAudiobook) {
return 'Audiobook';
Expand Down
Loading
Loading