diff --git a/pkg/guidednavigation/converter/converter.go b/pkg/guidednavigation/converter/converter.go
index 5dde48a7..2047a739 100644
--- a/pkg/guidednavigation/converter/converter.go
+++ b/pkg/guidednavigation/converter/converter.go
@@ -13,7 +13,7 @@ import (
"golang.org/x/net/html/atom"
)
-func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator) (*guidednavigation.GuidedNavigationDocument, error) {
+func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator, opts ...Option) (*guidednavigation.GuidedNavigationDocument, error) {
raw, rerr := fetcher.ReadResourceAsString(ctx, resource)
if rerr != nil {
return nil, errors.Wrap(rerr, "failed reading HTML string of "+resource.Link().Href.String())
@@ -45,7 +45,7 @@ func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator
return nil, errors.New("HTML of " + resource.Link().Href.String() + " doesn't have a
")
}
- contentConverter := NewHTMLConverter(locator)
+ contentConverter := NewHTMLConverter(locator, opts...)
contentConverter.xmlParsed = xmlParsed
// Traverse the document's HTML
diff --git a/pkg/guidednavigation/converter/converter_test.go b/pkg/guidednavigation/converter/converter_test.go
index c974f614..ecf7234b 100644
--- a/pkg/guidednavigation/converter/converter_test.go
+++ b/pkg/guidednavigation/converter/converter_test.go
@@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/require"
)
-func convertDoc(t *testing.T, doc string, mt *mediatype.MediaType) string {
+func convertDoc(t *testing.T, doc string, mt *mediatype.MediaType, opts ...Option) string {
t.Helper()
f := fetcher.NewBytesResource(manifest.Link{
Href: manifest.MustNewHREFFromString("hello.xhtml", false),
@@ -23,7 +23,7 @@ func convertDoc(t *testing.T, doc string, mt *mediatype.MediaType) string {
nav, err := Do(context.Background(), f, manifest.Locator{
Href: f.Link().Href.Resolve(nil, nil),
- })
+ }, opts...)
require.NoError(t, err)
bin, err := json.Marshal(nav)
require.NoError(t, err)
@@ -45,6 +45,12 @@ func convertBody(t *testing.T, body string) string {
return convertDoc(t, wrapXHTML("", body), &mediatype.XHTML)
}
+// Like convertBody, with textref locators enabled.
+func convertBodyWithLocators(t *testing.T, body string) string {
+ t.Helper()
+ return convertDoc(t, wrapXHTML("", body), &mediatype.XHTML, WithTextRefLocators())
+}
+
// The expected guided navigation document JSON for the given children of .
func guidedJSON(children string) string {
res := `{"guided":[{"role":["body"],"textref":"hello.xhtml"`
@@ -736,3 +742,58 @@ func TestDoErrorsWithoutBody(t *testing.T) {
})
require.Error(t, err)
}
+
+// With WithTextRefLocators, every object carrying roles points back at its source
+// element: by fragment id when the element has one, by css() selector otherwise.
+// Selectors are anchored to the closest ancestor with an id.
+func TestConvertTextRefLocators(t *testing.T) {
+ res := convertBodyWithLocators(t, `
+ world"
+ },
+ "children": [{
+ "id": "image1",
+ "role": ["image"],
+ "imgref": "img.png",
+ "textref": "hello.xhtml#css(body%20%3E%20section:nth-child(1)%20%3E%20p%20%3E%20img)",
+ "description": "An image"
+ }]
+ }]
+ },
+ {
+ "role": ["section"],
+ "textref": "hello.xhtml#s2",
+ "children": [{
+ "role": ["paragraph"],
+ "textref": "hello.xhtml#css(%23s2%20%3E%20p)",
+ "text": "Two"
+ }]
+ }`), res)
+}
+
+// Objects that carry nothing but a role are still emitted when locators are
+// enabled, since the locator alone makes them useful. Pagebreaks without an id
+// fall back to a css() selector too.
+func TestConvertTextRefLocatorsStandalone(t *testing.T) {
+ res := convertBodyWithLocators(t, `
+
+ `)
+ assert.JSONEq(t, guidedJSON(`{
+ "role": ["separator"],
+ "textref": "hello.xhtml#css(body%20%3E%20hr)"
+ },
+ {
+ "role": ["pagebreak"],
+ "text": "5",
+ "textref": "hello.xhtml#css(body%20%3E%20span)"
+ }`), res)
+}
diff --git a/pkg/guidednavigation/converter/html.go b/pkg/guidednavigation/converter/html.go
index 872b6477..69e70db0 100644
--- a/pkg/guidednavigation/converter/html.go
+++ b/pkg/guidednavigation/converter/html.go
@@ -9,6 +9,7 @@ import (
"unicode"
"github.com/readium/go-toolkit/pkg/guidednavigation"
+ iutil "github.com/readium/go-toolkit/pkg/internal/util"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/util/url"
"golang.org/x/net/html"
@@ -340,9 +341,24 @@ func (a *idAllocator) allocate(prefix string) string {
// contain noterefs. Beyond it, notes are referenced by textref instead.
const maxNoterefDepth = 3
+// An Option configures an [HTMLConverter].
+type Option func(*HTMLConverter)
+
+// WithTextRefLocators makes every emitted object whose element carries roles
+// reference its location in the source document through textref: the element's
+// fragment id when it has one (e.g. "chapter.xhtml#par1"), a css() fragment with
+// a unique CSS selector otherwise (e.g. "chapter.xhtml#css(body%20%3E%20p:nth-child(3))").
+// The selectors are anchored to the closest ancestor with an id when there is one.
+func WithTextRefLocators() Option {
+ return func(c *HTMLConverter) {
+ c.textRefLocators = true
+ }
+}
+
type HTMLConverter struct {
- baseLocator manifest.Locator
- xmlParsed bool // Whether the tree comes from an XML parser (self-closing tags handled correctly).
+ baseLocator manifest.Locator
+ xmlParsed bool // Whether the tree comes from an XML parser (self-closing tags handled correctly).
+ textRefLocators bool // Whether every object with roles gets a textref locating its source element.
segments []textSegment // Closed segments of the text flow accumulated for the current block.
textAcc strings.Builder // Text of the currently open segment, with coalesced whitespace.
@@ -362,10 +378,14 @@ type HTMLConverter struct {
allowNode *html.Node // Node exempt from suppression/visibility checks (target of a noteref sub-conversion).
}
-func NewHTMLConverter(baseLocator manifest.Locator) *HTMLConverter {
- return &HTMLConverter{
+func NewHTMLConverter(baseLocator manifest.Locator, opts ...Option) *HTMLConverter {
+ c := &HTMLConverter{
baseLocator: baseLocator,
}
+ for _, opt := range opts {
+ opt(c)
+ }
+ return c
}
// Whether an element opens (and closes) a navigation object during the traversal.
@@ -393,6 +413,24 @@ func (c *HTMLConverter) fragmentRef(id string) url.URL {
return c.baseLocator.Href.Resolve(frag)
}
+// Builds a reference locating an element of the converted resource: the element's
+// fragment id when it has one, a css() fragment with a unique CSS selector otherwise,
+// e.g. "chapter.xhtml#css(body%20%3E%20p:nth-child(3))".
+func (c *HTMLConverter) nodeRef(n *html.Node) url.URL {
+ if id := getAttr(n, "id"); id != "" {
+ return c.fragmentRef(id)
+ }
+ sel := iutil.CSSSelector(n)
+ if sel == "" || c.baseLocator.Href == nil {
+ return nil
+ }
+ frag, err := url.URLFromGo(&nurl.URL{Fragment: "css(" + sel + ")"})
+ if err != nil {
+ return nil
+ }
+ return c.baseLocator.Href.Resolve(frag)
+}
+
func (c *HTMLConverter) resolveHref(href string) url.URL {
u, err := url.URLFromString(href)
if err != nil || u == nil {
@@ -703,6 +741,11 @@ func (c *HTMLConverter) head(n *html.Node) {
if n.DataAtom == atom.Body {
// Contextualize the top-level object per the specification
cur.TextRef = c.baseLocator.Href
+ } else if c.textRefLocators {
+ // Locate every object carrying roles in the source document
+ if len(roles) > 0 {
+ cur.TextRef = c.nodeRef(n)
+ }
} else if len(roles) > 0 && !c.current.noText {
if id := getAttr(n, "id"); id != "" {
cur.TextRef = c.fragmentRef(id)
@@ -808,6 +851,11 @@ func (c *HTMLConverter) placeholderWithID(n *html.Node, tag string, object guide
// Registering it anyway would leave a dangling id in the SSML.
return
}
+ if c.textRefLocators && object.TextRef == nil {
+ // Locate the object's source element (checked after Empty so that
+ // skipped elements don't come back as bare locators)
+ object.TextRef = c.nodeRef(n)
+ }
child := &navigationObject{node: n, object: object}
if c.current.noText {
// The surrounding text is suppressed: keep the object, without a placeholder
@@ -875,13 +923,14 @@ func (c *HTMLConverter) noteref(n *html.Node, roles []guidednavigation.GuidedNav
// section) are only referenced, never embedded.
if target := c.ids[fragment]; target != nil && !isAncestorOf(target, n) && c.noterefDepth < maxNoterefDepth {
sub := &HTMLConverter{
- baseLocator: c.baseLocator,
- xmlParsed: c.xmlParsed,
- ids: c.ids,
- suppressed: c.suppressed,
- idAlloc: c.idAlloc,
- noterefDepth: c.noterefDepth + 1,
- allowNode: target,
+ baseLocator: c.baseLocator,
+ xmlParsed: c.xmlParsed,
+ textRefLocators: c.textRefLocators,
+ ids: c.ids,
+ suppressed: c.suppressed,
+ idAlloc: c.idAlloc,
+ noterefDepth: c.noterefDepth + 1,
+ allowNode: target,
}
sub.Convert(target)
obj.Children = sub.Result()
diff --git a/pkg/guidednavigation/fragments.go b/pkg/guidednavigation/fragments.go
index 94ac54c3..32731db3 100644
--- a/pkg/guidednavigation/fragments.go
+++ b/pkg/guidednavigation/fragments.go
@@ -21,6 +21,9 @@ import (
// https://idpf.org/epub/renditions/region-nav/#sec-3.5.1
// - Text: fragment ids ("chapter.html#par1") and text fragments
// https://wicg.github.io/scroll-to-text-fragment/
+// - Text: css() selector fragments locating an element of an (X)HTML resource,
+// e.g. "chapter.html#css(body%20%3E%20p:nth-child(3))", as emitted by the
+// converter package's WithTextRefLocators option
// The unit of a spatial fragment's coordinates.
type RegionUnit string
@@ -161,6 +164,13 @@ func (o GuidedNavigationObject) TextFragments() []TextFragment {
return refTextFragments(o.TextRef)
}
+// TextCSSSelector returns the CSS selector of the object's text reference
+// (e.g. "body > p:nth-child(3)" for "chapter.html#css(body%20%3E%20p:nth-child(3))"),
+// or an empty string when the reference has no css() fragment.
+func (o GuidedNavigationObject) TextCSSSelector() string {
+ return refCSSSelector(o.TextRef)
+}
+
// AudioFile returns the audio resource referenced by the description, without its media fragment.
func (d GuidedNavigationDescription) AudioFile() url.URL {
return refFile(d.AudioRef)
@@ -223,6 +233,12 @@ func (d GuidedNavigationDescription) TextFragments() []TextFragment {
return refTextFragments(d.TextRef)
}
+// TextCSSSelector returns the CSS selector of the description's text reference,
+// or an empty string when the reference has no css() fragment.
+func (d GuidedNavigationDescription) TextCSSSelector() string {
+ return refCSSSelector(d.TextRef)
+}
+
// The reference without its fragment.
func refFile(ref url.URL) url.URL {
if ref == nil {
@@ -503,11 +519,40 @@ func refFragmentID(ref url.URL) string {
// A fragment directive doesn't belong to the fragment id preceding it
fragment, _, _ = strings.Cut(fragment, textDirectiveDelimiter)
if decoded, err := nurl.PathUnescape(fragment); err == nil {
- return decoded
+ fragment = decoded
+ }
+ if _, ok := cssSelectorFragment(fragment); ok {
+ // A css() fragment is a selector, not an id
+ return ""
}
return fragment
}
+// The selector of a css() fragment, e.g. "css(body > p)" -> "body > p".
+// The fragment is expected in decoded form.
+func cssSelectorFragment(fragment string) (string, bool) {
+ inner, ok := strings.CutPrefix(fragment, "css(")
+ if !ok {
+ return "", false
+ }
+ inner, ok = strings.CutSuffix(inner, ")")
+ if !ok {
+ return "", false
+ }
+ return inner, true
+}
+
+func refCSSSelector(ref url.URL) string {
+ if ref == nil {
+ return ""
+ }
+ fragment := ref.Fragment()
+ // A fragment directive doesn't belong to the fragment preceding it
+ fragment, _, _ = strings.Cut(fragment, textDirectiveDelimiter)
+ sel, _ := cssSelectorFragment(fragment)
+ return sel
+}
+
func refTextFragments(ref url.URL) []TextFragment {
if ref == nil {
return nil
diff --git a/pkg/guidednavigation/fragments_test.go b/pkg/guidednavigation/fragments_test.go
index 4e281907..0fa8fe95 100644
--- a/pkg/guidednavigation/fragments_test.go
+++ b/pkg/guidednavigation/fragments_test.go
@@ -200,6 +200,8 @@ func TestTextFragmentID(t *testing.T) {
{"chapter1.html#id:~:text=highlighted", "id"},
{"chapter1.html#:~:text=highlighted", ""},
{"chapter1.html", ""},
+ // A css() fragment is a selector, not an id
+ {"chapter1.html#css(body%20%3E%20p:nth-child(3))", ""},
}
for _, tt := range tests {
obj := GuidedNavigationObject{TextRef: url.MustURLFromString(tt.ref)}
@@ -208,6 +210,28 @@ func TestTextFragmentID(t *testing.T) {
assert.Empty(t, GuidedNavigationObject{}.TextFragmentID())
}
+func TestTextCSSSelector(t *testing.T) {
+ tests := []struct {
+ ref string
+ selector string
+ }{
+ {"chapter1.html#css(body%20%3E%20p:nth-child(3))", "body > p:nth-child(3)"},
+ {"chapter1.html#css(%23s2%20%3E%20p)", "#s2 > p"},
+ {"chapter1.html#css(body%20%3E%20img)", "body > img"},
+ {"chapter1.html#start", ""},
+ {"chapter1.html#css(unterminated", ""},
+ {"chapter1.html", ""},
+ }
+ for _, tt := range tests {
+ obj := GuidedNavigationObject{TextRef: url.MustURLFromString(tt.ref)}
+ assert.Equal(t, tt.selector, obj.TextCSSSelector(), tt.ref)
+ desc := GuidedNavigationDescription{TextRef: url.MustURLFromString(tt.ref)}
+ assert.Equal(t, tt.selector, desc.TextCSSSelector(), tt.ref)
+ }
+ assert.Empty(t, GuidedNavigationObject{}.TextCSSSelector())
+ assert.Empty(t, GuidedNavigationDescription{}.TextCSSSelector())
+}
+
func TestTextFragments(t *testing.T) {
// Example from the specification's README
obj := GuidedNavigationObject{TextRef: url.MustURLFromString(
diff --git a/pkg/internal/util/css.go b/pkg/internal/util/css.go
index e665f313..8044b4ed 100644
--- a/pkg/internal/util/css.go
+++ b/pkg/internal/util/css.go
@@ -7,7 +7,6 @@ import (
"github.com/agext/regexp"
"github.com/andybalholm/cascadia"
- "github.com/pkg/errors"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
@@ -101,11 +100,15 @@ func CSSSelector(n *html.Node) string {
return selector.String()
}
- s, err := cascadia.Parse(selector.String())
- if err != nil {
- panic(errors.Wrap(err, "failed parsing generated CSS selector"))
+ // Check whether the selector needs disambiguation among the parent's contents.
+ // When the rudimentary escaping above produces a selector cascadia fails to
+ // parse, matches cannot be counted: conservatively pin the element's position,
+ // a redundant nth-child is still correct.
+ ambiguous := true
+ if s, err := cascadia.Parse(selector.String()); err == nil {
+ ambiguous = len(cascadia.QueryAll(n.Parent, s)) > 1
}
- if nodes := cascadia.QueryAll(n.Parent, s); len(nodes) > 1 {
+ if ambiguous {
// Figure out the index of this node among its siblings
idx := 1
for ps := n.PrevSibling; ps != nil; ps = ps.PrevSibling {
diff --git a/pkg/internal/util/css_test.go b/pkg/internal/util/css_test.go
index edbd7aa8..09f2331a 100644
--- a/pkg/internal/util/css_test.go
+++ b/pkg/internal/util/css_test.go
@@ -48,3 +48,50 @@ func TestCSSSelector(t *testing.T) {
assert.Equal(t, qf("#pgepubid00498 > p:nth-child(5)"), "#pgepubid00498 > p:nth-child(5)")
assert.Equal(t, qf("#pgepubid00498 > p:nth-child(4) > span"), "#pgepubid00498 > p:nth-child(4) > span")
}
+
+// Hostile identifiers must never panic the selector generator, and the escaped
+// selectors should round-trip: parsing them back finds exactly the source element.
+func TestCSSSelectorHostileIdentifiers(t *testing.T) {
+ docs := []string{
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ `x
`,
+ }
+ for _, doc := range docs {
+ root, err := html.Parse(strings.NewReader(doc))
+ require.NoError(t, err)
+
+ // The target is the first (or unknown prefixed element) in the doc
+ var target *html.Node
+ var walk func(*html.Node)
+ walk = func(n *html.Node) {
+ if n.Type == html.ElementNode && (n.Data == "p" || strings.Contains(n.Data, ":")) {
+ target = n
+ return
+ }
+ for c := n.FirstChild; c != nil && target == nil; c = c.NextSibling {
+ walk(c)
+ }
+ }
+ walk(root)
+ require.NotNil(t, target, doc)
+
+ sel := CSSSelector(target)
+ assert.NotEmpty(t, sel, doc)
+
+ s, err := cascadia.Parse(sel)
+ if !assert.NoError(t, err, "generated selector %q should parse", sel) {
+ continue
+ }
+ assert.Equal(t, target, cascadia.Query(root, s), "%q should round-trip", sel)
+ }
+}
diff --git a/pkg/manifest/encryption.go b/pkg/manifest/encryption.go
index ce60c148..2d8b9977 100644
--- a/pkg/manifest/encryption.go
+++ b/pkg/manifest/encryption.go
@@ -27,9 +27,9 @@ func EncryptionFromJSON(rawJson map[string]interface{}) (*Encryption, error) {
e := new(Encryption)
e.Algorithm = algorithm
e.Compression = parseOptString(rawJson["compression"])
- e.OriginalLength = int64(parseOptFloat64(rawJson["originalLength"]))
+ e.OriginalLength = parseOptInt64(rawJson["originalLength"])
if e.OriginalLength == 0 {
- e.OriginalLength = int64(parseOptFloat64(rawJson["original-length"]))
+ e.OriginalLength = parseOptInt64(rawJson["original-length"])
}
e.Profile = parseOptString(rawJson["profile"])
e.Scheme = parseOptString(rawJson["scheme"])
diff --git a/pkg/manifest/utils.go b/pkg/manifest/utils.go
index 71ee35cc..68f72c6d 100644
--- a/pkg/manifest/utils.go
+++ b/pkg/manifest/utils.go
@@ -95,6 +95,22 @@ func parseOptFloat64(raw interface{}) float64 {
return rb
}
+// parseOptInt64 reads an integer that may come from JSON (float64) or from
+// in-memory maps built with native integer types.
+func parseOptInt64(raw interface{}) int64 {
+ switch v := raw.(type) {
+ case float64:
+ return int64(v)
+ case int64:
+ return v
+ case int:
+ return int64(v)
+ case uint64:
+ return int64(v)
+ }
+ return 0
+}
+
func float64ToUint(f float64) uint {
if f < 0 {
return 0
diff --git a/pkg/parser/epub/deobfuscator.go b/pkg/parser/epub/deobfuscator.go
index 792db485..19f7ab13 100644
--- a/pkg/parser/epub/deobfuscator.go
+++ b/pkg/parser/epub/deobfuscator.go
@@ -63,6 +63,12 @@ func (d DeobfuscatingResource) Read(ctx context.Context, start, end int64) ([]by
shasum := sha1.Sum([]byte(d.identifier))
obfuscationKey = shasum[:]
}
+
+ // If getHashKeyAdobe() is blank, meaning the hex decoding of the UUID failed
+ if len(obfuscationKey) == 0 {
+ return nil, fetcher.Other(errors.New("error deriving font deobfuscation key"))
+ }
+
deobfuscateFont(data, start, obfuscationKey, v)
return data, nil
}
@@ -231,7 +237,7 @@ func (d DeobfuscatingResource) getHashKeyAdobe() []byte {
}
func deobfuscateFont(data []byte, start int64, obfuscationKey []byte, obfuscationLength int64) {
- if start >= obfuscationLength {
+ if start >= obfuscationLength || len(obfuscationKey) == 0 {
return
}
max := obfuscationLength - start
diff --git a/pkg/parser/epub/media_overlay_service.go b/pkg/parser/epub/media_overlay_service.go
index 656ff735..f3f29051 100644
--- a/pkg/parser/epub/media_overlay_service.go
+++ b/pkg/parser/epub/media_overlay_service.go
@@ -7,166 +7,142 @@ import (
"github.com/pkg/errors"
"github.com/readium/go-toolkit/pkg/fetcher"
"github.com/readium/go-toolkit/pkg/guidednavigation"
- "github.com/readium/go-toolkit/pkg/guidednavigation/converter"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/mediatype"
"github.com/readium/go-toolkit/pkg/pub"
)
+// MediaOverlayFactory creates a service converting the SMIL media overlays of an
+// EPUB/WebPub publication into guided navigation documents. Each SMIL alternate in
+// the manifest is replaced with an expansion of [pub.MediaOverlayLink] referencing
+// its resource — those swapped alternates are the only way the service surfaces in
+// the manifest, it is never advertised in the manifest's links. It does not fall
+// back to converting (X)HTML content (see [pub.HTMLGuidedNavigationServiceFactory]
+// for that). When the publication has no SMIL alternates, no service is created.
func MediaOverlayFactory() pub.ServiceFactory {
return func(context pub.Context, public bool) pub.Service {
- // Process reading order to find and replace SMIL alternates
smilMap := make(map[string]manifest.Link)
- htmlMap := make(map[string]manifest.Link)
- var guideIndexes []string
- for i := range context.Manifest.ReadingOrder {
- href := context.Manifest.ReadingOrder[i].Href.String()
- hasGuide := false
-
- alts := context.Manifest.ReadingOrder[i].Alternates
- for j := range alts {
- alt := context.Manifest.ReadingOrder[i].Alternates[j]
- if alt.MediaType.Equal(&mediatype.SMIL) {
- // SMIL alternate for reading order item found
-
- // Create a guided navigation link for the SMIL alt
- gnLink := pub.GuidedNavigationLink
- gnLink.Href = manifest.NewHREF(gnLink.URL(nil,
- map[string]string{
- "ref": href,
- },
- ))
-
- // Store the original SMIL alt in an internal map
- smilMap[href] = alt
- hasGuide = true
-
- // Swap the original SMIL alt with the new guided navigation link
- alts = append(append(alts[:j], gnLink), alts[j+1:]...)
- }
- }
- if !hasGuide {
- if mt := context.Manifest.ReadingOrder[i].MediaType; mt != nil && mt.IsHTML() {
- // No SMIL alternate, but a guided navigation document can still
- // be generated from the (X)HTML resource's content
- htmlMap[href] = context.Manifest.ReadingOrder[i]
- hasGuide = true
+ // Find and replace SMIL alternates with media overlay links
+ process := func(link *manifest.Link) (hasOverlay bool) {
+ href := link.Href.String()
+ for j := range link.Alternates {
+ alt := link.Alternates[j]
+ if alt.MediaType == nil || !alt.MediaType.Equal(&mediatype.SMIL) {
+ continue
}
+ // SMIL alternate for the item found
+
+ // Create a media overlay link for the SMIL alt
+ moLink := pub.MediaOverlayLink
+ moLink.Href = manifest.NewHREF(moLink.URL(nil,
+ map[string]string{
+ "ref": href,
+ },
+ ))
+
+ // Store the original SMIL alt in an internal map
+ smilMap[href] = alt
+ hasOverlay = true
+
+ // Swap the original SMIL alt with the new media overlay link
+ link.Alternates[j] = moLink
}
- if hasGuide {
- guideIndexes = append(guideIndexes, href)
+ return hasOverlay
+ }
+
+ // Only reading order items are chained through next/prev links
+ var guideIndexes []string
+ for i := range context.Manifest.ReadingOrder {
+ if process(&context.Manifest.ReadingOrder[i]) {
+ guideIndexes = append(guideIndexes, context.Manifest.ReadingOrder[i].Href.String())
}
}
- if len(guideIndexes) == 0 {
- // No items anyway, don't set up service
+ for i := range context.Manifest.Resources {
+ process(&context.Manifest.Resources[i])
+ }
+ if len(smilMap) == 0 {
+ // No media overlays anyway, don't set up service
return nil
}
return &MediaOverlayService{
- fetcher: context.Fetcher,
- originalSmilAlternates: smilMap,
- htmlResources: htmlMap,
- guideIndexes: guideIndexes,
- public: public,
+ fetcher: context.Fetcher,
+ smilAlternates: smilMap,
+ guideIndexes: guideIndexes,
}
}
}
-// MediaOverlayService provides guided navigation documents for a publication's
-// reading order: from a SMIL media overlay when the resource has one, otherwise
-// by converting the (X)HTML resource's content.
+// MediaOverlayService converts the SMIL media overlays of a publication's reading
+// order and resources into guided navigation documents.
type MediaOverlayService struct {
- public bool
- fetcher fetcher.Fetcher
- originalSmilAlternates map[string]manifest.Link
- htmlResources map[string]manifest.Link
- guideIndexes []string
+ fetcher fetcher.Fetcher
+ smilAlternates map[string]manifest.Link
+ guideIndexes []string
// TODO: smil parsing cache
}
func (s *MediaOverlayService) Close() {
- clear(s.originalSmilAlternates)
- clear(s.htmlResources)
+ clear(s.smilAlternates)
clear(s.guideIndexes)
}
func (s *MediaOverlayService) Links() manifest.LinkList {
- if !s.public {
- return nil
- }
- return manifest.LinkList{pub.GuidedNavigationLink}
+ // The service is only surfaced through the swapped alternate links
+ return nil
}
func (s *MediaOverlayService) HasGuideForResource(href string) bool {
- if _, ok := s.originalSmilAlternates[href]; ok {
- return true
- }
- _, ok := s.htmlResources[href]
+ _, ok := s.smilAlternates[href]
return ok
}
func (s *MediaOverlayService) GuideForResource(ctx context.Context, href string) (*guidednavigation.GuidedNavigationDocument, error) {
- var doc *guidednavigation.GuidedNavigationDocument
- if link, ok := s.originalSmilAlternates[href]; ok {
- // The resource has a SMIL media overlay
- res := s.fetcher.Get(ctx, link)
- defer res.Close()
-
- n, rerr := fetcher.ReadResourceAsXML(ctx, res)
- if rerr != nil {
- return nil, rerr.Cause
- }
+ link, ok := s.smilAlternates[href]
+ if !ok {
+ return nil, errors.New("resource has no media overlay")
+ }
+ res := s.fetcher.Get(ctx, link)
+ defer res.Close()
- // Convert SMIL to guided navigation document
- var err error
- doc, err = ParseSMILDocument(n, link.URL(nil, nil))
- if err != nil {
- return nil, err
- }
- } else if link, ok := s.htmlResources[href]; ok {
- // Fall back to converting the (X)HTML resource's content
- res := s.fetcher.Get(ctx, link)
- defer res.Close()
-
- var err error
- doc, err = converter.Do(ctx, res, manifest.Locator{
- Href: link.URL(nil, nil),
- MediaType: *link.MediaType,
- Title: link.Title,
- })
- if err != nil {
- return nil, err
- }
- } else {
- return nil, errors.New("resource cannot be converted to a guided navigation document")
+ n, rerr := fetcher.ReadResourceAsXML(ctx, res)
+ if rerr != nil {
+ return nil, rerr.Cause
}
- // Find the next and previous guided navigation docs in the readingOrder
- // Then enhance the document with additional next/prev links
- idx := slices.Index(s.guideIndexes, href)
- if idx > 0 {
- l := pub.GuidedNavigationLink
- l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{
- "ref": s.guideIndexes[idx-1],
- }))
- l.Rels = append(l.Rels, "prev")
- doc.Links = append(doc.Links, l)
+ // Convert SMIL to guided navigation document
+ doc, err := ParseSMILDocument(n, link.URL(nil, nil))
+ if err != nil {
+ return nil, err
}
- if idx < len(s.guideIndexes)-1 {
- l := pub.GuidedNavigationLink
- l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{
- "ref": s.guideIndexes[idx+1],
- }))
- l.Rels = append(l.Rels, "next")
- doc.Links = append(doc.Links, l)
+
+ // Find the next and previous media overlays in the readingOrder
+ // Then enhance the document with additional next/prev links.
+ // Resources outside the reading order aren't part of the chain
+ if idx := slices.Index(s.guideIndexes, href); idx >= 0 {
+ if idx > 0 {
+ l := pub.MediaOverlayLink
+ l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{
+ "ref": s.guideIndexes[idx-1],
+ }))
+ l.Rels = append(l.Rels, "prev")
+ doc.Links = append(doc.Links, l)
+ }
+ if idx < len(s.guideIndexes)-1 {
+ l := pub.MediaOverlayLink
+ l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{
+ "ref": s.guideIndexes[idx+1],
+ }))
+ l.Rels = append(l.Rels, "next")
+ doc.Links = append(doc.Links, l)
+ }
}
return doc, nil
}
func (s *MediaOverlayService) Get(ctx context.Context, link manifest.Link) (fetcher.Resource, bool) {
- if !s.public {
- return nil, false
- }
- return pub.GetForGuidedNavigationService(ctx, s, link)
+ // Not gated on the service being public: the swapped alternate links are in
+ // the manifest unconditionally, so they must always be servable
+ return pub.GetForMediaOverlayService(ctx, s, link)
}
diff --git a/pkg/parser/epub/media_overlay_service_test.go b/pkg/parser/epub/media_overlay_service_test.go
new file mode 100644
index 00000000..2aab902f
--- /dev/null
+++ b/pkg/parser/epub/media_overlay_service_test.go
@@ -0,0 +1,117 @@
+package epub
+
+import (
+ "context"
+ "testing"
+
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/readium/go-toolkit/pkg/mediatype"
+ "github.com/readium/go-toolkit/pkg/pub"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// An in-memory fetcher serving string documents by href.
+type stringFetcher map[string]string
+
+func (f stringFetcher) Links(ctx context.Context) (manifest.LinkList, error) {
+ return nil, nil
+}
+
+func (f stringFetcher) Get(ctx context.Context, link manifest.Link) fetcher.Resource {
+ doc, ok := f[link.Href.String()]
+ if !ok {
+ return fetcher.NewFailureResource(link, fetcher.NotFound(nil))
+ }
+ return fetcher.NewBytesResource(link, func() []byte {
+ return []byte(doc)
+ })
+}
+
+func (f stringFetcher) Close() {}
+
+func htmlLink(href string, alternates ...manifest.Link) manifest.Link {
+ return manifest.Link{
+ Href: manifest.MustNewHREFFromString(href, false),
+ MediaType: &mediatype.XHTML,
+ Alternates: alternates,
+ }
+}
+
+func smilAlt(href string) manifest.Link {
+ return manifest.Link{
+ Href: manifest.MustNewHREFFromString(href, false),
+ MediaType: &mediatype.SMIL,
+ }
+}
+
+const moTestSmil = `
+
+
+`
+
+// The media overlay service only covers resources with SMIL alternates: it swaps
+// them for media overlay links, stays out of the manifest's links, and does not
+// fall back to converting (X)HTML content.
+func TestMediaOverlayServiceSMILOnly(t *testing.T) {
+ m := manifest.Manifest{
+ ReadingOrder: manifest.LinkList{htmlLink("a.xhtml", smilAlt("a.smil")), htmlLink("b.xhtml")},
+ }
+ f := stringFetcher{"a.smil": moTestSmil}
+ service, ok := MediaOverlayFactory()(pub.NewContext(m, f), false).(*MediaOverlayService)
+ require.True(t, ok)
+
+ assert.True(t, service.HasGuideForResource("a.xhtml"))
+ // No fallback to HTML conversion
+ assert.False(t, service.HasGuideForResource("b.xhtml"))
+ // Never advertised in the manifest's links
+ assert.Empty(t, service.Links())
+
+ // The SMIL alternate was swapped for a media overlay link
+ require.Len(t, m.ReadingOrder[0].Alternates, 1)
+ assert.Equal(t, "~readium/media-overlay.json?ref=a.xhtml", m.ReadingOrder[0].Alternates[0].Href.String())
+
+ doc, err := service.GuideForResource(context.Background(), "a.xhtml")
+ require.NoError(t, err)
+ require.NotEmpty(t, doc.Guided)
+ assert.Equal(t, "a.xhtml#p1", doc.Guided[0].TextRef.String())
+ // The only overlay in the reading order: no next/prev chain
+ assert.Empty(t, doc.Links)
+
+ // The swapped alternate links must be servable even when the service isn't public
+ res, ok := service.Get(context.Background(), manifest.Link{
+ Href: manifest.MustNewHREFFromString("~readium/media-overlay.json?ref=a.xhtml", false),
+ })
+ require.True(t, ok)
+ data, rerr := res.Read(context.Background(), 0, 0)
+ require.Nil(t, rerr)
+ assert.NotEmpty(t, data)
+}
+
+// Overlays chain through next/prev media overlay links along the reading order.
+func TestMediaOverlayServiceChain(t *testing.T) {
+ m := manifest.Manifest{
+ ReadingOrder: manifest.LinkList{
+ htmlLink("a.xhtml", smilAlt("a.smil")),
+ htmlLink("b.xhtml", smilAlt("b.smil")),
+ },
+ }
+ f := stringFetcher{"a.smil": moTestSmil, "b.smil": moTestSmil}
+ service, ok := MediaOverlayFactory()(pub.NewContext(m, f), false).(*MediaOverlayService)
+ require.True(t, ok)
+
+ doc, err := service.GuideForResource(context.Background(), "a.xhtml")
+ require.NoError(t, err)
+ require.Len(t, doc.Links, 1)
+ assert.Contains(t, doc.Links[0].Rels, "next")
+ assert.Equal(t, "~readium/media-overlay.json?ref=b.xhtml", doc.Links[0].Href.String())
+}
+
+// Without SMIL alternates there is no media overlay service at all.
+func TestMediaOverlayServiceAbsentWithoutSMIL(t *testing.T) {
+ m := manifest.Manifest{
+ ReadingOrder: manifest.LinkList{htmlLink("a.xhtml"), htmlLink("b.xhtml")},
+ }
+ assert.Nil(t, MediaOverlayFactory()(pub.NewContext(m, stringFetcher{}), false))
+}
diff --git a/pkg/parser/epub/parser.go b/pkg/parser/epub/parser.go
index c29a14b8..3049a550 100644
--- a/pkg/parser/epub/parser.go
+++ b/pkg/parser/epub/parser.go
@@ -86,7 +86,8 @@ func (p Parser) Parse(ctx context.Context, asset asset.PublicationAsset, f fetch
// pub.ContentService_Name: pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{
// iterator.HTMLFactory(),
// }),
- pub.GuidedNavigationService_Name: MediaOverlayFactory(),
+ pub.GuidedNavigationService_Name: pub.HTMLGuidedNavigationServiceFactory(),
+ pub.MediaOverlayService_Name: MediaOverlayFactory(),
})
return pub.NewBuilder(manifest, ffetcher, builder), nil
}
diff --git a/pkg/parser/webpub/parser.go b/pkg/parser/webpub/parser.go
index 35dc1954..e5e98bab 100644
--- a/pkg/parser/webpub/parser.go
+++ b/pkg/parser/webpub/parser.go
@@ -157,18 +157,15 @@ func (p WebPubParser) Parse(ctx context.Context, a asset.PublicationAsset, f fet
serviceFactories[pub.PositionsService_Name] = epub.PositionsServiceFactory(nil)
}
- // Add guided navigation service for WebPubs with HTML contents. It serves SMIL
- // media overlays when reading order items have them, and converts the HTML
- // resources themselves otherwise. It replaces the content service:
+ // Guided navigation for (X)HTML contents in the reading order or the resources,
+ // and media overlays for items with SMIL alternates. Both factories create no
+ // service when the publication has nothing for them. The guided navigation
+ // service replaces the content service:
// serviceFactories[pub.ContentService_Name] = pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{
// iterator.HTMLFactory(),
// })
- for _, link := range m.ReadingOrder {
- if link.MediaType != nil && link.MediaType.IsHTML() {
- serviceFactories[pub.GuidedNavigationService_Name] = epub.MediaOverlayFactory()
- break
- }
- }
+ serviceFactories[pub.GuidedNavigationService_Name] = pub.HTMLGuidedNavigationServiceFactory()
+ serviceFactories[pub.MediaOverlayService_Name] = epub.MediaOverlayFactory()
var servicesBuilder *pub.ServicesBuilder
if len(serviceFactories) > 0 {
diff --git a/pkg/parser/webpub/parser_test.go b/pkg/parser/webpub/parser_test.go
index 98acf652..64d329e2 100644
--- a/pkg/parser/webpub/parser_test.go
+++ b/pkg/parser/webpub/parser_test.go
@@ -474,6 +474,38 @@ func TestParseServiceFactories(t *testing.T) {
assert.NotNil(t, builder.ServicesBuilder.Get(pub.GuidedNavigationService_Name))
}
+// HTML documents among the resources are enough to get a guided navigation service,
+// even when the reading order has none. Without SMIL alternates there is no media
+// overlay service.
+func TestParseGuidedNavigationForHTMLResources(t *testing.T) {
+ dir := t.TempDir()
+ manifestJSON := `{
+ "@context": "https://readium.org/webpub-manifest/context.jsonld",
+ "metadata": {
+ "conformsTo": "https://readium.org/webpub-manifest/profiles/audiobook",
+ "title": "Audiobook with sync text",
+ "duration": 60
+ },
+ "links": [],
+ "readingOrder": [
+ {"href": "track1.mp3", "type": "audio/mpeg", "duration": 60}
+ ],
+ "resources": [
+ {"href": "sync.xhtml", "type": "application/xhtml+xml"}
+ ]
+ }`
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifestJSON), 0o644))
+
+ builder, err := parseFileAsset(t, nil, filepath.ToSlash(filepath.Join(dir, "manifest.json")), &mediatype.ReadiumWebpubManifest)
+ require.NoError(t, err)
+ require.NotNil(t, builder)
+
+ p := builder.Build()
+ defer p.Close()
+ assert.NotNil(t, p.FindService(pub.GuidedNavigationService_Name))
+ assert.Nil(t, p.FindService(pub.MediaOverlayService_Name))
+}
+
// The parser must not swallow assets which aren't WebPub flavored.
func TestParseSkipsOtherMediaTypes(t *testing.T) {
builder, err := parseFileAsset(t, nil, "testdata/audio/manifest.json", &mediatype.JSON)
diff --git a/pkg/pub/service.go b/pkg/pub/service.go
index b71f3097..250de69a 100644
--- a/pkg/pub/service.go
+++ b/pkg/pub/service.go
@@ -17,6 +17,7 @@ const (
SearchService_Name ServiceName = "SearchService"
ContentService_Name ServiceName = "ContentService"
GuidedNavigationService_Name ServiceName = "GuidedNavigationService"
+ MediaOverlayService_Name ServiceName = "MediaOverlayService"
)
// Base interface to be implemented by all publication services.
diff --git a/pkg/pub/service_guided_navigation.go b/pkg/pub/service_guided_navigation.go
index c4f5a4ca..36d8292d 100644
--- a/pkg/pub/service_guided_navigation.go
+++ b/pkg/pub/service_guided_navigation.go
@@ -12,16 +12,30 @@ import (
"github.com/readium/go-toolkit/pkg/util/url"
)
+// GuidedNavigationLink is the templated link of the guided navigation service,
+// which generates guided navigation documents from the publication's (X)HTML
+// documents. It is advertised in the manifest's links when the service is public.
var GuidedNavigationLink = manifest.Link{
Href: manifest.MustNewHREFFromString("~readium/guided-navigation.json{?ref}", true),
MediaType: &mediatype.ReadiumGuidedNavigationDocument,
}
-// Pre-cached value of the guided navigation link's path
+// MediaOverlayLink is the templated link of the media overlay service, which
+// converts a resource's SMIL media overlay into a guided navigation document.
+// It is never advertised in the manifest's links: the service replaces each SMIL
+// alternate with an expansion of this link instead.
+var MediaOverlayLink = manifest.Link{
+ Href: manifest.MustNewHREFFromString("~readium/media-overlay.json{?ref}", true),
+ MediaType: &mediatype.ReadiumGuidedNavigationDocument,
+}
+
+// Pre-cached values of the service links' paths
var resolvedGuidedNavigation url.URL
+var resolvedMediaOverlay url.URL
func init() {
resolvedGuidedNavigation = GuidedNavigationLink.URL(nil, nil)
+ resolvedMediaOverlay = MediaOverlayLink.URL(nil, nil)
}
// GuidedNavigationService implements Service
@@ -32,11 +46,21 @@ type GuidedNavigationService interface {
HasGuideForResource(href string) bool
}
+// GetForGuidedNavigationService serves a [GuidedNavigationLink] expansion from the given service.
func GetForGuidedNavigationService(ctx context.Context, service GuidedNavigationService, link manifest.Link) (fetcher.Resource, bool) {
+ return getForGuideService(ctx, service, link, GuidedNavigationLink, resolvedGuidedNavigation)
+}
+
+// GetForMediaOverlayService serves a [MediaOverlayLink] expansion from the given service.
+func GetForMediaOverlayService(ctx context.Context, service GuidedNavigationService, link manifest.Link) (fetcher.Resource, bool) {
+ return getForGuideService(ctx, service, link, MediaOverlayLink, resolvedMediaOverlay)
+}
+
+func getForGuideService(ctx context.Context, service GuidedNavigationService, link manifest.Link, template manifest.Link, resolved url.URL) (fetcher.Resource, bool) {
u := link.URL(nil, nil)
- if u.Path() != resolvedGuidedNavigation.Path() {
- // Not the guided navigation link
+ if u.Path() != resolved.Path() {
+ // Not the service's link
return nil, false
}
@@ -48,8 +72,8 @@ func GetForGuidedNavigationService(ctx context.Context, service GuidedNavigation
return nil, false
}
- // Overrride the link's href with the expanded guided navigation link
- expandedHref := GuidedNavigationLink.URL(nil, map[string]string{
+ // Overrride the link's href with the expanded service link
+ expandedHref := template.URL(nil, map[string]string{
"ref": ref,
})
link.Href = manifest.NewHREF(expandedHref)
diff --git a/pkg/pub/service_guided_navigation_html.go b/pkg/pub/service_guided_navigation_html.go
new file mode 100644
index 00000000..dc9deb88
--- /dev/null
+++ b/pkg/pub/service_guided_navigation_html.go
@@ -0,0 +1,127 @@
+package pub
+
+import (
+ "context"
+ "slices"
+
+ "github.com/pkg/errors"
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/guidednavigation"
+ "github.com/readium/go-toolkit/pkg/guidednavigation/converter"
+ "github.com/readium/go-toolkit/pkg/manifest"
+)
+
+// HTMLGuidedNavigationServiceFactory creates a service generating guided navigation
+// documents from the (X)HTML documents of a publication's reading order and
+// resources. It never touches SMIL media overlays (see the EPUB parser's media
+// overlay service for those) and adds nothing to the manifest besides its own
+// templated link when public. The options are passed to the HTML converter, e.g.
+// [converter.WithTextRefLocators]. When the publication has no (X)HTML documents,
+// no service is created.
+func HTMLGuidedNavigationServiceFactory(opts ...converter.Option) ServiceFactory {
+ return func(context Context, public bool) Service {
+ htmlMap := make(map[string]manifest.Link)
+
+ // Only reading order items are chained through next/prev links
+ var guideIndexes []string
+ for _, link := range context.Manifest.ReadingOrder {
+ if mt := link.MediaType; mt != nil && mt.IsHTML() {
+ href := link.Href.String()
+ htmlMap[href] = link
+ guideIndexes = append(guideIndexes, href)
+ }
+ }
+ for _, link := range context.Manifest.Resources {
+ if mt := link.MediaType; mt != nil && mt.IsHTML() {
+ htmlMap[link.Href.String()] = link
+ }
+ }
+ if len(htmlMap) == 0 {
+ // No convertible documents, don't set up service
+ return nil
+ }
+
+ return &HTMLGuidedNavigationService{
+ fetcher: context.Fetcher,
+ htmlResources: htmlMap,
+ guideIndexes: guideIndexes,
+ converterOptions: opts,
+ public: public,
+ }
+ }
+}
+
+// HTMLGuidedNavigationService provides guided navigation documents generated from
+// the (X)HTML documents of a publication's reading order and resources.
+type HTMLGuidedNavigationService struct {
+ public bool
+ fetcher fetcher.Fetcher
+ htmlResources map[string]manifest.Link
+ guideIndexes []string
+ converterOptions []converter.Option
+}
+
+func (s *HTMLGuidedNavigationService) Close() {
+ clear(s.htmlResources)
+ clear(s.guideIndexes)
+}
+
+func (s *HTMLGuidedNavigationService) Links() manifest.LinkList {
+ if !s.public {
+ return nil
+ }
+ return manifest.LinkList{GuidedNavigationLink}
+}
+
+func (s *HTMLGuidedNavigationService) HasGuideForResource(href string) bool {
+ _, ok := s.htmlResources[href]
+ return ok
+}
+
+func (s *HTMLGuidedNavigationService) GuideForResource(ctx context.Context, href string) (*guidednavigation.GuidedNavigationDocument, error) {
+ link, ok := s.htmlResources[href]
+ if !ok {
+ return nil, errors.New("resource cannot be converted to a guided navigation document")
+ }
+ res := s.fetcher.Get(ctx, link)
+ defer res.Close()
+
+ doc, err := converter.Do(ctx, res, manifest.Locator{
+ Href: link.URL(nil, nil),
+ MediaType: *link.MediaType,
+ Title: link.Title,
+ }, s.converterOptions...)
+ if err != nil {
+ return nil, err
+ }
+
+ // Find the next and previous guided navigation docs in the readingOrder
+ // Then enhance the document with additional next/prev links.
+ // Resources outside the reading order aren't part of the chain
+ if idx := slices.Index(s.guideIndexes, href); idx >= 0 {
+ if idx > 0 {
+ l := GuidedNavigationLink
+ l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{
+ "ref": s.guideIndexes[idx-1],
+ }))
+ l.Rels = append(l.Rels, "prev")
+ doc.Links = append(doc.Links, l)
+ }
+ if idx < len(s.guideIndexes)-1 {
+ l := GuidedNavigationLink
+ l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{
+ "ref": s.guideIndexes[idx+1],
+ }))
+ l.Rels = append(l.Rels, "next")
+ doc.Links = append(doc.Links, l)
+ }
+ }
+ return doc, nil
+}
+
+func (s *HTMLGuidedNavigationService) Get(ctx context.Context, link manifest.Link) (fetcher.Resource, bool) {
+ if !s.public {
+ return nil, false
+ }
+ return GetForGuidedNavigationService(ctx, s, link)
+}
diff --git a/pkg/pub/service_guided_navigation_html_test.go b/pkg/pub/service_guided_navigation_html_test.go
new file mode 100644
index 00000000..b48cce4e
--- /dev/null
+++ b/pkg/pub/service_guided_navigation_html_test.go
@@ -0,0 +1,138 @@
+package pub
+
+import (
+ "context"
+ "testing"
+
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/guidednavigation"
+ "github.com/readium/go-toolkit/pkg/guidednavigation/converter"
+ "github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/readium/go-toolkit/pkg/mediatype"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// An in-memory fetcher serving string documents by href.
+type stringFetcher map[string]string
+
+func (f stringFetcher) Links(ctx context.Context) (manifest.LinkList, error) {
+ return nil, nil
+}
+
+func (f stringFetcher) Get(ctx context.Context, link manifest.Link) fetcher.Resource {
+ doc, ok := f[link.Href.String()]
+ if !ok {
+ return fetcher.NewFailureResource(link, fetcher.NotFound(nil))
+ }
+ return fetcher.NewBytesResource(link, func() []byte {
+ return []byte(doc)
+ })
+}
+
+func (f stringFetcher) Close() {}
+
+func gnHTMLLink(href string, alternates ...manifest.Link) manifest.Link {
+ return manifest.Link{
+ Href: manifest.MustNewHREFFromString(href, false),
+ MediaType: &mediatype.XHTML,
+ Alternates: alternates,
+ }
+}
+
+const gnTestDoc = `
+
+
Test
+Hello
world
+`
+
+// The guided navigation service converts (X)HTML documents of the reading order
+// and resources, leaves SMIL alternates alone, and does not touch the manifest.
+func TestHTMLGuidedNavigationService(t *testing.T) {
+ smilAlt := manifest.Link{
+ Href: manifest.MustNewHREFFromString("a.smil", false),
+ MediaType: &mediatype.SMIL,
+ }
+ m := manifest.Manifest{
+ ReadingOrder: manifest.LinkList{
+ gnHTMLLink("a.xhtml", smilAlt),
+ {Href: manifest.MustNewHREFFromString("audio/a.mp3", false), MediaType: &mediatype.MPEGAudio},
+ gnHTMLLink("b.xhtml"),
+ },
+ Resources: manifest.LinkList{gnHTMLLink("cover.xhtml")},
+ }
+ f := stringFetcher{"a.xhtml": gnTestDoc, "b.xhtml": gnTestDoc, "cover.xhtml": gnTestDoc}
+ service, ok := HTMLGuidedNavigationServiceFactory()(NewContext(m, f), false).(*HTMLGuidedNavigationService)
+ require.True(t, ok)
+
+ assert.True(t, service.HasGuideForResource("a.xhtml"))
+ assert.True(t, service.HasGuideForResource("cover.xhtml"))
+ assert.False(t, service.HasGuideForResource("audio/a.mp3"))
+
+ // The manifest is untouched: the SMIL alternate is still there
+ require.Len(t, m.ReadingOrder[0].Alternates, 1)
+ assert.Equal(t, "a.smil", m.ReadingOrder[0].Alternates[0].Href.String())
+
+ // A document with a SMIL overlay is still converted from its (X)HTML content
+ doc, err := service.GuideForResource(context.Background(), "a.xhtml")
+ require.NoError(t, err)
+ require.NotEmpty(t, doc.Guided)
+ assert.Contains(t, doc.Guided[0].Role, guidednavigation.RoleBody)
+
+ // Reading order documents chain along the reading order's HTML items
+ require.Len(t, doc.Links, 1)
+ assert.Contains(t, doc.Links[0].Rels, "next")
+ assert.Equal(t, "~readium/guided-navigation.json?ref=b.xhtml", doc.Links[0].Href.String())
+
+ // Resources aren't part of the chain
+ doc, err = service.GuideForResource(context.Background(), "cover.xhtml")
+ require.NoError(t, err)
+ assert.Empty(t, doc.Links)
+}
+
+// The service link is advertised when public, and Get is gated on it.
+func TestHTMLGuidedNavigationServiceLinks(t *testing.T) {
+ m := manifest.Manifest{ReadingOrder: manifest.LinkList{gnHTMLLink("a.xhtml")}}
+ f := stringFetcher{"a.xhtml": gnTestDoc}
+ gnLink := manifest.Link{
+ Href: manifest.MustNewHREFFromString("~readium/guided-navigation.json?ref=a.xhtml", false),
+ }
+
+ private := HTMLGuidedNavigationServiceFactory()(NewContext(m, f), false)
+ assert.Empty(t, private.Links())
+ _, ok := private.Get(context.Background(), gnLink)
+ assert.False(t, ok)
+
+ public := HTMLGuidedNavigationServiceFactory()(NewContext(m, f), true)
+ assert.Equal(t, manifest.LinkList{GuidedNavigationLink}, public.Links())
+ res, ok := public.Get(context.Background(), gnLink)
+ require.True(t, ok)
+ data, rerr := res.Read(context.Background(), 0, 0)
+ require.Nil(t, rerr)
+ assert.NotEmpty(t, data)
+}
+
+// Converter options are threaded through, e.g. textref locators.
+func TestHTMLGuidedNavigationServiceConverterOptions(t *testing.T) {
+ m := manifest.Manifest{ReadingOrder: manifest.LinkList{gnHTMLLink("a.xhtml")}}
+ f := stringFetcher{"a.xhtml": gnTestDoc}
+ service, ok := HTMLGuidedNavigationServiceFactory(converter.WithTextRefLocators())(NewContext(m, f), false).(*HTMLGuidedNavigationService)
+ require.True(t, ok)
+
+ doc, err := service.GuideForResource(context.Background(), "a.xhtml")
+ require.NoError(t, err)
+ require.NotEmpty(t, doc.Guided)
+ require.NotEmpty(t, doc.Guided[0].Children)
+ assert.Equal(t, "body > p", doc.Guided[0].Children[0].TextCSSSelector())
+}
+
+// Without (X)HTML documents there is no guided navigation service at all.
+func TestHTMLGuidedNavigationServiceAbsentWithoutHTML(t *testing.T) {
+ m := manifest.Manifest{
+ ReadingOrder: manifest.LinkList{{
+ Href: manifest.MustNewHREFFromString("audio/a.mp3", false),
+ MediaType: &mediatype.MPEGAudio,
+ }},
+ }
+ assert.Nil(t, HTMLGuidedNavigationServiceFactory()(NewContext(m, stringFetcher{}), false))
+}