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