Skip to content

Commit 7094889

Browse files
authored
Add support for TDM Reservation Protocol metadata (#634)
1 parent 0d2b5dc commit 7094889

File tree

11 files changed

+295
-1
lines changed

11 files changed

+295
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. Take a look
1010

1111
#### Shared
1212

13+
* Support for [W3C's Text & data mining Reservation Protocol](https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/) in our metadata models.
1314
* Support for [accessibility exemption metadata](https://readium.org/webpub-manifest/contexts/default/#exemption), which allows content creators to identify publications that do not meet conformance requirements but fall under exemptions in a given juridiction.
1415
* Support for [EPUB Accessibility 1.1](https://www.w3.org/TR/epub-a11y-11/) conformance profiles.
1516

readium/shared/src/main/java/org/readium/r2/shared/publication/Metadata.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import org.readium.r2.shared.util.logging.log
2929
/**
3030
* https://readium.org/webpub-manifest/schema/metadata.schema.json
3131
*
32+
* @param tdm Publications can indicate whether they allow third parties to use their content for
33+
* text and data mining purposes using the [TDM Rep protocol](https://www.w3.org/community/tdmrep/),
34+
* as defined in a [W3C Community Group Report](https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/).
3235
* @param otherMetadata Additional metadata for extensions, as a JSON dictionary.
3336
*/
3437
@Parcelize
@@ -62,6 +65,7 @@ public data class Metadata(
6265
val duration: Double? = null,
6366
val numberOfPages: Int? = null,
6467
val belongsTo: Map<String, List<Collection>> = emptyMap(),
68+
val tdm: Tdm? = null,
6569
val otherMetadata: @WriteWith<JSONParceler> Map<String, Any> = mapOf(),
6670
) : JSONable, Parcelable {
6771

@@ -97,6 +101,7 @@ public data class Metadata(
97101
belongsTo: Map<String, List<Collection>> = emptyMap(),
98102
belongsToCollections: List<Collection> = emptyList(),
99103
belongsToSeries: List<Collection> = emptyList(),
104+
tdm: Tdm? = null,
100105
otherMetadata: Map<String, Any> = mapOf(),
101106
) : this(
102107
identifier = identifier,
@@ -138,6 +143,7 @@ public data class Metadata(
138143
}
139144
}
140145
.toMap(),
146+
tdm = tdm,
141147
otherMetadata = otherMetadata
142148
)
143149

@@ -198,6 +204,7 @@ public data class Metadata(
198204
put("duration", duration)
199205
put("numberOfPages", numberOfPages)
200206
putIfNotEmpty("belongsTo", belongsTo)
207+
putIfNotEmpty("tdm", tdm)
201208
}
202209

203210
/**
@@ -298,6 +305,7 @@ public data class Metadata(
298305
val duration = json.optPositiveDouble("duration", remove = true)
299306
val numberOfPages = json.optPositiveInt("numberOfPages", remove = true)
300307

308+
val tdm = Tdm.fromJSON(json.remove("tdm"), warnings)
301309
val belongsToJson = (
302310
json.remove("belongsTo") as? JSONObject
303311
?: json.remove("belongs_to") as? JSONObject
@@ -345,6 +353,7 @@ public data class Metadata(
345353
duration = duration,
346354
numberOfPages = numberOfPages,
347355
belongsTo = belongsTo.toMap(),
356+
tdm = tdm,
348357
otherMetadata = json.toMap()
349358
)
350359
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2025 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.shared.publication
8+
9+
import android.os.Parcelable
10+
import kotlinx.parcelize.Parcelize
11+
import org.json.JSONObject
12+
import org.readium.r2.shared.InternalReadiumApi
13+
import org.readium.r2.shared.JSONable
14+
import org.readium.r2.shared.extensions.optNullableString
15+
import org.readium.r2.shared.util.AbsoluteUrl
16+
import org.readium.r2.shared.util.logging.WarningLogger
17+
import org.readium.r2.shared.util.logging.log
18+
19+
/**
20+
* Publications can indicate whether they allow third parties to use their content for text and data
21+
* mining purposes using the [TDM Rep protocol](https://www.w3.org/community/tdmrep/), as defined in
22+
* a [W3C Community Group Report](https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/).
23+
*
24+
* https://github.com/readium/webpub-manifest/blob/master/schema/metadata.schema.json
25+
*
26+
* @param policy URL pointing to a TDM Policy set be the rightsholder.
27+
*/
28+
@Parcelize
29+
public data class Tdm(
30+
val reservation: Reservation,
31+
val policy: AbsoluteUrl? = null,
32+
) : JSONable, Parcelable {
33+
34+
@Parcelize
35+
public data class Reservation(public val value: String) : Parcelable {
36+
37+
public companion object {
38+
/**
39+
* All TDM rights are reserved. If a TDM Policy is set, TDM Agents MAY use it to get
40+
* information on how they can acquire from the rightsholder an authorization to mine
41+
* the content.
42+
*/
43+
public val ALL: Reservation = Reservation("all")
44+
45+
/**
46+
* TDM rights are not reserved. TDM agents can mine the content for TDM purposes without
47+
* having to contact the rightsholder.
48+
*/
49+
public val NONE: Reservation = Reservation("none")
50+
}
51+
}
52+
53+
override fun toJSON(): JSONObject = JSONObject().apply {
54+
put("reservation", reservation.value)
55+
put("policy", policy?.toString())
56+
}
57+
58+
public companion object {
59+
60+
/**
61+
* Parses a [Tdm] from its RWPM JSON representation.
62+
*
63+
* If the TDM can't be parsed, a warning will be logged with [warnings].
64+
*/
65+
@OptIn(InternalReadiumApi::class)
66+
public fun fromJSON(
67+
json: Any?,
68+
warnings: WarningLogger? = null,
69+
): Tdm? {
70+
if (json !is JSONObject) return null
71+
val reservation = json.optNullableString("reservation")?.let { Reservation(it) }
72+
val policy = json.optNullableString("policy")?.let { AbsoluteUrl(it) }
73+
74+
if (reservation == null) {
75+
warnings?.log(Tdm::class.java, "no valid reservation in TDM object", json)
76+
return null
77+
}
78+
79+
return Tdm(
80+
reservation = reservation,
81+
policy = policy
82+
)
83+
}
84+
}
85+
}

readium/shared/src/test/java/org/readium/r2/shared/publication/MetadataTest.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import org.junit.Assert.assertNull
1212
import org.junit.Test
1313
import org.junit.runner.RunWith
1414
import org.readium.r2.shared.assertJSONEquals
15+
import org.readium.r2.shared.util.AbsoluteUrl
1516
import org.readium.r2.shared.util.Instant
1617
import org.readium.r2.shared.util.Language
1718
import org.robolectric.RobolectricTestRunner
@@ -83,6 +84,10 @@ class MetadataTest {
8384
),
8485
belongsToCollections = listOf(Contributor(name = "Collection")),
8586
belongsToSeries = listOf(Contributor(name = "Series")),
87+
tdm = Tdm(
88+
reservation = Tdm.Reservation.ALL,
89+
policy = AbsoluteUrl("http://example.com/tdm-policy")!!
90+
),
8691
otherMetadata = mapOf(
8792
"other-metadata1" to "value",
8893
"other-metadata2" to listOf(42)
@@ -134,6 +139,10 @@ class MetadataTest {
134139
"schema:Periodical": "Periodical",
135140
"schema:Newspaper": [ "Newspaper 1", "Newspaper 2" ]
136141
},
142+
"tdm": {
143+
"reservation": "all",
144+
"policy": "http://example.com/tdm-policy"
145+
},
137146
"other-metadata1": "value",
138147
"other-metadata2": [42]
139148
}"""
@@ -255,6 +264,10 @@ class MetadataTest {
255264
"series": [{"name": {"und": "Series"}}],
256265
"schema:Periodical": [{"name": {"und": "Periodical"}}]
257266
},
267+
"tdm": {
268+
"reservation": "all",
269+
"policy": "http://example.com/tdm-policy"
270+
},
258271
"other-metadata1": "value",
259272
"other-metadata2": [42]
260273
}"""
@@ -312,6 +325,10 @@ class MetadataTest {
312325
belongsTo = mapOf("schema:Periodical" to listOf(Contributor(name = "Periodical"))),
313326
belongsToCollections = listOf(Contributor(name = "Collection")),
314327
belongsToSeries = listOf(Contributor(name = "Series")),
328+
tdm = Tdm(
329+
reservation = Tdm.Reservation.ALL,
330+
policy = AbsoluteUrl("http://example.com/tdm-policy")!!
331+
),
315332
otherMetadata = mapOf(
316333
"other-metadata1" to "value",
317334
"other-metadata2" to listOf(42)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright 2025 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.shared.publication
8+
9+
import kotlin.test.assertEquals
10+
import org.json.JSONObject
11+
import org.junit.Assert
12+
import org.junit.Test
13+
import org.junit.runner.RunWith
14+
import org.readium.r2.shared.assertJSONEquals
15+
import org.readium.r2.shared.util.AbsoluteUrl
16+
import org.robolectric.RobolectricTestRunner
17+
18+
@RunWith(RobolectricTestRunner::class)
19+
class TdmTest {
20+
21+
@Test
22+
fun `invalid policy is just ignored`() {
23+
assertEquals(
24+
Tdm(
25+
reservation = Tdm.Reservation.ALL,
26+
policy = null
27+
),
28+
Tdm.fromJSON(
29+
JSONObject(
30+
"""{
31+
"reservation": "all",
32+
"policy": "not an URL"
33+
}"""
34+
)
35+
)
36+
)
37+
}
38+
39+
@Test
40+
fun `parse minimal JSON`() {
41+
assertEquals(
42+
Tdm(
43+
reservation = Tdm.Reservation.NONE,
44+
policy = null
45+
),
46+
Tdm.fromJSON(JSONObject("""{ "reservation": "none" }")"""))
47+
)
48+
}
49+
50+
@Test
51+
fun `parse null JSON`() {
52+
Assert.assertNull(Tdm.fromJSON(null))
53+
}
54+
55+
@Test
56+
fun `parse full JSON`() {
57+
assertEquals(
58+
Tdm(
59+
reservation = Tdm.Reservation.ALL,
60+
policy = AbsoluteUrl("https://policy")!!
61+
),
62+
Tdm.fromJSON(
63+
JSONObject(
64+
"""{
65+
"reservation": "all",
66+
"policy": "https://policy"
67+
}"""
68+
)
69+
)
70+
)
71+
}
72+
73+
@Test
74+
fun `get minimal JSON`() {
75+
assertJSONEquals(
76+
JSONObject("""{ "reservation": "all" }"""),
77+
Tdm(
78+
reservation = Tdm.Reservation.ALL,
79+
policy = null
80+
).toJSON(),
81+
)
82+
}
83+
84+
@Test
85+
fun `get full JSON`() {
86+
assertJSONEquals(
87+
JSONObject(
88+
"""{
89+
"reservation": "all",
90+
"policy": "https://policy"
91+
}"""
92+
),
93+
Tdm(
94+
reservation = Tdm.Reservation.ALL,
95+
policy = AbsoluteUrl("https://policy")!!
96+
).toJSON(),
97+
)
98+
}
99+
}

readium/streamer/src/main/java/org/readium/r2/streamer/parser/epub/Constants.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ internal object Vocabularies {
3535
const val ONIX = "http://www.editeur.org/ONIX/book/codelists/current.html#"
3636
const val SCHEMA = "http://schema.org/"
3737
const val XSD = "http://www.w3.org/2001/XMLSchema#"
38+
const val TDM = "http://www.w3.org/ns/tdmrep#"
3839

3940
const val MSV = "http://www.idpf.org/epub/vocab/structure/magazine/#"
4041
const val PRISM = "http://www.prismstandard.org/specifications/3.0/PRISM_CV_Spec_3.0.htm#"

readium/streamer/src/main/java/org/readium/r2/streamer/parser/epub/MetadataAdapter.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import org.readium.r2.shared.extensions.toMap
1313
import org.readium.r2.shared.publication.*
1414
import org.readium.r2.shared.publication.Collection
1515
import org.readium.r2.shared.publication.presentation.Presentation
16+
import org.readium.r2.shared.util.AbsoluteUrl
1617
import org.readium.r2.shared.util.Instant
1718

1819
internal class MetadataAdapter(
@@ -88,6 +89,9 @@ internal class MetadataAdapter(
8889
val presentation: Presentation = globalItemsHolder
8990
.adapt(PresentationAdapter(epubVersion, displayOptions)::adapt)
9091

92+
val tdm: Tdm? = globalItemsHolder
93+
.adapt(TdmAdapter()::adapt)
94+
9195
val links: List<Link> = globalItemsHolder
9296
.adapt(LinksAdapter()::adapt)
9397

@@ -113,6 +117,7 @@ internal class MetadataAdapter(
113117
readingProgression = readingProgression,
114118
belongsToCollections = belongsToCollections,
115119
belongsToSeries = belongsToSeries,
120+
tdm = tdm,
116121
otherMetadata = otherMetadata,
117122

118123
authors = contributors("aut"),
@@ -185,6 +190,35 @@ private class IdentifierAdapter(private val uniqueIdentifierId: String?) {
185190
}
186191
}
187192

193+
private class TdmAdapter {
194+
195+
fun adapt(items: List<MetadataItem>): Pair<Tdm?, List<MetadataItem>> {
196+
val itemsHolder = MetadataItemsHolder(items)
197+
198+
val reservation = itemsHolder
199+
.adapt { it.takeAllWithProperty(Vocabularies.TDM + "reservation") }
200+
.firstOrNull()
201+
?.let {
202+
when (it.value) {
203+
"1" -> Tdm.Reservation.ALL
204+
"0" -> Tdm.Reservation.NONE
205+
else -> null
206+
}
207+
}
208+
209+
val policy = itemsHolder
210+
.adapt { it.takeAllWithProperty(Vocabularies.TDM + "policy") }
211+
.firstOrNull()
212+
?.let { AbsoluteUrl(it.value) }
213+
214+
val tdm = reservation?.let {
215+
Tdm(reservation = it, policy = policy)
216+
}
217+
218+
return tdm to itemsHolder.remainingItems
219+
}
220+
}
221+
188222
private class LanguageAdapter {
189223

190224
fun adapt(items: List<MetadataItem>): Pair<List<String>, List<MetadataItem>> = items

readium/streamer/src/main/java/org/readium/r2/streamer/parser/epub/PropertyDataType.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ internal val PACKAGE_RESERVED_PREFIXES = mapOf(
1414
"marc" to Vocabularies.MARC,
1515
"onix" to Vocabularies.ONIX,
1616
"schema" to Vocabularies.SCHEMA,
17-
"xsd" to Vocabularies.XSD
17+
"xsd" to Vocabularies.XSD,
18+
"tdm" to Vocabularies.TDM,
1819
)
1920

2021
internal val CONTENT_RESERVED_PREFIXES = mapOf(

0 commit comments

Comments
 (0)