Skip to content

Commit 7cd7937

Browse files
authoredSep 11, 2024··
Guided navigation EPUB implementation (#100)
1 parent 20dcb7f commit 7cd7937

39 files changed

+918
-99
lines changed
 

‎.github/workflows/build.yml

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ jobs:
1616
- name: Set up Go
1717
uses: actions/setup-go@v5
1818
with:
19-
go-version: '>=1.22.0'
19+
go-version: '>=1.23.0'
20+
cache: false
2021

2122
- name: Build
2223
run: go build -v ./...

‎.github/workflows/release.yml

+2-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ jobs:
2424
- name: Set up Go
2525
uses: actions/setup-go@v5
2626
with:
27-
go-version: '>=1.22.0'
27+
go-version: '>=1.23.0'
28+
cache: false
2829
- name: Build release
2930
uses: goreleaser/goreleaser-action@v5
3031
with:

‎cmd/rwp/cmd/serve/api.go

+11-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"path"
1111
"path/filepath"
12+
"slices"
1213
"strconv"
1314
"strings"
1415
"syscall"
@@ -196,9 +197,15 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) {
196197
w.WriteHeader(http.StatusNotFound)
197198
return
198199
}
200+
finalLink := *link
201+
202+
// Expand templated links to include URL query parameters
203+
if finalLink.Templated {
204+
finalLink = finalLink.ExpandTemplate(convertURLValuesToMap(r.URL.Query()))
205+
}
199206

200207
// Get the asset from the publication
201-
res := publication.Get(*link)
208+
res := publication.Get(finalLink)
202209
defer res.Close()
203210

204211
// Get asset length in bytes
@@ -214,6 +221,9 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) {
214221
if sub, ok := mimeSubstitutions[contentType]; ok {
215222
contentType = sub
216223
}
224+
if slices.Contains(utfCharsetNeeded, contentType) {
225+
contentType += "; charset=utf-8"
226+
}
217227
w.Header().Set("content-type", contentType)
218228
w.Header().Set("cache-control", "private, max-age=86400, immutable")
219229
w.Header().Set("content-length", strconv.FormatInt(l, 10))

‎cmd/rwp/cmd/serve/helpers.go

+30-10
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,24 @@ package serve
22

33
import (
44
"net/http"
5+
"net/url"
56
"strings"
67

78
"github.com/readium/go-toolkit/pkg/manifest"
9+
"github.com/readium/go-toolkit/pkg/mediatype"
810
)
911

1012
var mimeSubstitutions = map[string]string{
11-
"application/vnd.ms-opentype": "font/otf", // Not just because it's sane, but because CF will compress it!
12-
"application/vnd.readium.content+json": "application/vnd.readium.content+json; charset=utf-8", // Need utf-8 encoding
13+
"application/vnd.ms-opentype": "font/otf", // Not just because it's sane, but because CF will compress it!
14+
}
15+
16+
var utfCharsetNeeded = []string{
17+
mediatype.ReadiumWebpubManifest.String(),
18+
mediatype.ReadiumDivinaManifest.String(),
19+
mediatype.ReadiumAudiobookManifest.String(),
20+
mediatype.ReadiumPositionList.String(),
21+
mediatype.ReadiumContentDocument.String(),
22+
mediatype.ReadiumGuidedNavigationDocument.String(),
1323
}
1424

1525
var compressableMimes = []string{
@@ -19,11 +29,11 @@ var compressableMimes = []string{
1929
"text/css",
2030
"text/html",
2131
"application/xhtml+xml",
22-
"application/webpub+json",
23-
"application/divina+json",
24-
"application/vnd.readium.position-list+json",
25-
"application/vnd.readium.content+json",
26-
"application/audiobook+json",
32+
mediatype.ReadiumWebpubManifest.String(),
33+
mediatype.ReadiumDivinaManifest.String(),
34+
mediatype.ReadiumPositionList.String(),
35+
mediatype.ReadiumContentDocument.String(),
36+
mediatype.ReadiumAudiobookManifest.String(),
2737
"font/ttf",
2838
"application/ttf",
2939
"application/x-ttf",
@@ -50,12 +60,12 @@ func makeRelative(link manifest.Link) manifest.Link {
5060
}
5161

5262
func conformsToAsMimetype(conformsTo manifest.Profiles) string {
53-
mime := "application/webpub+json"
63+
mime := mediatype.ReadiumWebpubManifest.String()
5464
for _, profile := range conformsTo {
5565
if profile == manifest.ProfileDivina {
56-
mime = "application/divina+json"
66+
mime = mediatype.ReadiumDivinaManifest.String()
5767
} else if profile == manifest.ProfileAudiobook {
58-
mime = "application/audiobook+json"
68+
mime = mediatype.ReadiumAudiobookManifest.String()
5969
} else {
6070
continue
6171
}
@@ -88,3 +98,13 @@ func parseCoding(s string) (coding string) {
8898
coding = strings.ToLower(strings.TrimSpace(s[:p]))
8999
return
90100
}
101+
102+
func convertURLValuesToMap(values url.Values) map[string]string {
103+
result := make(map[string]string)
104+
for key, val := range values {
105+
if len(val) > 0 {
106+
result[key] = val[0] // Take the first value for each key
107+
}
108+
}
109+
return result
110+
}

‎pkg/manifest/guided_navigation.go

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package manifest
2+
3+
// Readium Guided Navigation Document
4+
// https://readium.org/guided-navigation/schema/document.schema.json
5+
type GuidedNavigationDocument struct {
6+
Links []Link `json:"links,omitempty"` // References to other resources that are related to the current Guided Navigation Document.
7+
Guided []GuidedNavigationObject `json:"guided"` // A sequence of resources and/or media fragments into these resources, meant to be presented sequentially to the user.
8+
}
9+
10+
// Readium Guided Navigation Object
11+
// https://readium.org/guided-navigation/schema/object.schema.json
12+
// TODO: Role should be typed
13+
type GuidedNavigationObject struct {
14+
AudioRef string `json:"audioref,omitempty"` // References an audio resource or a fragment of it.
15+
ImgRef string `json:"imgref,omitempty"` // References an image or a fragment of it.
16+
TextRef string `json:"textref,omitempty"` // References a textual resource or a fragment of it.
17+
Text string `json:"text,omitempty"` // Textual equivalent of the resources or fragment of the resources referenced by the current Guided Navigation Object.
18+
Role []string `json:"role,omitempty"` // Convey the structural semantics of a publication
19+
Children []GuidedNavigationObject `json:"children,omitempty"` // Items that are children of the containing Guided Navigation Object.
20+
}
21+
22+
// TODO: functions for objects to get e.g. audio time, audio file, text file, fragment id, audio "clip", image xywh, etc.
23+
// This will come after the URL utility revamp to avoid implementation twice

‎pkg/manifest/link.go

+12
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,12 @@ type LinkList []Link
229229
// Returns the first [Link] with the given [href], or null if not found.
230230
func (ll LinkList) IndexOfFirstWithHref(href string) int {
231231
for i, link := range ll {
232+
if link.Templated {
233+
if strings.TrimPrefix(link.ExpandTemplate(nil).Href, "/") == href {
234+
// TODO: remove trimming when href utils are updated
235+
return i
236+
}
237+
}
232238
if link.Href == href {
233239
return i
234240
}
@@ -244,6 +250,12 @@ func (ll LinkList) IndexOfFirstWithHref(href string) int {
244250
// Finds the first link matching the given HREF.
245251
func (ll LinkList) FirstWithHref(href string) *Link {
246252
for _, link := range ll {
253+
if link.Templated {
254+
if strings.TrimPrefix(link.ExpandTemplate(nil).Href, "/") == href {
255+
// TODO: remove trimming when href utils are updated
256+
return &link
257+
}
258+
}
247259
if link.Href == href {
248260
return &link
249261
}

‎pkg/manifest/media_overlay.go

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package manifest
2+
3+
// EPUB profile extension for WebPub Manifest for media overlay features.
4+
type MediaOverlay struct {
5+
ActiveClass string `json:"activeClass,omitempty"` // Author-defined CSS class name to apply to the currently-playing EPUB Content Document element.
6+
PlaybackActiveClass string `json:"playbackActiveClass,omitempty"` // Author-defined CSS class name to apply to the EPUB Content Document's document element when playback is active.
7+
}

‎pkg/manifest/metadata.go

+32-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"strings"
66
"time"
77

8+
"github.com/go-viper/mapstructure/v2"
89
"github.com/pkg/errors"
910
"github.com/readium/go-toolkit/pkg/internal/util"
1011
)
@@ -52,8 +53,9 @@ type Metadata struct {
5253
NumberOfPages *uint `json:"numberOfPages,omitempty"`
5354
BelongsTo map[string]Collections `json:"belongsTo,omitempty"`
5455
Presentation *Presentation `json:"presentation,omitempty"`
56+
MediaOverlay *MediaOverlay `json:"mediaOverlay,omitempty"`
5557

56-
OtherMetadata map[string]interface{} `json:"-"` // Extension point for other metadata. TODO implement
58+
OtherMetadata map[string]interface{} `json:"-"` // Extension point for other metadata.
5759
}
5860

5961
func (m Metadata) Title() string {
@@ -351,7 +353,31 @@ func MetadataFromJSON(rawJson map[string]interface{}, normalizeHref LinkHrefNorm
351353
}
352354

353355
// Presentation
354-
// TODO custom presentation unmarshalling
356+
if presentation, ok := rawJson["presentation"].(map[string]interface{}); ok {
357+
metadata.Presentation = &Presentation{}
358+
359+
decoder, _ := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
360+
TagName: "json",
361+
Result: metadata.Presentation,
362+
})
363+
if err := decoder.Decode(presentation); err != nil {
364+
return nil, errors.Wrap(err, "failed parsing 'presentation'")
365+
}
366+
metadata.Presentation.setDefaults()
367+
}
368+
369+
// Media Overlay
370+
if mediaOverlay, ok := rawJson["mediaOverlay"].(map[string]interface{}); ok {
371+
metadata.MediaOverlay = &MediaOverlay{}
372+
373+
decoder, _ := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
374+
TagName: "json",
375+
Result: metadata.MediaOverlay,
376+
})
377+
if err := decoder.Decode(mediaOverlay); err != nil {
378+
return nil, errors.Wrap(err, "failed parsing 'mediaOverlay'")
379+
}
380+
}
355381

356382
// Delete above vals so that we can put everything else in OtherMetadata
357383
for _, v := range []string{
@@ -378,6 +404,7 @@ func MetadataFromJSON(rawJson map[string]interface{}, normalizeHref LinkHrefNorm
378404
"numberOfPages",
379405
"penciler",
380406
"presentation",
407+
"mediaOverlay",
381408
"published",
382409
"publisher",
383410
"readingProgression",
@@ -423,6 +450,9 @@ func (m Metadata) MarshalJSON() ([]byte, error) {
423450
if m.Presentation != nil {
424451
j["presentation"] = m.Presentation
425452
}
453+
if m.MediaOverlay != nil {
454+
j["mediaOverlay"] = m.MediaOverlay
455+
}
426456

427457
if m.Identifier != "" {
428458
j["identifier"] = m.Identifier

‎pkg/mediatype/mediatype.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import (
1414

1515
// MediaType represents a document format, identified by a unique RFC 6838 media type.
1616
// [MediaType] handles:
17-
// - components parsing – eg. type, subtype and parameters,
18-
// - media types comparison.
17+
// - components parsing – eg. type, subtype and parameters,
18+
// - media types comparison.
1919
//
2020
// Comparing media types is more complicated than it looks, since they can contain parameters,
2121
// such as `charset=utf-8`. We can't ignore them because some formats use parameters in their
@@ -287,13 +287,13 @@ func (mt MediaType) IsVideo() bool {
287287

288288
// Returns whether this media type is of a Readium Web Publication Manifest.
289289
func (mt MediaType) IsRwpm() bool {
290-
return mt.Matches(&ReadiumAudiobookManifest, &DivinaManifest, &ReadiumWebpubManifest)
290+
return mt.Matches(&ReadiumAudiobookManifest, &ReadiumDivinaManifest, &ReadiumWebpubManifest)
291291
}
292292

293293
// Returns whether this media type is of a publication file.
294294
func (mt MediaType) IsPublication() bool {
295295
return mt.Matches(
296-
&ReadiumAudiobook, &ReadiumAudiobookManifest, &CBZ, &Divina, &DivinaManifest, &EPUB, &LCPProtectedAudiobook,
296+
&ReadiumAudiobook, &ReadiumAudiobookManifest, &CBZ, &ReadiumDivina, &ReadiumDivinaManifest, &EPUB, &LCPProtectedAudiobook,
297297
&LCPProtectedPDF, &LPF, &PDF, &W3CWPUBManifest, &ReadiumWebpub, &ReadiumWebpubManifest, &ZAB,
298298
)
299299
}

‎pkg/mediatype/sniffer.go

+8-4
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,10 @@ func SniffWebpub(context SnifferContext) *MediaType {
184184
}
185185

186186
if context.HasFileExtension("divina") || context.HasMediaType("application/divina+zip") {
187-
return &Divina
187+
return &ReadiumDivina
188188
}
189189
if context.HasMediaType("application/divina+json") {
190-
return &DivinaManifest
190+
return &ReadiumDivinaManifest
191191
}
192192

193193
if context.HasFileExtension("webpub") || context.HasMediaType("application/webpub+zip") {
@@ -281,8 +281,12 @@ func SniffLPF(context SnifferContext) *MediaType {
281281
// Authorized extensions for resources in a CBZ archive.
282282
// Reference: https://wiki.mobileread.com/wiki/CBR_and_CBZ
283283
var cbz_extensions = map[string]struct{}{
284-
"bmp": {}, "dib": {}, "gif": {}, "jif": {}, "jfi": {}, "jfif": {}, "jpg": {}, "jpeg": {}, "png": {}, "tif": {}, "tiff": {}, "webp": {}, // Bitmap. Note there's no AVIF or JXL
285-
"acbf": {}, "xml": {}, "txt": {}, // Metadata
284+
// Bitmaps
285+
"bmp": {}, "dib": {}, "gif": {}, "jif": {}, "jfi": {}, "jfif": {}, "jpg": {},
286+
"jpeg": {}, "png": {}, "tif": {}, "tiff": {}, "webp": {}, "avif": {}, "jxl": {},
287+
288+
// Metadata
289+
"acbf": {}, "xml": {}, "txt": {},
286290
}
287291

288292
// Authorized extensions for resources in a ZAB archive (Zipped Audio Book).

‎pkg/mediatype/sniffer_test.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,14 @@ func TestSniffCBZ(t *testing.T) {
124124
}
125125

126126
func TestSniffDiViNa(t *testing.T) {
127-
assert.Equal(t, &Divina, OfExtension("divina"))
128-
assert.Equal(t, &Divina, OfString("application/divina+zip"))
127+
assert.Equal(t, &ReadiumDivina, OfExtension("divina"))
128+
assert.Equal(t, &ReadiumDivina, OfString("application/divina+zip"))
129129
// TODO needs webpub heavy parsing. See func SniffWebpub in sniffer.go for details.
130130
// assert.Equal(t, &DIVINA, OfFileOnly("divina-package.unknown"))
131131
}
132132

133133
func TestSniffDiViNaManifest(t *testing.T) {
134-
assert.Equal(t, &DivinaManifest, OfString("application/divina+json"))
134+
assert.Equal(t, &ReadiumDivinaManifest, OfString("application/divina+json"))
135135
// TODO needs webpub heavy parsing. See func SniffWebpub in sniffer.go for details.
136136
// assert.Equal(t, &DIVINA_MANIFEST, OfFileOnly("divina.json"))
137137
}

0 commit comments

Comments
 (0)
Please sign in to comment.