diff --git a/r2-shared/build.gradle b/r2-shared/build.gradle
index 78849088..9038dbb6 100644
--- a/r2-shared/build.gradle
+++ b/r2-shared/build.gradle
@@ -51,7 +51,12 @@ dependencies {
implementation 'nl.komponents.kovenant:kovenant-functional:3.3.0'
implementation 'joda-time:joda-time:2.9.9'
+ testImplementation "androidx.test:core-ktx:1.2.0"
+ testImplementation "androidx.test.ext:junit-ktx:1.1.1"
+ testImplementation "junit:junit:4.12"
+ testImplementation "net.sf.kxml:kxml2:2.3.0"
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
+ testImplementation "org.robolectric:robolectric:4.3.1"
testImplementation 'junit:junit:4.12'
testImplementation 'org.assertj:assertj-core:3.14.0'
testImplementation 'org.mockito:mockito-core:2.19.0'
diff --git a/r2-shared/src/main/java/org/readium/r2/shared/Deprecated.kt b/r2-shared/src/main/java/org/readium/r2/shared/Deprecated.kt
index 62c55659..45bd3b9c 100644
--- a/r2-shared/src/main/java/org/readium/r2/shared/Deprecated.kt
+++ b/r2-shared/src/main/java/org/readium/r2/shared/Deprecated.kt
@@ -23,6 +23,20 @@ import org.readium.r2.shared.publication.encryption.Encryption
import org.readium.r2.shared.publication.presentation.Presentation
import java.net.URL
+@Deprecated("Moved to another package", ReplaceWith("org.readium.r2.shared.publication.Locator"))
+typealias Locator = org.readium.r2.shared.publication.Locator
+
+@Deprecated("Renamed into [Locator.Locations]", ReplaceWith("Locator.Locations", "org.readium.r2.shared.publication.Locator"))
+typealias Locations = org.readium.r2.shared.publication.Locator.Locations
+
+@Deprecated("Renamed into [Locator.Text]", ReplaceWith("Locator.Text", "org.readium.r2.shared.publication.Locator"))
+typealias LocatorText = org.readium.r2.shared.publication.Locator.Text
+
+@Deprecated("Moved to another package", ReplaceWith("Locator.Text", "org.readium.r2.shared.publication.html.DomRange"))
+typealias DomRange = org.readium.r2.shared.publication.html.DomRange
+
+@Deprecated("Renamed into [DomRange.Point]", ReplaceWith("DomRange.Point", "org.readium.r2.shared.publication.html.DomRange"))
+typealias Range = org.readium.r2.shared.publication.html.DomRange.Point
@Deprecated("Refactored into [LocalizedString]", ReplaceWith("org.readium.r2.shared.publication.LocalizedString"))
typealias MultilanguageString = org.readium.r2.shared.publication.LocalizedString
diff --git a/r2-shared/src/main/java/org/readium/r2/shared/Locator.kt b/r2-shared/src/main/java/org/readium/r2/shared/Locator.kt
deleted file mode 100644
index eb493b71..00000000
--- a/r2-shared/src/main/java/org/readium/r2/shared/Locator.kt
+++ /dev/null
@@ -1,285 +0,0 @@
-/*
- * Module: r2-shared-kotlin
- * Developers: Aferdita Muriqi, Mostapha Idoubihi, Paul Stoica
- *
- * Copyright (c) 2018. Readium Foundation. All rights reserved.
- * Use of this source code is governed by a BSD-style license which is detailed in the
- * LICENSE file present in the project repository where this source code is maintained.
- */
-
-package org.readium.r2.shared
-
-import org.json.JSONObject
-import java.io.Serializable
-
-/**
- * Locator model - https://github.com/readium/architecture/tree/master/locators
- *
- * @val href: String - The href of the resource the locator points at.
- * @val type: String - The media type of the resource that the Locator Object points to.
- * @val title: String - The title of the chapter or section which is more relevant in the context of this locator.
- * @val location: Location - One or more alternative expressions of the location.
- * @val text: LocatorText? - Textual context of the locator.
- */
-
-open class Locator(var href: String? = null,
- var type: String? = null,
- var title: String? = null,
- var locations: Locations? = null,
- var text: LocatorText? = null) : JSONable, Serializable {
-
-
- companion object {
- fun fromJSON(json: JSONObject): Locator {
-
- val locator = Locator()
- if (json.has("href")) {
- locator.href = json.getString("href")
- }
- if (json.has("type")) {
- locator.type = json.getString("type")
- }
- if (json.has("title")) {
- locator.title = json.getString("title")
- }
- if (json.has("locations")) {
- locator.locations = Locations.fromJSON(JSONObject(json.getString("locations")))
- }
- if (json.has("text")) {
- locator.text = LocatorText.fromJSON(JSONObject(json.getString("text")))
- }
-
- return locator
- }
- }
-
- override fun toJSON(): JSONObject {
- val json = JSONObject()
-
- href.let {
- json.putOpt("href", href)
- }
- type.let {
- json.putOpt("type", type)
- }
- title.let {
- json.putOpt("title", title)
- }
- locations?.let {
- json.putOpt("locations", it.toJSON())
- }
- text?.let {
- json.putOpt("text", it.toJSON())
- }
- return json
- }
-
-}
-
-class LocatorText(var after: String? = null,
- var before: String? = null,
- var highlight: String? = null)
- : JSONable, Serializable {
-
- companion object {
- fun fromJSON(json: JSONObject): LocatorText {
-
- val location = LocatorText()
- if (json.has("before")) {
- location.before = json.getString("before")
- }
- if (json.has("highlight")) {
- location.highlight = json.getString("highlight")
- }
- if (json.has("after")) {
- location.after = json.getString("after")
- }
-
- return location
- }
- }
-
- override fun toJSON(): JSONObject {
- val json = JSONObject()
-
- before?.let {
- json.putOpt("before", before)
- }
- highlight?.let {
- json.putOpt("highlight", highlight)
- }
- after?.let {
- json.putOpt("after", after)
- }
-
- return json
- }
-
-}
-
-/**
- * Location : Class that contain the different variables needed to localize a particular position
- *
- * @var fragment: Long? - Contains one or more fragment in the resource referenced by the Locator Object.
- * @var progression: Double - Progression in the resource expressed as a percentage.
- * @var position: Long - An index in the publication.
- *
- */
-class Locations(var fragment: String? = null, // 1 = fragment identifier (toc, page lists, landmarks)
- var progression: Double? = null, // 2 = bookmarks
- var position: Long? = null, // 3 = goto page
- var cssSelector: String? = null,
- var partialCfi: String? = null,
- var domRange: DomRange? = null
-) : JSONable, Serializable {
-
- companion object {
- fun fromJSON(json: JSONObject): Locations {
-
- val location = Locations()
- if (json.has("fragment")) {
- location.fragment = json.getString("fragment")
- }
- if (json.has("progression")) {
- location.progression = json.getDouble("progression")
- }
- if (json.has("position")) {
- location.position = json.getLong("position")
- }
- if (json.has("cssSelector")) {
- location.cssSelector = json.getString("cssSelector")
- }
- if (json.has("partialCfi")) {
- location.partialCfi = json.getString("partialCfi")
- }
- if (json.has("domRange")) {
- location.domRange = DomRange.fromJSON(JSONObject(json.getString("domRange")))
- }
- return location
- }
-
- fun isEmpty(locations: Locations): Boolean {
- if (locations.fragment == null && locations.position == null && locations.progression == null && locations.cssSelector == null && locations.partialCfi == null && locations.domRange == null) {
- return true
- }
- return false
- }
-
- }
-
- override fun toJSON(): JSONObject {
- val json = JSONObject()
-
- fragment?.let {
- json.putOpt("fragment", fragment)
- }
- progression?.let {
- json.putOpt("progression", progression)
- }
- position?.let {
- json.putOpt("position", position)
- }
- cssSelector?.let {
- json.putOpt("cssSelector", cssSelector)
- }
- partialCfi?.let {
- json.putOpt("partialCfi", partialCfi)
- }
- domRange?.let {
- json.putOpt("domRange", it.toJSON())
- }
-
- return json
- }
-}
-
-class Range(var cssSelector: String? = null,
- var textNodeIndex: Long? = null,
- var offset: Long? = null
-) : JSONable, Serializable {
-
- companion object {
- fun fromJSON(json: JSONObject): Range {
-
- val range = Range()
- if (json.has("cssSelector")) {
- range.cssSelector = json.getString("cssSelector")
- }
- if (json.has("textNodeIndex")) {
- range.textNodeIndex = json.getLong("textNodeIndex")
- }
- if (json.has("offset")) {
- range.offset = json.getLong("offset")
- }
- return range
- }
-
- fun isEmpty(locations: Range): Boolean {
- if (locations.cssSelector == null && locations.textNodeIndex == null && locations.offset == null) {
- return true
- }
- return false
- }
-
- }
-
- override fun toJSON(): JSONObject {
- val json = JSONObject()
-
- cssSelector?.let {
- json.putOpt("cssSelector", cssSelector)
- }
- textNodeIndex?.let {
- json.putOpt("textNodeIndex", textNodeIndex)
- }
- offset?.let {
- json.putOpt("offset", offset)
- }
-
- return json
- }
-}
-
-
-class DomRange(var start: Range? = null,
- var end: Range? = null
-) : JSONable, Serializable {
-
- companion object {
- fun fromJSON(json: JSONObject): DomRange {
-
- val domRange = DomRange()
-
- if (json.has("start")) {
- domRange.start = Range.fromJSON(JSONObject(json.getString("start")))
- }
- if (json.has("end")) {
- domRange.end = Range.fromJSON(JSONObject(json.getString("end")))
- }
-
- return domRange
- }
-
- fun isEmpty(locations: DomRange): Boolean {
- if (locations.start == null && locations.end == null) {
- return true
- }
- return false
- }
-
- }
-
- override fun toJSON(): JSONObject {
- val json = JSONObject()
-
- start?.let {
- json.putOpt("start", it.toJSON())
- }
- end?.let {
- json.putOpt("end", it.toJSON())
- }
-
- return json
- }
-
-}
diff --git a/r2-shared/src/main/java/org/readium/r2/shared/extensions/JSON.kt b/r2-shared/src/main/java/org/readium/r2/shared/extensions/JSON.kt
index 29cf3064..f756797c 100644
--- a/r2-shared/src/main/java/org/readium/r2/shared/extensions/JSON.kt
+++ b/r2-shared/src/main/java/org/readium/r2/shared/extensions/JSON.kt
@@ -108,7 +108,7 @@ internal fun JSONObject.putIfNotEmpty(name: String, collection: Collection<*>) {
*/
fun JSONObject.optPositiveInt(name: String, fallback: Int = -1, remove: Boolean = false): Int? {
val int = optInt(name, fallback)
- val value = if (int > 0) int else null
+ val value = if (int >= 0) int else null
if (remove) {
this.remove(name)
}
@@ -122,7 +122,7 @@ fun JSONObject.optPositiveInt(name: String, fallback: Int = -1, remove: Boolean
*/
fun JSONObject.optPositiveDouble(name: String, fallback: Double = -1.0, remove: Boolean = false): Double? {
val double = optDouble(name, fallback)
- val value = if (double > 0) double else null
+ val value = if (double >= 0) double else null
if (remove) {
this.remove(name)
}
diff --git a/r2-shared/src/main/java/org/readium/r2/shared/publication/Link.kt b/r2-shared/src/main/java/org/readium/r2/shared/publication/Link.kt
index c35ccb7f..e634300e 100644
--- a/r2-shared/src/main/java/org/readium/r2/shared/publication/Link.kt
+++ b/r2-shared/src/main/java/org/readium/r2/shared/publication/Link.kt
@@ -153,3 +153,10 @@ data class Link(
get() = rels.toList()
}
+
+/**
+ * Returns the first [Link] with the given [href], or [null] if not found.
+ */
+fun List.indexOfFirstWithHref(href: String): Int? =
+ indexOfFirst { it.href == href }
+ .takeUnless { it == -1 }
diff --git a/r2-shared/src/main/java/org/readium/r2/shared/publication/Locator.kt b/r2-shared/src/main/java/org/readium/r2/shared/publication/Locator.kt
new file mode 100644
index 00000000..57bfcdeb
--- /dev/null
+++ b/r2-shared/src/main/java/org/readium/r2/shared/publication/Locator.kt
@@ -0,0 +1,211 @@
+/*
+ * Module: r2-shared-kotlin
+ * Developers: Mickaël Menu
+ *
+ * Copyright (c) 2020. Readium Foundation. All rights reserved.
+ * Use of this source code is governed by a BSD-style license which is detailed in the
+ * LICENSE file present in the project repository where this source code is maintained.
+ */
+
+package org.readium.r2.shared.publication
+
+import android.os.Parcelable
+import kotlinx.android.parcel.Parcelize
+import kotlinx.android.parcel.WriteWith
+import org.json.JSONObject
+import org.readium.r2.shared.JSONable
+import org.readium.r2.shared.extensions.*
+import org.readium.r2.shared.extensions.putIfNotEmpty
+import org.readium.r2.shared.util.logging.JsonWarning
+import org.readium.r2.shared.util.logging.WarningLogger
+import org.readium.r2.shared.util.logging.log
+
+/**
+ * Provides a precise location in a publication in a format that can be stored and shared.
+ *
+ * There are many different use cases for locators:
+ * - getting back to the last position in a publication
+ * - bookmarks
+ * - highlights & annotations
+ * - search results
+ * - human-readable (and shareable) reference in a publication
+ *
+ * https://github.com/readium/architecture/tree/master/locators
+ */
+@Parcelize
+data class Locator(
+ val href: String,
+ val type: String,
+ val title: String? = null,
+ val locations: Locations = Locations(),
+ val text: Text = Text()
+) : JSONable, Parcelable {
+
+ /**
+ * One or more alternative expressions of the location.
+ * https://github.com/readium/architecture/tree/master/models/locators#the-location-object
+ *
+ * @param fragments Contains one or more fragment in the resource referenced by the [Locator].
+ * @param progression Progression in the resource expressed as a percentage (between 0 and 1).
+ * @param position An index in the publication (>= 1).
+ * @param totalProgression Progression in the publication expressed as a percentage (between 0
+ * and 1).
+ * @param otherLocations Additional locations for extensions.
+ */
+ @Parcelize
+ data class Locations(
+ val fragments: List = emptyList(),
+ val progression: Double? = null,
+ val position: Int? = null,
+ val totalProgression: Double? = null,
+ val otherLocations: @WriteWith Map = emptyMap()
+ ) : JSONable, Parcelable {
+
+ override fun toJSON() = JSONObject(otherLocations).apply {
+ putIfNotEmpty("fragments", fragments)
+ put("progression", progression)
+ put("position", position)
+ put("totalProgression", totalProgression)
+ }
+
+ /**
+ * Syntactic sugar to access the [otherLocations] values by subscripting [Locations] directly.
+ * `locations["cssSelector"] == locations.otherLocations["cssSelector"]`
+ */
+ operator fun get(key: String): Any? = otherLocations[key]
+
+ companion object {
+
+ fun fromJSON(json: JSONObject?): Locations {
+ val fragments = json?.optStringsFromArrayOrSingle("fragments", remove = true)?.takeIf { it.isNotEmpty() }
+ ?: json?.optStringsFromArrayOrSingle("fragment", remove = true)
+ ?: emptyList()
+
+ val progression = json?.optNullableDouble("progression", remove = true)
+ ?.takeIf { it in 0.0..1.0 }
+
+ val position = json?.optNullableInt("position", remove = true)
+ ?.takeIf { it > 0 }
+
+ val totalProgression = json?.optNullableDouble("totalProgression", remove = true)
+ ?.takeIf { it in 0.0..1.0 }
+
+ return Locations(
+ fragments = fragments,
+ progression = progression,
+ position = position,
+ totalProgression = totalProgression,
+ otherLocations = json?.toMap() ?: emptyMap()
+ )
+ }
+
+ }
+
+ @Deprecated("Renamed to [fragments]", ReplaceWith("fragments"))
+ val fragment: String? get() = fragments.firstOrNull()
+
+ }
+
+ /**
+ * Textual context of the locator.
+ *
+ * A Locator Text Object contains multiple text fragments, useful to give a context to the
+ * [Locator] or for highlights.
+ * https://github.com/readium/architecture/tree/master/models/locators#the-text-object
+ *
+ * @param before The text before the locator.
+ * @param highlight The text at the locator.
+ * @param after The text after the locator.
+ */
+ @Parcelize
+ data class Text(
+ val before: String? = null,
+ val highlight: String? = null,
+ val after: String? = null
+ ) : JSONable, Parcelable {
+
+ override fun toJSON() = JSONObject().apply {
+ put("before", before)
+ put("highlight", highlight)
+ put("after", after)
+ }
+
+ companion object {
+
+ fun fromJSON(json: JSONObject?) = Text(
+ before = json?.optNullableString("before"),
+ highlight = json?.optNullableString("highlight"),
+ after = json?.optNullableString("after")
+ )
+
+ }
+
+ }
+
+ /**
+ * Shortcut to get a copy of the [Locator] with different [Locations] sub-properties.
+ */
+ fun copyWithLocations(
+ fragments: List = locations.fragments,
+ progression: Double? = locations.progression,
+ position: Int? = locations.position,
+ totalProgression: Double? = locations.totalProgression,
+ otherLocations: Map = locations.otherLocations
+ ) = copy(
+ locations = locations.copy(
+ fragments = fragments,
+ progression = progression,
+ position = position,
+ totalProgression = totalProgression,
+ otherLocations = otherLocations
+ )
+ )
+
+ override fun toJSON() = JSONObject().apply {
+ put("href", href)
+ put("type", type)
+ put("title", title)
+ putIfNotEmpty("locations", locations)
+ putIfNotEmpty("text", text)
+ }
+
+ companion object {
+
+ fun fromJSON(json: JSONObject?): Locator? =
+ fromJSON(json, null)
+
+ internal fun fromJSON(json: JSONObject?, warnings: WarningLogger?): Locator? {
+ val href = json?.optNullableString("href")
+ val type = json?.optNullableString("type")
+ if (href == null || type == null) {
+ warnings?.log(Locator::class.java, "[href] and [type] are required", json)
+ return null
+ }
+
+ return Locator(
+ href = href,
+ type = type,
+ title = json.optNullableString("title"),
+ locations = Locations.fromJSON(json.optJSONObject("locations")),
+ text = Text.fromJSON(json.optJSONObject("text"))
+ )
+ }
+
+ }
+
+}
+
+/**
+ * Creates a [Locator] from a reading order [Link].
+ */
+fun Link.toLocator(): Locator {
+ val components = href.split("#", limit = 2)
+ return Locator(
+ href = components.firstOrNull() ?: href,
+ type = type ?: "",
+ title = title,
+ locations = Locator.Locations(
+ fragments = listOfNotNull(components.getOrNull(1))
+ )
+ )
+}
diff --git a/r2-shared/src/main/java/org/readium/r2/shared/publication/Publication.kt b/r2-shared/src/main/java/org/readium/r2/shared/publication/Publication.kt
index 6fa42684..b95af519 100644
--- a/r2-shared/src/main/java/org/readium/r2/shared/publication/Publication.kt
+++ b/r2-shared/src/main/java/org/readium/r2/shared/publication/Publication.kt
@@ -10,8 +10,11 @@
package org.readium.r2.shared.publication
import android.net.Uri
+import android.os.Parcel
import android.os.Parcelable
+import kotlinx.android.parcel.IgnoredOnParcel
import kotlinx.android.parcel.Parcelize
+import kotlinx.android.parcel.WriteWith
import org.json.JSONArray
import org.json.JSONObject
import org.readium.r2.shared.JSONable
@@ -25,6 +28,8 @@ import org.readium.r2.shared.publication.epub.listOfVideoClips
import org.readium.r2.shared.toJSON
import org.readium.r2.shared.util.logging.JsonWarning
import org.readium.r2.shared.util.logging.log
+import timber.log.Timber
+import java.io.Serializable
import java.net.URL
/**
@@ -32,6 +37,7 @@ import java.net.URL
*
* @param type The kind of publication it is ( Epub, Cbz, ... )
* @param version The version of the publication, if the type needs any.
+ * @param positionsFactory Factory used to build lazily the [positions].
*/
@Parcelize
data class Publication(
@@ -44,6 +50,7 @@ data class Publication(
val resources: List = emptyList(),
val tableOfContents: List = emptyList(),
val otherCollections: List = emptyList(),
+ val positionsFactory: @WriteWith PositionListFactory? = null,
// FIXME: To be refactored, with the TYPE and EXTENSION enums as well
var type: TYPE = TYPE.EPUB,
@@ -58,6 +65,57 @@ data class Publication(
) : JSONable, Parcelable {
+ /**
+ * Creates a [Publication]'s [positions].
+ *
+ * The parsers provide an implementation of this interface for each format, but a host app
+ * might want to use a custom factory to implement, for example, a caching mechanism or use a
+ * different calculation method.
+ */
+ interface PositionListFactory {
+ fun create(): List
+
+ /**
+ * Implementation of a [Parceler] to be used with [@Parcelize] to serialize a
+ * [PositionListFactory].
+ *
+ * Since we can't serialize a factory, we're loading eagerly the [positions] to be
+ * serialized. Upon deserialization, the positions will be wrapped in a static factory.
+ *
+ * This won't be needed anymore once we use [Fragment] instead of [Activity] in the
+ * navigator.
+ */
+ object Parceler : kotlinx.android.parcel.Parceler {
+
+ private class StaticPositionListFactory(private val positions: List): PositionListFactory {
+ override fun create(): List = positions
+ }
+
+ override fun create(parcel: Parcel): PositionListFactory? =
+ try {
+ mutableListOf()
+ .apply {
+ @Suppress("UNCHECKED_CAST")
+ parcel.readList(this as MutableList, Locator::class.java.classLoader)
+ }
+ .let { StaticPositionListFactory(it) }
+
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to read a PositionListFactory from a Parcel")
+ null
+ }
+
+ override fun PositionListFactory?.write(parcel: Parcel, flags: Int) {
+ try {
+ parcel.writeList(this?.create())
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to write a PositionListFactory into a Parcel")
+ }
+ }
+
+ }
+ }
+
@Parcelize
enum class TYPE : Parcelable {
EPUB, CBZ, FXL, WEBPUB, AUDIO, DiViNa
@@ -79,6 +137,22 @@ data class Publication(
}
}
+ /**
+ * List of all the positions in the publication.
+ */
+ @IgnoredOnParcel
+ val positions: List by lazy {
+ positionsFactory?.create() ?: emptyList()
+ }
+
+ /**
+ * List of all the positions in each resource, indexed by their [href].
+ */
+ @IgnoredOnParcel
+ val positionsByResource: Map> by lazy {
+ positions.groupBy { it.href }
+ }
+
/**
* Returns the RWPM JSON representation for this manifest, as a string.
*/
@@ -195,6 +269,15 @@ data class Publication(
internal fun linksWithRole(role: String): List =
otherCollections.firstWithRole(role)?.links ?: emptyList()
+ /**
+ * Copy the [Publication] with a different [PositionListFactory].
+ * The provided closure will be used to build the [PositionListFactory], with [this] being the
+ * [Publication].
+ */
+ fun copyWithPositionsFactory(createFactory: Publication.() -> PositionListFactory): Publication {
+ return run { copy(positionsFactory = createFactory()) }
+ }
+
companion object {
fun fromJSON(json: JSONObject?, normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity): Publication? =
@@ -251,9 +334,11 @@ data class Publication(
}
+ @IgnoredOnParcel
@Deprecated("Renamed to [listOfAudioClips]", ReplaceWith("listOfAudioClips"))
val listOfAudioFiles: List = listOfAudioClips
+ @IgnoredOnParcel
@Deprecated("Renamed to [listOfVideoClips]", ReplaceWith("listOfVideoClips"))
val listOfVideos: List = listOfVideoClips
diff --git a/r2-shared/src/main/java/org/readium/r2/shared/publication/html/DomRange.kt b/r2-shared/src/main/java/org/readium/r2/shared/publication/html/DomRange.kt
new file mode 100644
index 00000000..24482756
--- /dev/null
+++ b/r2-shared/src/main/java/org/readium/r2/shared/publication/html/DomRange.kt
@@ -0,0 +1,128 @@
+/*
+ * Module: r2-shared-kotlin
+ * Developers: Mickaël Menu
+ *
+ * Copyright (c) 2020. Readium Foundation. All rights reserved.
+ * Use of this source code is governed by a BSD-style license which is detailed in the
+ * LICENSE file present in the project repository where this source code is maintained.
+ */
+
+package org.readium.r2.shared.publication.html
+
+import android.os.Parcelable
+import kotlinx.android.parcel.Parcelize
+import org.json.JSONObject
+import org.readium.r2.shared.JSONable
+import org.readium.r2.shared.extensions.optNullableString
+import org.readium.r2.shared.extensions.optPositiveInt
+import org.readium.r2.shared.extensions.putIfNotEmpty
+import org.readium.r2.shared.util.logging.JsonWarning
+import org.readium.r2.shared.util.logging.WarningLogger
+import org.readium.r2.shared.util.logging.log
+
+/**
+ * This construct enables a serializable representation of a DOM Range.
+ *
+ * In a DOM Range object, the startContainer + startOffset tuple represents the [start] boundary
+ * point. Similarly, the the endContainer + endOffset tuple represents the [end] boundary point.
+ * In both cases, the start/endContainer property is a pointer to either a DOM text node, or a DOM
+ * element (this typically depends on the mechanism from which the DOM Range instance originates,
+ * for example when obtaining the currently-selected document fragment using the `window.selection`
+ * API). In the case of a DOM text node, the start/endOffset corresponds to a position within the
+ * character data. In the case of a DOM element node, the start/endOffset corresponds to a position
+ * that designates a child text node.
+ *
+ * Note that [end] field is optional. When only the start field is specified, the domRange object
+ * represents a "collapsed" range that has identical [start] and [end] boundary points.
+ *
+ * https://github.com/readium/architecture/blob/master/models/locators/extensions/html.md#the-domrange-object
+ *
+ * @param start A serializable representation of the "start" boundary point of the DOM Range.
+ * @param end A serializable representation of the "end" boundary point of the DOM Range.
+ */
+@Parcelize
+data class DomRange(
+ val start: Point,
+ val end: Point? = null
+) : JSONable, Parcelable {
+
+ /**
+ * A serializable representation of a boundary point in a DOM Range.
+ *
+ * The [cssSelector] field always references a DOM element. If the original DOM Range
+ * start/endContainer property references a DOM text node, the [textNodeIndex] field is used to
+ * complement the CSS Selector; thereby providing a pointer to a child DOM text node; and
+ * [charOffset] is used to tell a position within the character data of that DOM text node
+ * (just as the DOM Range start/endOffset does). If the original DOM Range start/endContainer
+ * property references a DOM Element, then the [textNodeIndex] field is used to designate the
+ * child Text node (just as the DOM Range start/endOffset does), and the optional [charOffset]
+ * field is not used (as there is no explicit position within the character data of the text
+ * node).
+ *
+ * https://github.com/readium/architecture/blob/master/models/locators/extensions/html.md#the-start-and-end-object
+ */
+ @Parcelize
+ data class Point(
+ val cssSelector: String,
+ val textNodeIndex: Int,
+ val charOffset: Int? = null
+ ) : JSONable, Parcelable {
+
+ override fun toJSON() = JSONObject().apply {
+ put("cssSelector", cssSelector)
+ put("textNodeIndex", textNodeIndex)
+ put("charOffset", charOffset)
+ }
+
+ companion object {
+
+ fun fromJSON(json: JSONObject?): Point? =
+ fromJSON(json, null)
+
+ internal fun fromJSON(json: JSONObject?, warnings: WarningLogger?): Point? {
+ val cssSelector = json?.optNullableString("cssSelector")
+ val textNodeIndex = json?.optPositiveInt("textNodeIndex")
+ if (cssSelector == null || textNodeIndex == null) {
+ warnings?.log(Point::class.java, "[cssSelector] and [textNodeIndex] are required", json)
+ return null
+ }
+
+ return Point(
+ cssSelector = cssSelector,
+ textNodeIndex = textNodeIndex,
+ charOffset = json.optPositiveInt("charOffset")
+ )
+ }
+
+ }
+
+ @Deprecated("Renamed into [charOffset]", ReplaceWith("charOffset"))
+ val offset: Long? get() = charOffset?.toLong()
+ }
+
+ override fun toJSON() = JSONObject().apply {
+ putIfNotEmpty("start", start)
+ putIfNotEmpty("end", end)
+ }
+
+ companion object {
+
+ fun fromJSON(json: JSONObject?): DomRange? =
+ fromJSON(json, null)
+
+ internal fun fromJSON(json: JSONObject?, warnings: WarningLogger?): DomRange? {
+ val start = Point.fromJSON(json?.optJSONObject("start"))
+ if (start == null) {
+ warnings?.log(DomRange::class.java, "[start] is required", json)
+ return null
+ }
+
+ return DomRange(
+ start = start,
+ end = Point.fromJSON(json?.optJSONObject("end"))
+ )
+ }
+
+ }
+
+}
diff --git a/r2-shared/src/main/java/org/readium/r2/shared/publication/html/Locator.kt b/r2-shared/src/main/java/org/readium/r2/shared/publication/html/Locator.kt
new file mode 100644
index 00000000..9d0d517c
--- /dev/null
+++ b/r2-shared/src/main/java/org/readium/r2/shared/publication/html/Locator.kt
@@ -0,0 +1,39 @@
+/*
+ * Module: r2-shared-kotlin
+ * Developers: Mickaël Menu
+ *
+ * Copyright (c) 2020. Readium Foundation. All rights reserved.
+ * Use of this source code is governed by a BSD-style license which is detailed in the
+ * LICENSE file present in the project repository where this source code is maintained.
+ */
+
+package org.readium.r2.shared.publication.html
+
+import org.json.JSONObject
+import org.readium.r2.shared.publication.Locator
+
+// HTML extensions for [Locator.Locations].
+// https://github.com/readium/architecture/blob/master/models/locators/extensions/html.md
+
+/**
+ * A CSS Selector.
+ */
+val Locator.Locations.cssSelector: String?
+ get() = this["cssSelector"] as? String
+
+/**
+ * [partialCfi] is an expression conforming to the "right-hand" side of the EPUB CFI syntax, that is
+ * to say: without the EPUB-specific OPF spine item reference that precedes the first ! exclamation
+ * mark (which denotes the "step indirection" into a publication document). Note that the wrapping
+ * epubcfi(***) syntax is not used for the [partialCfi] string, i.e. the "fragment" part of the CFI
+ * grammar is ignored.
+ */
+val Locator.Locations.partialCfi: String?
+ get() = this["partialCfi"] as? String
+
+/**
+ * An HTML DOM range.
+ */
+val Locator.Locations.domRange: DomRange?
+ get() = (this["domRange"] as? Map<*, *>)
+ ?.let { DomRange.fromJSON(JSONObject(it)) }
diff --git a/r2-shared/src/test/java/org/readium/r2/shared/publication/LinkTest.kt b/r2-shared/src/test/java/org/readium/r2/shared/publication/LinkTest.kt
index cd6700de..65664217 100644
--- a/r2-shared/src/test/java/org/readium/r2/shared/publication/LinkTest.kt
+++ b/r2-shared/src/test/java/org/readium/r2/shared/publication/LinkTest.kt
@@ -215,4 +215,18 @@ class LinkTest {
)
}
+ @Test
+ fun `Find the first index of the {Link} with the given {href} in a list of {Link}`() {
+ assertNull(listOf(Link(href = "href")).indexOfFirstWithHref("foobar"))
+
+ assertEquals(
+ 1,
+ listOf(
+ Link(href = "href1"),
+ Link(href = "href2"),
+ Link(href = "href2") // duplicated on purpose
+ ).indexOfFirstWithHref("href2")
+ )
+ }
+
}
diff --git a/r2-shared/src/test/java/org/readium/r2/shared/publication/LocatorTest.kt b/r2-shared/src/test/java/org/readium/r2/shared/publication/LocatorTest.kt
new file mode 100644
index 00000000..da2a6990
--- /dev/null
+++ b/r2-shared/src/test/java/org/readium/r2/shared/publication/LocatorTest.kt
@@ -0,0 +1,297 @@
+/*
+ * Module: r2-shared-kotlin
+ * Developers: Mickaël Menu
+ *
+ * Copyright (c) 2020. Readium Foundation. All rights reserved.
+ * Use of this source code is governed by a BSD-style license which is detailed in the
+ * LICENSE file present in the project repository where this source code is maintained.
+ */
+
+package org.readium.r2.shared.publication
+
+import org.json.JSONObject
+import org.junit.Assert.*
+import org.junit.Test
+import org.readium.r2.shared.assertJSONEquals
+
+class LocatorTest {
+
+ @Test fun `parse {Locator} minimal JSON`() {
+ assertEquals(
+ Locator(href = "http://locator", type = "text/html"),
+ Locator.fromJSON(JSONObject("""{
+ "href": "http://locator",
+ "type": "text/html"
+ }"""))
+ )
+ }
+
+ @Test fun `parse {Locator} full JSON`() {
+ assertEquals(
+ Locator(
+ href = "http://locator",
+ type = "text/html",
+ title = "My Locator",
+ locations = Locator.Locations(position = 42),
+ text = Locator.Text(highlight = "Excerpt")
+ ),
+ Locator.fromJSON(JSONObject("""{
+ "href": "http://locator",
+ "type": "text/html",
+ "title": "My Locator",
+ "locations": {
+ "position": 42
+ },
+ "text": {
+ "highlight": "Excerpt"
+ }
+ }"""))
+ )
+ }
+
+ @Test fun `parse {Locator} null JSON`() {
+ assertEquals(null, Locator.fromJSON(null))
+ }
+
+ @Test fun `parse {Locator} invalid JSON`() {
+ assertNull(Locator.fromJSON(JSONObject("{ 'invalid': 'object' }")))
+ }
+
+ @Test fun `create {Locator} from minimal {Link}`() {
+ assertEquals(
+ Locator(href = "http://locator", type = ""),
+ Link(href = "http://locator").toLocator()
+ )
+ }
+
+ @Test fun `create {Locator} from full {Link} with fragment`() {
+ assertEquals(
+ Locator(
+ href = "http://locator",
+ type = "text/html",
+ title = "My Link",
+ locations = Locator.Locations(fragments = listOf("page=42"))
+ ),
+ Link(
+ href = "http://locator#page=42",
+ type = "text/html",
+ title = "My Link"
+ ).toLocator()
+ )
+ }
+
+ @Test fun `get {Locator} minimal JSON`() {
+ assertJSONEquals(
+ JSONObject("""{
+ "href": "http://locator",
+ "type": "text/html"
+ }"""),
+ Locator(href = "http://locator", type = "text/html").toJSON()
+ )
+ }
+
+ @Test fun `get {Locator} full JSON`() {
+ assertJSONEquals(
+ JSONObject("""{
+ "href": "http://locator",
+ "type": "text/html",
+ "title": "My Locator",
+ "locations": {
+ "position": 42
+ },
+ "text": {
+ "highlight": "Excerpt"
+ }
+ }"""),
+ Locator(
+ href = "http://locator",
+ type = "text/html",
+ title = "My Locator",
+ locations = Locator.Locations(position = 42),
+ text = Locator.Text(highlight = "Excerpt")
+ ).toJSON()
+ )
+ }
+
+ @Test fun `copy a {Locator} with different {Locations} sub-properties`() {
+ assertEquals(
+ Locator(
+ href = "http://locator",
+ type = "text/html",
+ locations = Locator.Locations(
+ fragments = listOf("p=4", "frag34"),
+ progression = 0.74,
+ position = 42,
+ totalProgression = 0.32,
+ otherLocations = mapOf("other" to "other-location")
+ )
+ ),
+ Locator(
+ href = "http://locator",
+ type = "text/html",
+ locations = Locator.Locations(position = 42, progression = 2.0)
+ ).copyWithLocations(
+ fragments = listOf("p=4", "frag34"),
+ progression = 0.74,
+ position = 42,
+ totalProgression = 0.32,
+ otherLocations = mapOf("other" to "other-location")
+ )
+ )
+ }
+
+ @Test fun `copy a {Locator} with reset {Locations} sub-properties`() {
+ assertEquals(
+ Locator(
+ href = "http://locator",
+ type = "text/html",
+ locations = Locator.Locations()
+ ),
+ Locator(
+ href = "http://locator",
+ type = "text/html",
+ locations = Locator.Locations(position = 42, progression = 2.0)
+ ).copyWithLocations(
+ fragments = emptyList(),
+ progression = null,
+ position = null,
+ totalProgression = null,
+ otherLocations = emptyMap()
+ )
+ )
+ }
+
+ @Test fun `parse {Locations} minimal JSON`() {
+ assertEquals(
+ Locator.Locations(),
+ Locator.Locations.fromJSON(JSONObject("{}"))
+ )
+ }
+
+ @Test fun `parse {Locations} full JSON`() {
+ assertEquals(
+ Locator.Locations(
+ fragments = listOf("p=4", "frag34"),
+ progression = 0.74,
+ position = 42,
+ totalProgression = 0.32,
+ otherLocations = mapOf("other" to "other-location")
+ ),
+ Locator.Locations.fromJSON(JSONObject("""{
+ "fragments": ["p=4", "frag34"],
+ "progression": 0.74,
+ "totalProgression": 0.32,
+ "position": 42,
+ "other": "other-location"
+ }"""))
+ )
+ }
+
+ @Test fun `parse {Locations} null JSON`() {
+ assertEquals(Locator.Locations(), Locator.Locations.fromJSON(null))
+ }
+
+ @Test fun `parse {Locations} single fragment JSON`() {
+ assertEquals(
+ Locator.Locations(fragments = listOf("frag34")),
+ Locator.Locations.fromJSON(JSONObject("{ 'fragment': 'frag34' }"))
+ )
+ }
+
+ @Test fun `parse {Locations} ignores {position} smaller than 1`() {
+ assertEquals(Locator.Locations(position = 1), Locator.Locations.fromJSON(JSONObject("{ 'position': 1 }")))
+ assertEquals(Locator.Locations(), Locator.Locations.fromJSON(JSONObject("{ 'position': 0 }")))
+ assertEquals(Locator.Locations(), Locator.Locations.fromJSON(JSONObject("{ 'position': -1 }")))
+ }
+
+ @Test fun `parse {Locations} ignores {progression} outside of 0-1 range`() {
+ assertEquals(Locator.Locations(progression = 0.5), Locator.Locations.fromJSON(JSONObject("{ 'progression': 0.5 }")))
+ assertEquals(Locator.Locations(progression = 0.0), Locator.Locations.fromJSON(JSONObject("{ 'progression': 0 }")))
+ assertEquals(Locator.Locations(progression = 1.0), Locator.Locations.fromJSON(JSONObject("{ 'progression': 1 }")))
+ assertEquals(Locator.Locations(), Locator.Locations.fromJSON(JSONObject("{ 'progression': -0.5 }")))
+ assertEquals(Locator.Locations(), Locator.Locations.fromJSON(JSONObject("{ 'progression': 1.2 }")))
+ }
+
+ @Test fun `parse {Locations} ignores {totalProgression} outside of 0-1 range`() {
+ assertEquals(Locator.Locations(totalProgression = 0.5), Locator.Locations.fromJSON(JSONObject("{ 'totalProgression': 0.5 }")))
+ assertEquals(Locator.Locations(totalProgression = 0.0), Locator.Locations.fromJSON(JSONObject("{ 'totalProgression': 0 }")))
+ assertEquals(Locator.Locations(totalProgression = 1.0), Locator.Locations.fromJSON(JSONObject("{ 'totalProgression': 1 }")))
+ assertEquals(Locator.Locations(), Locator.Locations.fromJSON(JSONObject("{ 'totalProgression': -0.5 }")))
+ assertEquals(Locator.Locations(), Locator.Locations.fromJSON(JSONObject("{ 'totalProgression': 1.2 }")))
+ }
+
+ @Test fun `get {Locations} minimal JSON`() {
+ assertJSONEquals(
+ JSONObject("{}"),
+ Locator.Locations().toJSON()
+ )
+ }
+
+ @Test fun `get {Locations} full JSON`() {
+ assertJSONEquals(
+ JSONObject("""{
+ "fragments": ["p=4", "frag34"],
+ "progression": 0.74,
+ "totalProgression": 25.32,
+ "position": 42,
+ "other": "other-location"
+ }"""),
+ Locator.Locations(
+ fragments = listOf("p=4", "frag34"),
+ progression = 0.74,
+ position = 42,
+ totalProgression = 25.32,
+ otherLocations = mapOf("other" to "other-location")
+ ).toJSON()
+ )
+ }
+
+ @Test fun `parse {Text} minimal JSON`() {
+ assertEquals(
+ Locator.Text(),
+ Locator.Text.fromJSON(JSONObject("{}"))
+ )
+ }
+
+ @Test fun `parse {Text} full JSON`() {
+ assertEquals(
+ Locator.Text(
+ before = "Text before",
+ highlight = "Highlighted text",
+ after = "Text after"
+ ),
+ Locator.Text.fromJSON(JSONObject("""{
+ "before": "Text before",
+ "highlight": "Highlighted text",
+ "after": "Text after"
+ }"""))
+ )
+ }
+
+ @Test fun `parse {Text} null JSON`() {
+ assertEquals(Locator.Text(), Locator.Text.fromJSON(null))
+ }
+
+ @Test fun `get {Text} minimal JSON`() {
+ assertJSONEquals(
+ JSONObject("{}"),
+ Locator.Text().toJSON()
+ )
+ }
+
+ @Test fun `get {Text} full JSON`() {
+ assertJSONEquals(
+ JSONObject("""{
+ "before": "Text before",
+ "highlight": "Highlighted text",
+ "after": "Text after"
+ }"""),
+ Locator.Text(
+ before = "Text before",
+ highlight = "Highlighted text",
+ after = "Text after"
+ ).toJSON()
+ )
+ }
+
+}
diff --git a/r2-shared/src/test/java/org/readium/r2/shared/publication/PublicationTest.kt b/r2-shared/src/test/java/org/readium/r2/shared/publication/PublicationTest.kt
index cb0e849b..255e91c7 100644
--- a/r2-shared/src/test/java/org/readium/r2/shared/publication/PublicationTest.kt
+++ b/r2-shared/src/test/java/org/readium/r2/shared/publication/PublicationTest.kt
@@ -13,6 +13,7 @@ import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import org.readium.r2.shared.assertJSONEquals
+import java.io.Serializable
import java.net.URL
class PublicationTest {
@@ -23,7 +24,8 @@ class PublicationTest {
readingProgression: ReadingProgression = ReadingProgression.AUTO,
links: List = listOf(),
readingOrder: List = emptyList(),
- resources: List = emptyList()
+ resources: List = emptyList(),
+ positionsFactory: Publication.PositionListFactory? = null
) = Publication(
metadata = Metadata(
localizedTitle = LocalizedString(title),
@@ -32,7 +34,8 @@ class PublicationTest {
),
links = links,
readingOrder = readingOrder,
- resources = resources
+ resources = resources,
+ positionsFactory = positionsFactory
)
@Test fun `parse minimal JSON`() {
@@ -258,6 +261,45 @@ class PublicationTest {
)
}
+ @Test fun `get the default empty {positions}`() {
+ assertEquals(emptyList(), createPublication().positions)
+ }
+
+ @Test fun `get the {positions} computed from the {positionsFactory}`() {
+ assertEquals(
+ listOf(Locator(href = "locator", type = "")),
+ createPublication(
+ positionsFactory = object : Publication.PositionListFactory {
+ override fun create(): List =
+ listOf(Locator(href = "locator", type = ""))
+ }
+ ).positions
+ )
+ }
+
+ @Test fun `get the {positionsByResource} computed from the {positionsFactory}`() {
+ assertEquals(
+ mapOf(
+ "res1" to listOf(
+ Locator(href="res1", type = "text/html", title = "Loc A"),
+ Locator(href="res1", type = "text/html", title = "Loc B")
+ ),
+ "res2" to listOf(
+ Locator(href="res2", type = "text/html", title = "Loc B")
+ )
+ ),
+ createPublication(
+ positionsFactory = object : Publication.PositionListFactory {
+ override fun create(): List = listOf(
+ Locator(href="res1", type = "text/html", title = "Loc A"),
+ Locator(href="res2", type = "text/html", title = "Loc B"),
+ Locator(href="res1", type = "text/html", title = "Loc B")
+ )
+ }
+ ).positionsByResource
+ )
+ }
+
@Test fun `get {contentLayout} for the default language`() {
assertEquals(
ContentLayout.RTL,
diff --git a/r2-shared/src/test/java/org/readium/r2/shared/publication/html/DomRangeTest.kt b/r2-shared/src/test/java/org/readium/r2/shared/publication/html/DomRangeTest.kt
new file mode 100644
index 00000000..07d5bb36
--- /dev/null
+++ b/r2-shared/src/test/java/org/readium/r2/shared/publication/html/DomRangeTest.kt
@@ -0,0 +1,190 @@
+/*
+ * Module: r2-shared-kotlin
+ * Developers: Mickaël Menu
+ *
+ * Copyright (c) 2020. Readium Foundation. All rights reserved.
+ * Use of this source code is governed by a BSD-style license which is detailed in the
+ * LICENSE file present in the project repository where this source code is maintained.
+ */
+
+package org.readium.r2.shared.publication.html
+
+import org.json.JSONObject
+import org.junit.Test
+import org.readium.r2.shared.assertJSONEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class DomRangeTest {
+
+ @Test fun `parse {DomRange} minimal JSON`() {
+ assertEquals(
+ DomRange(start = DomRange.Point(cssSelector = "p", textNodeIndex = 4)),
+ DomRange.fromJSON(JSONObject("""{
+ "start": {
+ "cssSelector": "p",
+ "textNodeIndex": 4
+ }
+ }"""))
+ )
+ }
+
+ @Test fun `parse {DomRange} full JSON`() {
+ assertEquals(
+ DomRange(
+ start = DomRange.Point(cssSelector = "p", textNodeIndex = 4),
+ end = DomRange.Point(cssSelector = "a", textNodeIndex = 2)
+ ),
+ DomRange.fromJSON(JSONObject("""{
+ "start": {
+ "cssSelector": "p",
+ "textNodeIndex": 4
+ },
+ "end": {
+ "cssSelector": "a",
+ "textNodeIndex": 2
+ }
+ }"""))
+ )
+ }
+
+ @Test fun `parse {DomRange} invalid JSON`() {
+ assertNull(DomRange.fromJSON(JSONObject("{ 'invalid': 'object' }")))
+ }
+
+ @Test fun `parse {DomRange} null JSON`() {
+ assertNull(DomRange.fromJSON(null))
+ }
+
+ @Test fun `get {DomRange} minimal JSON`() {
+ assertJSONEquals(
+ JSONObject("""{
+ "start": {
+ "cssSelector": "p",
+ "textNodeIndex": 4
+ }
+ }"""),
+ DomRange(start = DomRange.Point(cssSelector = "p", textNodeIndex = 4)).toJSON()
+ )
+ }
+
+ @Test fun `get {DomRange} full JSON`() {
+ assertJSONEquals(
+ JSONObject("""{
+ "start": {
+ "cssSelector": "p",
+ "textNodeIndex": 4
+ },
+ "end": {
+ "cssSelector": "a",
+ "textNodeIndex": 2
+ }
+ }"""),
+ DomRange(
+ start = DomRange.Point(cssSelector = "p", textNodeIndex = 4),
+ end = DomRange.Point(cssSelector = "a", textNodeIndex = 2)
+ ).toJSON()
+ )
+ }
+
+ @Test fun `parse {Point} minimal JSON`() {
+ assertEquals(
+ DomRange.Point(cssSelector = "p", textNodeIndex = 4),
+ DomRange.Point.fromJSON(JSONObject("""{
+ "cssSelector": "p",
+ "textNodeIndex": 4
+ }"""))
+ )
+ }
+
+ @Test fun `parse {Point} full JSON`() {
+ assertEquals(
+ DomRange.Point(cssSelector = "p", textNodeIndex = 4, charOffset = 32),
+ DomRange.Point.fromJSON(JSONObject("""{
+ "cssSelector": "p",
+ "textNodeIndex": 4,
+ "charOffset": 32
+ }"""))
+ )
+ }
+
+ @Test fun `parse {Point} invalid JSON`() {
+ assertNull(DomRange.Point.fromJSON(JSONObject("""{
+ "cssSelector": "p"
+ }""")))
+ }
+
+ @Test fun `parse {Point} null JSON`() {
+ assertNull(DomRange.Point.fromJSON(null))
+ }
+
+ @Test fun `parse {Point} requires positive {textNodeIndex}`() {
+ assertEquals(
+ DomRange.Point(cssSelector = "p", textNodeIndex = 1),
+ DomRange.Point.fromJSON(JSONObject("""{
+ "cssSelector": "p",
+ "textNodeIndex": 1
+ }"""))
+ )
+ assertEquals(
+ DomRange.Point(cssSelector = "p", textNodeIndex = 0),
+ DomRange.Point.fromJSON(JSONObject("""{
+ "cssSelector": "p",
+ "textNodeIndex": 0
+ }"""))
+ )
+ assertNull(DomRange.fromJSON(JSONObject("""{
+ "cssSelector": "p",
+ "textNodeIndex": -1
+ }""")))
+ }
+
+ @Test fun `parse {Point} requires positive {charOffset}`() {
+ assertEquals(
+ DomRange.Point(cssSelector = "p", textNodeIndex = 1, charOffset = 1),
+ DomRange.Point.fromJSON(JSONObject("""{
+ "cssSelector": "p",
+ "textNodeIndex": 1,
+ "charOffset": 1
+ }"""))
+ )
+ assertEquals(
+ DomRange.Point(cssSelector = "p", textNodeIndex = 1, charOffset = 0),
+ DomRange.Point.fromJSON(JSONObject("""{
+ "cssSelector": "p",
+ "textNodeIndex": 1,
+ "charOffset": 0
+ }"""))
+ )
+ assertEquals(
+ DomRange.Point(cssSelector = "p", textNodeIndex = 1),
+ DomRange.Point.fromJSON(JSONObject("""{
+ "cssSelector": "p",
+ "textNodeIndex": 1,
+ "charOffset": -1
+ }"""))
+ )
+ }
+
+ @Test fun `get {Point} minimal JSON`() {
+ assertJSONEquals(
+ JSONObject("""{
+ "cssSelector": "p",
+ "textNodeIndex": 4
+ }"""),
+ DomRange.Point(cssSelector = "p", textNodeIndex = 4).toJSON()
+ )
+ }
+
+ @Test fun `get {Point} full JSON`() {
+ assertJSONEquals(
+ JSONObject("""{
+ "cssSelector": "p",
+ "textNodeIndex": 4,
+ "charOffset": 32
+ }"""),
+ DomRange.Point(cssSelector = "p", textNodeIndex = 4, charOffset = 32).toJSON()
+ )
+ }
+
+}
diff --git a/r2-shared/src/test/java/org/readium/r2/shared/publication/html/LocatorTest.kt b/r2-shared/src/test/java/org/readium/r2/shared/publication/html/LocatorTest.kt
new file mode 100644
index 00000000..80d46f24
--- /dev/null
+++ b/r2-shared/src/test/java/org/readium/r2/shared/publication/html/LocatorTest.kt
@@ -0,0 +1,58 @@
+/*
+ * Module: r2-shared-kotlin
+ * Developers: Mickaël Menu
+ *
+ * Copyright (c) 2020. Readium Foundation. All rights reserved.
+ * Use of this source code is governed by a BSD-style license which is detailed in the
+ * LICENSE file present in the project repository where this source code is maintained.
+ */
+
+package org.readium.r2.shared.publication.html
+
+import org.junit.Assert.*
+import org.junit.Test
+import org.readium.r2.shared.publication.Locator
+
+class LocatorTest {
+
+ @Test fun `get Locations {cssSelector} when available`() {
+ assertEquals(
+ "p",
+ Locator.Locations(otherLocations = mapOf("cssSelector" to "p")).cssSelector
+ )
+ }
+
+ @Test fun `get Locations {cssSelector} when missing`() {
+ assertNull(Locator.Locations().cssSelector)
+ }
+
+ @Test fun `get Locations {partialCfi} when available`() {
+ assertEquals(
+ "epubcfi(/4)",
+ Locator.Locations(otherLocations = mapOf("partialCfi" to "epubcfi(/4)")).partialCfi
+ )
+ }
+
+ @Test fun `get Locations {partialCfi} when missing`() {
+ assertNull(Locator.Locations().partialCfi)
+ }
+
+ @Test fun `get Locations {domRange} when available`() {
+ assertEquals(
+ DomRange(start = DomRange.Point(cssSelector = "p", textNodeIndex = 4)),
+ Locator.Locations(otherLocations = mapOf(
+ "domRange" to mapOf(
+ "start" to mapOf(
+ "cssSelector" to "p",
+ "textNodeIndex" to 4
+ )
+ )
+ )).domRange
+ )
+ }
+
+ @Test fun `get Locations {domRange} when missing`() {
+ assertNull(Locator.Locations().domRange)
+ }
+
+}
\ No newline at end of file