Skip to content

Commit eb10465

Browse files
authored
Add Link properties for archive entry metadata (#169)
1 parent 0be4763 commit eb10465

File tree

6 files changed

+213
-30
lines changed

6 files changed

+213
-30
lines changed

readium/shared/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ All notable changes to this project will be documented in this file.
1010

1111
* (*alpha*) A new Publication `SearchService` to search through the resources' content, with a default implementation `StringSearchService`.
1212
* `ContentProtection.Scheme` can be used to identify protection technologies using unique URI identifiers.
13+
* `Link` objects from archive-based publication assets (e.g. an EPUB/ZIP) have additional properties for entry metadata.
14+
```json
15+
"properties" {
16+
"archive": {
17+
"entryLength": 8273,
18+
"isEntryCompressed": true
19+
}
20+
}
21+
```
1322

1423
### Changed
1524

readium/shared/r2-shared/src/main/java/org/readium/r2/shared/fetcher/ArchiveFetcher.kt

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import org.readium.r2.shared.extensions.addPrefix
1515
import org.readium.r2.shared.extensions.tryOr
1616
import org.readium.r2.shared.extensions.tryOrNull
1717
import org.readium.r2.shared.publication.Link
18+
import org.readium.r2.shared.publication.Properties
1819
import org.readium.r2.shared.util.Try
1920
import org.readium.r2.shared.util.archive.Archive
2021
import org.readium.r2.shared.util.archive.ArchiveFactory
@@ -67,10 +68,8 @@ class ArchiveFetcher private constructor(private val archive: Archive) : Fetcher
6768
}
6869

6970
override suspend fun link(): Link {
70-
val compressedLength = entry().map { it.compressedLength }.getOrNull()
71-
?: return originalLink
72-
73-
return originalLink.addProperties(mapOf("compressedLength" to compressedLength))
71+
val entry = entry().getOrNull() ?: return originalLink
72+
return originalLink.addProperties(entry.toLinkProperties())
7473
}
7574

7675
override suspend fun read(range: LongRange?): ResourceTry<ByteArray> =
@@ -98,11 +97,25 @@ class ArchiveFetcher private constructor(private val archive: Archive) : Fetcher
9897
}
9998

10099
private suspend fun Archive.Entry.toLink(): Link {
101-
val link = Link(
100+
return Link(
102101
href = path.addPrefix("/"),
103-
type = MediaType.of(fileExtension = File(path).extension)?.toString()
102+
type = MediaType.of(fileExtension = File(path).extension)?.toString(),
103+
properties = Properties(toLinkProperties())
104+
)
105+
}
106+
107+
private fun Archive.Entry.toLinkProperties(): Map<String, Any> {
108+
val properties = mutableMapOf<String, Any>(
109+
"archive" to mapOf(
110+
"entryLength" to (compressedLength ?: length ?: 0),
111+
"isEntryCompressed" to (compressedLength != null)
112+
)
104113
)
105114

106-
return compressedLength?.let { link.addProperties(mapOf("compressedLength" to it)) }
107-
?: link
115+
compressedLength?.let {
116+
// FIXME: Legacy property, should be removed in 3.0.0
117+
properties["compressedLength"] = it
118+
}
119+
120+
return properties
108121
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2021 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.archive
8+
9+
import android.os.Parcelable
10+
import kotlinx.parcelize.Parcelize
11+
import org.json.JSONObject
12+
import org.readium.r2.shared.JSONable
13+
import org.readium.r2.shared.extensions.optNullableBoolean
14+
import org.readium.r2.shared.extensions.optNullableLong
15+
import org.readium.r2.shared.extensions.optNullableString
16+
import org.readium.r2.shared.publication.Properties
17+
import org.readium.r2.shared.publication.encryption.Encryption
18+
import org.readium.r2.shared.publication.encryption.encryption
19+
import org.readium.r2.shared.util.logging.WarningLogger
20+
import org.readium.r2.shared.util.logging.log
21+
22+
// Archive Link Properties Extension
23+
24+
/**
25+
* Holds information about how the resource is stored in the publication archive.
26+
*
27+
* @param entryLength The length of the entry stored in the archive. It might be a compressed length
28+
* if the entry is deflated.
29+
* @param isEntryCompressed Indicates whether the entry was compressed before being stored in the
30+
* archive.
31+
*/
32+
@Parcelize
33+
data class ArchiveProperties(
34+
val entryLength: Long,
35+
val isEntryCompressed: Boolean
36+
) : JSONable, Parcelable {
37+
38+
override fun toJSON(): JSONObject = JSONObject().apply {
39+
put("entryLength", entryLength)
40+
put("isEntryCompressed", isEntryCompressed)
41+
}
42+
43+
companion object {
44+
fun fromJSON(json: JSONObject?, warnings: WarningLogger? = null): ArchiveProperties? {
45+
json ?: return null
46+
47+
val entryLength = json.optNullableLong("entryLength")
48+
val isEntryCompressed = json.optNullableBoolean("isEntryCompressed")
49+
if (entryLength == null || isEntryCompressed == null) {
50+
warnings?.log(ArchiveProperties::class.java, "[entryLength] and [isEntryCompressed] are required", json)
51+
return null
52+
}
53+
54+
return ArchiveProperties(entryLength = entryLength, isEntryCompressed = isEntryCompressed)
55+
}
56+
57+
}
58+
}
59+
60+
/**
61+
* Provides information about how the resource is stored in the publication archive.
62+
*/
63+
val Properties.archive: ArchiveProperties?
64+
get() = (this["archive"] as? Map<*, *>)
65+
?.let { ArchiveProperties.fromJSON(JSONObject(it)) }

readium/shared/r2-shared/src/main/java/org/readium/r2/shared/util/archive/Archive.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
package org.readium.r2.shared.util.archive
1111

12-
import android.content.Context
1312
import kotlinx.coroutines.Dispatchers
1413
import kotlinx.coroutines.withContext
1514
import org.readium.r2.shared.extensions.tryOr
@@ -49,7 +48,10 @@ interface Archive : SuspendingCloseable {
4948
*/
5049
interface Entry : SuspendingCloseable {
5150

52-
/** Absolute path to the entry in the archive. */
51+
/**
52+
* Absolute path to the entry in the archive.
53+
* It MUST start with /.
54+
*/
5355
val path: String
5456

5557
/**

readium/shared/r2-shared/src/test/java/org/readium/r2/shared/fetcher/ArchiveFetcherTest.kt

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ package org.readium.r2.shared.fetcher
1111

1212
import android.webkit.MimeTypeMap
1313
import kotlinx.coroutines.runBlocking
14+
import org.json.JSONObject
1415
import org.junit.Test
1516
import org.junit.runner.RunWith
17+
import org.readium.r2.shared.assertJSONEquals
1618
import org.readium.r2.shared.lengthBlocking
1719
import org.readium.r2.shared.linkBlocking
1820
import org.readium.r2.shared.publication.Link
@@ -46,29 +48,36 @@ class ArchiveFetcherTest {
4648
addExtensionMimeTypMapping("xml", "text/xml")
4749
}
4850

49-
fun createLink(href: String, type: String?, compressedLength: Long? = null) = Link(
50-
href = href,
51-
type = type,
52-
properties =
53-
Properties(
54-
compressedLength
55-
?.let {mapOf("compressedLength" to compressedLength) }
56-
?: mapOf()
51+
fun createLink(href: String, type: String?, entryLength: Long, isCompressed: Boolean): Link {
52+
val props = mutableMapOf<String, Any>(
53+
"archive" to mapOf(
54+
"entryLength" to entryLength,
55+
"isEntryCompressed" to isCompressed
5756
)
58-
)
57+
)
58+
if (isCompressed) {
59+
props["compressedLength"] = entryLength
60+
}
61+
62+
return Link(
63+
href = href,
64+
type = type,
65+
properties = Properties(props)
66+
)
67+
}
5968

6069
assertEquals(
6170
listOf(
62-
createLink("/mimetype", null),
63-
createLink("/EPUB/cover.xhtml" , "application/xhtml+xml", 259L),
64-
createLink("/EPUB/css/epub.css", "text/css", 595L),
65-
createLink("/EPUB/css/nav.css", "text/css", 306L),
66-
createLink("/EPUB/images/cover.png", "image/png", 35809L),
67-
createLink("/EPUB/nav.xhtml", "application/xhtml+xml", 2293L),
68-
createLink("/EPUB/package.opf", null, 773L),
69-
createLink("/EPUB/s04.xhtml", "application/xhtml+xml", 118269L),
70-
createLink("/EPUB/toc.ncx", null, 1697),
71-
createLink("/META-INF/container.xml", "text/xml", 176)
71+
createLink("/mimetype", null, 20, false),
72+
createLink("/EPUB/cover.xhtml" , "application/xhtml+xml", 259L, true),
73+
createLink("/EPUB/css/epub.css", "text/css", 595L, true),
74+
createLink("/EPUB/css/nav.css", "text/css", 306L, true),
75+
createLink("/EPUB/images/cover.png", "image/png", 35809L, true),
76+
createLink("/EPUB/nav.xhtml", "application/xhtml+xml", 2293L, true),
77+
createLink("/EPUB/package.opf", null, 773L, true),
78+
createLink("/EPUB/s04.xhtml", "application/xhtml+xml", 118269L, true),
79+
createLink("/EPUB/toc.ncx", null, 1697, true),
80+
createLink("/META-INF/container.xml", "text/xml", 176, true)
7281
),
7382
runBlocking { fetcher.links() }
7483
)
@@ -136,14 +145,28 @@ class ArchiveFetcherTest {
136145
assertFailsWith<Resource.Exception.NotFound> { resource.lengthBlocking().getOrThrow() }
137146
}
138147

148+
@Test
149+
fun `Adds compressed length and archive properties to the Link`() = runBlocking {
150+
assertJSONEquals(
151+
JSONObject(mapOf(
152+
"compressedLength" to 595L,
153+
"archive" to mapOf(
154+
"entryLength" to 595L,
155+
"isEntryCompressed" to true
156+
)
157+
)),
158+
fetcher.get(Link(href = "/EPUB/css/epub.css")).link().properties.toJSON()
159+
)
160+
}
139161

140162
@Test
141163
fun `Original link properties are kept`() {
142164
val resource = fetcher.get(Link(href = "/mimetype", properties = Properties(mapOf("other" to "property"))))
143165

144166
assertEquals(
145167
Link(href = "/mimetype", properties = Properties(mapOf(
146-
"other" to "property"
168+
"other" to "property",
169+
"archive" to mapOf("entryLength" to 20L, "isEntryCompressed" to false)
147170
))),
148171
resource.linkBlocking()
149172
)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package org.readium.r2.shared.publication.archive
2+
3+
import org.json.JSONObject
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Assert.assertNull
6+
import org.junit.Test
7+
import org.readium.r2.shared.assertJSONEquals
8+
import org.readium.r2.shared.publication.Properties
9+
10+
class PropertiesTest {
11+
12+
@Test
13+
fun `get no archive`() {
14+
assertNull(Properties().archive)
15+
}
16+
17+
@Test
18+
fun `get full archive`() {
19+
assertEquals(
20+
ArchiveProperties(entryLength = 8273, isEntryCompressed = true),
21+
Properties(mapOf(
22+
"archive" to mapOf(
23+
"entryLength" to 8273,
24+
"isEntryCompressed" to true
25+
)
26+
)).archive
27+
)
28+
}
29+
30+
@Test
31+
fun `get invalid archive`() {
32+
assertNull(
33+
Properties(mapOf(
34+
"archive" to mapOf(
35+
"foo" to "bar"
36+
)
37+
)).archive
38+
)
39+
}
40+
41+
@Test
42+
fun `get incomplete archive`() {
43+
assertNull(
44+
Properties(mapOf(
45+
"archive" to mapOf(
46+
"isEntryCompressed" to true
47+
)
48+
)).archive
49+
)
50+
51+
assertNull(
52+
Properties(mapOf(
53+
"archive" to mapOf(
54+
"entryLength" to 8273
55+
)
56+
)).archive
57+
)
58+
}
59+
60+
@Test
61+
fun `get archive JSON`() {
62+
assertJSONEquals(
63+
JSONObject(mapOf(
64+
"entryLength" to 8273L,
65+
"isEntryCompressed" to true
66+
)),
67+
ArchiveProperties(entryLength = 8273, isEntryCompressed = true).toJSON()
68+
)
69+
}
70+
71+
}

0 commit comments

Comments
 (0)