Skip to content

Commit fc92cb7

Browse files
authored
Add support for TDM Reservation Protocol metadata (#564)
1 parent c8917de commit fc92cb7

File tree

14 files changed

+265
-3
lines changed

14 files changed

+265
-3
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. Take a look
88

99
### Added
1010

11+
#### Shared
12+
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.
14+
1115
#### LCP
1216

1317
* Support for streaming an LCP-protected publication from its License Document (LCPL). [Take a look at the LCP guide for more information](docs/Guides/Readium%20LCP.md#streaming-an-lcp-protected-package).

Sources/Shared/Publication/Metadata.swift

+12
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable {
4949
public var numberOfPages: Int?
5050
public var belongsTo: [String: [Collection]]
5151

52+
/// Publications can indicate whether they allow third parties to use their
53+
/// content for text and data mining purposes using the [TDM Rep protocol](https://www.w3.org/community/tdmrep/),
54+
/// as defined in a [W3C Community Group Report](https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/).
55+
public var tdm: TDM?
56+
5257
public var readingProgression: ReadingProgression
5358

5459
/// Additional properties for extensions.
@@ -92,6 +97,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable {
9297
belongsTo: [String: [Collection]] = [:],
9398
belongsToCollections: [Collection] = [],
9499
belongsToSeries: [Collection] = [],
100+
tdm: TDM? = nil,
95101
otherMetadata: JSONDictionary.Wrapped = [:]
96102
) {
97103
self.identifier = identifier
@@ -133,6 +139,8 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable {
133139
}
134140
self.belongsTo = belongsTo
135141

142+
self.tdm = tdm
143+
136144
otherMetadataJSON = JSONDictionary(otherMetadata) ?? JSONDictionary()
137145
}
138146

@@ -179,6 +187,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable {
179187
belongsTo = (json.pop("belongsTo") as? JSONDictionary.Wrapped)?
180188
.compactMapValues { item in [Collection](json: item, warnings: warnings) }
181189
?? [:]
190+
tdm = try? TDM(json: json.pop("tdm"), warnings: warnings)
182191
otherMetadataJSON = json
183192
}
184193

@@ -213,6 +222,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable {
213222
"duration": encodeIfNotNil(duration),
214223
"numberOfPages": encodeIfNotNil(numberOfPages),
215224
"belongsTo": encodeIfNotEmpty(belongsTo.mapValues { $0.json }),
225+
"tdm": encodeIfNotEmpty(tdm?.json),
216226
], additional: otherMetadata)
217227
}
218228

@@ -261,6 +271,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable {
261271
belongsTo: [String: [Collection]]? = nil,
262272
belongsToCollections: [Collection]? = nil,
263273
belongsToSeries: [Collection]? = nil,
274+
tdm: TDM? = nil,
264275
otherMetadata: JSONDictionary.Wrapped? = nil
265276
) -> Metadata {
266277
Metadata(
@@ -295,6 +306,7 @@ public struct Metadata: Hashable, Loggable, WarningLogger, Sendable {
295306
belongsTo: belongsTo ?? self.belongsTo,
296307
belongsToCollections: belongsToCollections ?? self.belongsToCollections,
297308
belongsToSeries: belongsToSeries ?? self.belongsToSeries,
309+
tdm: tdm ?? self.tdm,
298310
otherMetadata: otherMetadata ?? self.otherMetadata
299311
)
300312
}

Sources/Shared/Publication/TDM.swift

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
import Foundation
8+
import ReadiumInternal
9+
10+
/// Publications can indicate whether they allow third parties to use their
11+
/// content for text and data mining purposes using the [TDM Rep protocol](https://www.w3.org/community/tdmrep/),
12+
/// as defined in a [W3C Community Group Report](https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/).
13+
///
14+
/// https://github.com/readium/webpub-manifest/blob/master/schema/metadata.schema.json
15+
public struct TDM: Hashable, Sendable {
16+
public struct Reservation: RawRepresentable, Hashable, Sendable {
17+
public let rawValue: String
18+
19+
public init(rawValue: RawValue) {
20+
self.rawValue = rawValue
21+
}
22+
23+
/// All TDM rights are reserved. If a TDM Policy is set, TDM Agents MAY
24+
/// use it to get information on how they can acquire from the
25+
/// rightsholder an authorization to mine the content.
26+
public static let all = Reservation(rawValue: "all")
27+
28+
/// TDM rights are not reserved. TDM agents can mine the content for TDM
29+
/// purposes without having to contact the rightsholder.
30+
public static let none = Reservation(rawValue: "none")
31+
}
32+
33+
public var reservation: Reservation
34+
35+
/// URL pointing to a TDM Policy set be the rightsholder.
36+
public var policy: HTTPURL?
37+
38+
public init(reservation: Reservation, policy: HTTPURL? = nil) {
39+
self.reservation = reservation
40+
self.policy = policy
41+
}
42+
43+
public init?(json: Any?, warnings: WarningLogger? = nil) throws {
44+
guard
45+
let json = json as? [String: Any],
46+
let reservation = (json["reservation"] as? String).flatMap(Reservation.init(rawValue:))
47+
else {
48+
warnings?.log("Invalid TDM object", model: Self.self, source: json, severity: .minor)
49+
throw JSONError.parsing(Self.self)
50+
}
51+
52+
self.init(
53+
reservation: reservation,
54+
policy: (json["policy"] as? String).flatMap { HTTPURL(string: $0) }
55+
)
56+
}
57+
58+
public var json: [String: Any] {
59+
makeJSON([
60+
"reservation": reservation.rawValue,
61+
"policy": encodeIfNotNil(policy?.string),
62+
])
63+
}
64+
}

Sources/Shared/Toolkit/Data/Container/Container.swift

+10-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,16 @@ import Foundation
88

99
/// A container provides access to a list of `Resource` entries.
1010
public protocol Container: Closeable {
11-
/// Direct source to this container, when available.
11+
/// URL locating this container, when available.
12+
///
13+
/// This can be used to optimize access to a container's content for the
14+
/// caller. For example if the container is available on the local file
15+
/// system, a caller might prefer using a file handle instead of the
16+
/// ``Container`` API.
17+
///
18+
/// Note that this must represent the same content available in
19+
/// ``Container``. If you transform the resources content on the fly (e.g.
20+
/// with ``TransformingContainer``), then the `sourceURL` becomes nil.
1221
var sourceURL: AbsoluteURL? { get }
1322

1423
/// List of all the container entries.

Sources/Shared/Toolkit/Data/Resource/Resource.swift

+13-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,19 @@ import Foundation
88

99
/// Acts as a proxy to an actual resource by handling read access.
1010
public protocol Resource: Streamable {
11-
/// URL locating this resource, if any.
11+
/// URL locating this resource, when available.
12+
///
13+
/// This can be used to optimize access to a resource's content for the
14+
/// caller. For example if the resource is available on the local file
15+
/// system, a caller might prefer using a file handle instead of the
16+
/// ``Resource`` API.
17+
///
18+
/// Note that this must represent the same content available in
19+
/// ``Resource``. If you transform the resources content on the fly (e.g.
20+
/// with ``TransformingResource``), then the `sourceURL` becomes nil.
21+
///
22+
/// A ``Resource`` located in a ZIP archive will have a nil `sourceURL`, as
23+
/// there is no direct access to the ZIP entry using an absolute URL.
1224
var sourceURL: AbsoluteURL? { get }
1325

1426
/// Properties associated to the resource.

Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift

+30
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ final class EPUBMetadataParser: Loggable {
6161
numberOfPages: numberOfPages,
6262
belongsToCollections: belongsToCollections,
6363
belongsToSeries: belongsToSeries,
64+
tdm: tdm(),
6465
otherMetadata: otherMetadata
6566
)
6667
}
@@ -276,6 +277,22 @@ final class EPUBMetadataParser: Loggable {
276277
.map { Accessibility.Hazard($0.content) }
277278
}
278279

280+
/// https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/#sec-epub3
281+
private func tdm() -> TDM? {
282+
guard
283+
let reservationMeta = metas["reservation", in: .tdm].first,
284+
let reservation = TDM.Reservation(epub: reservationMeta)
285+
else {
286+
return nil
287+
}
288+
289+
return TDM(
290+
reservation: reservation,
291+
policy: metas["policy", in: .tdm].first
292+
.flatMap { HTTPURL(string: $0.content) }
293+
)
294+
}
295+
279296
/// Parse and return the Epub unique identifier.
280297
/// https://github.com/readium/architecture/blob/master/streamer/parser/metadata.md#identifier
281298
private lazy var uniqueIdentifier: String? =
@@ -573,3 +590,16 @@ final class EPUBMetadataParser: Loggable {
573590
.firstChild(xpath: "(.|opf:dc-metadata)/dc:\(tag)")
574591
}
575592
}
593+
594+
private extension TDM.Reservation {
595+
init?(epub: OPFMeta) {
596+
switch epub.content {
597+
case "0":
598+
self = .none
599+
case "1":
600+
self = .all
601+
default:
602+
return nil
603+
}
604+
}
605+
}

Sources/Streamer/Parser/EPUB/OPFMeta.swift

+10-1
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,18 @@ import ReadiumShared
1313
enum OPFVocabulary: String {
1414
// Fallback prefixes for metadata's properties and links' rels.
1515
case defaultMetadata, defaultLinkRel
16-
// Reserved prefixes (https://idpf.github.io/epub-prefixes/packages/).
16+
17+
// Reserved prefixes
18+
// https://idpf.github.io/epub-prefixes/packages/
1719
case a11y, dcterms, epubsc, marc, media, onix, rendition, schema, xsd
20+
1821
// Additional prefixes used in the streamer.
1922
case calibre
2023

24+
// New TDM Reservation Protocol
25+
// https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/
26+
case tdm
27+
2128
var uri: String {
2229
switch self {
2330
case .defaultMetadata:
@@ -45,6 +52,8 @@ enum OPFVocabulary: String {
4552
case .calibre:
4653
// https://github.com/kovidgoyal/calibre/blob/3f903cbdd165e0d1c5c25eecb6eef2a998342230/src/calibre/ebooks/metadata/opf3.py#L170
4754
return "https://calibre-ebook.com"
55+
case .tdm:
56+
return "http://www.w3.org/ns/tdmrep#"
4857
}
4958
}
5059

Support/Carthage/.xcodegen

+1
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,7 @@
659659
../../Sources/Shared/Publication/Services/Table Of Contents
660660
../../Sources/Shared/Publication/Services/Table Of Contents/TableOfContentsService.swift
661661
../../Sources/Shared/Publication/Subject.swift
662+
../../Sources/Shared/Publication/TDM.swift
662663
../../Sources/Shared/Publication/User Settings
663664
../../Sources/Shared/Publication/User Settings/UserProperties.swift
664665
../../Sources/Shared/Publication/User Settings/UserSettings.swift

Support/Carthage/Readium.xcodeproj/project.pbxproj

+4
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@
327327
D1248D9E9EE269C3245927F7 /* Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBD54FD376456C1925316BC /* Cancellable.swift */; };
328328
D25058A427D47ABEA88A3F4B /* PublicationSpeechSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99DE4955327D8C2DE6F866D3 /* PublicationSpeechSynthesizer.swift */; };
329329
D29E1DBC5BD1B82C996427C4 /* Closeable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02A9225D636D845BF24F6AC /* Closeable.swift */; };
330+
D3058C80ABFBAED1CAA1B0CC /* TDM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28792F801221D49F61B92CF8 /* TDM.swift */; };
330331
D4BBC0AD7652265497B5CD1C /* URLExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10894CC9684584098A22D8FA /* URLExtensions.swift */; };
331332
D525E9E2AFA0ED63151A4FBC /* Assets in Resources */ = {isa = PBXBuildFile; fileRef = DBCE9786DD346E6BDB2E50FF /* Assets */; };
332333
D589F337B244F5B58E85AEFB /* AudioSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC9D0ACCBA7B6E4473A95D5E /* AudioSettings.swift */; };
@@ -542,6 +543,7 @@
542543
258351CE21165EDED7F87878 /* URLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocol.swift; sourceTree = "<group>"; };
543544
2732AFC91AB15FA09C60207A /* Locator+Audio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+Audio.swift"; sourceTree = "<group>"; };
544545
2828D89EBB52CCA782ED1146 /* ReadiumFuzi.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ReadiumFuzi.xcframework; path = ../../Carthage/Build/ReadiumFuzi.xcframework; sourceTree = "<group>"; };
546+
28792F801221D49F61B92CF8 /* TDM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDM.swift; sourceTree = "<group>"; };
545547
294E01A2E6FF25539EBC1082 /* Properties+Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Archive.swift"; sourceTree = "<group>"; };
546548
29AD63CD2A41586290547212 /* NavigationDocumentParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationDocumentParser.swift; sourceTree = "<group>"; };
547549
2AF56CF04F94B7BE45631897 /* LCPContentProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPContentProtection.swift; sourceTree = "<group>"; };
@@ -2005,6 +2007,7 @@
20052007
3B0A149FC97C747F55F6463C /* PublicationCollection.swift */,
20062008
74F646B746EB27124F9456F8 /* ReadingProgression.swift */,
20072009
98CD4C99103DC795E44F56AE /* Subject.swift */,
2010+
28792F801221D49F61B92CF8 /* TDM.swift */,
20082011
87352D29A81641A4B9054319 /* Asset */,
20092012
055166DFDEE6C6A17D04D42D /* Extensions */,
20102013
C1002695D860AE505D689C26 /* Media Overlays */,
@@ -2686,6 +2689,7 @@
26862689
4DB4C10CB9AB5D38C56C1609 /* StringEncoding.swift in Sources */,
26872690
E6AC10CCF9711168BE2BE85C /* StringSearchService.swift in Sources */,
26882691
3E9F244ACDA938D330B9EAEA /* Subject.swift in Sources */,
2692+
D3058C80ABFBAED1CAA1B0CC /* TDM.swift in Sources */,
26892693
CB95F5EAA4D0DB5177FED4F7 /* TableOfContentsService.swift in Sources */,
26902694
4E84353322A4CDBBCAD6C070 /* TailCachingResource.swift in Sources */,
26912695
96048047B4205636ABB66DC9 /* TextTokenizer.swift in Sources */,

Tests/SharedTests/Publication/MetadataTests.swift

+9
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class MetadataTests: XCTestCase {
4242
],
4343
belongsToCollections: [Contributor(name: "Collection")],
4444
belongsToSeries: [Contributor(name: "Series")],
45+
tdm: TDM(reservation: .all, policy: HTTPURL(string: "https://tdm.com")!),
4546
otherMetadata: [
4647
"other-metadata1": "value",
4748
"other-metadata2": [42],
@@ -105,6 +106,10 @@ class MetadataTests: XCTestCase {
105106
"series": "Series",
106107
"schema:Periodical": "Periodical",
107108
],
109+
"tdm": [
110+
"reservation": "all",
111+
"policy": "https://tdm.com",
112+
],
108113
"other-metadata1": "value",
109114
"other-metadata2": [42],
110115
] as [String: Any]),
@@ -215,6 +220,10 @@ class MetadataTests: XCTestCase {
215220
"series": [["name": "Series"]],
216221
"schema:Periodical": [["name": "Periodical"]],
217222
],
223+
"tdm": [
224+
"reservation": "all",
225+
"policy": "https://tdm.com",
226+
],
218227
"other-metadata1": "value",
219228
"other-metadata2": [42],
220229
] as [String: Any]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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+
@testable import ReadiumShared
8+
import XCTest
9+
10+
class TDMTests: XCTestCase {
11+
func testParseMinimalJSON() {
12+
XCTAssertEqual(
13+
try? TDM(json: ["reservation": "none"]),
14+
TDM(reservation: .none)
15+
)
16+
}
17+
18+
func testParseFullJSON() {
19+
XCTAssertEqual(
20+
try? TDM(json: [
21+
"reservation": "all",
22+
"policy": "https://policy",
23+
] as [String: Any]),
24+
TDM(
25+
reservation: .all,
26+
policy: HTTPURL(string: "https://policy")
27+
)
28+
)
29+
}
30+
31+
func testParseJSONRequiresReservation() {
32+
XCTAssertThrowsError(try TDM(json: [
33+
"policy": "https://policy",
34+
]))
35+
}
36+
37+
func testGetMinimalJSON() {
38+
AssertJSONEqual(
39+
TDM(reservation: .none).json,
40+
["reservation": "none"]
41+
)
42+
}
43+
44+
func testGetFullJSON() {
45+
AssertJSONEqual(
46+
TDM(
47+
reservation: .all,
48+
policy: HTTPURL(string: "https://policy")
49+
).json,
50+
[
51+
"reservation": "all",
52+
"policy": "https://policy",
53+
] as [String: Any]
54+
)
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?xml version="1.0"?>
2+
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id" version="2.0" xml:lang="en">
3+
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
4+
<dc:title>Alice's Adventures in Wonderland</dc:title>
5+
6+
<meta name="tdm:reservation" content="1" />
7+
<meta name="tdm:policy" content="https://provider.com/policies/policy.json" />
8+
</metadata>
9+
<manifest>
10+
<item id="titlepage" href="titlepage.xhtml"/>
11+
</manifest>
12+
<spine>
13+
<itemref idref="titlepage"/>
14+
</spine>
15+
</package>

0 commit comments

Comments
 (0)