diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c0fb0..9b37030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. **Warning:** Features marked as *alpha* may change or be removed in a future release without notice. Use with caution. +## [0.9.1] - 2025-05-05 + +### Added + +- New config option available when creating a `Streamer`: `InferIgnoredImages`, a list of hashes of images to ignore when when inferring nonvisual reading +- `analyzer.MatchImage` function that compares an image link's hashes with given hashes to check for a match +- `HashValue` has new `String` and `Equal` convenience functions. `HashList` has a new `Find` convenience function. + +### Changed + +- Renamed `analyzer.Image` to `analyzer.InspectImage` +- Slight adjustments to behavior of manifest properties functions + ## [0.9.0] - 2025-04-30 ### Removed diff --git a/pkg/analyzer/image.go b/pkg/analyzer/image.go index b8f8e69..67267df 100644 --- a/pkg/analyzer/image.go +++ b/pkg/analyzer/image.go @@ -81,7 +81,7 @@ func (p *imageProperties) EnhanceLink(link *manifest.Link) { } hashes.Deduplicate() - link.Properties["hash"] = hashes + link.Properties["hash"] = hashes.ToJSONArray() link.Properties["animated"] = p.Animated } @@ -101,14 +101,31 @@ func hasVisualAlgorithm(hashes []manifest.HashAlgorithm) bool { return visualHash } -// Image inspects an image located in the provided filesystem, using the provided link's [manifest.HREF] +// InspectImage inspects an image located in the provided filesystem, using the provided link's [manifest.HREF] // as a path. Additional properties from the link, such as the [mediatype.MediaType], may be used, and should // be included. A copy of the provided link will be returned, with the `size`, `width`, `height` and // `properties.animated` attributes set. A slice of [manifest.HashAlgorithm] can be provided, in which case // the returned link will also have `properties.hash` set with the computed hashes. Currently, the supported // algorithms are: [manifest.HashAlgorithmSHA256], [manifest.HashAlgorithmMD5], [manifest.HashAlgorithmPhashDCT], // and `https://blurha.sh` (BlurHash). The latter two are visual hashes, which are more computationally expensive. -func Image(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm) (*manifest.Link, error) { +func InspectImage(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm) (*manifest.Link, error) { + + // Skip any supplied algorithms for hashes that have already been computed in the link properties + neededAlgorithms := make([]manifest.HashAlgorithm, 0, len(algorithms)) + existingHashes := link.Properties.Hash() + for _, algorithm := range algorithms { + exists := false + for _, hash := range existingHashes { + if hash.Algorithm == algorithm { + exists = true + break + } + } + if !exists && !slices.Contains(neededAlgorithms, algorithm) { + neededAlgorithms = append(neededAlgorithms, algorithm) + } + } + path := link.Href.String() file, err := system.Open(path) if err != nil { @@ -236,7 +253,7 @@ func Image(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm if err != nil { return nil, errors.Wrap(err, "failed reopening file") } - visualHash := hasVisualAlgorithm(algorithms) + visualHash := hasVisualAlgorithm(neededAlgorithms) hashVisually := func(img image.Image) { if !visualHash { return @@ -248,12 +265,12 @@ func Image(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm img = imaging.Resize(img, 128, 0, imaging.Lanczos) } - if slices.Contains(algorithms, manifest.HashAlgorithmPhashDCT) { + if slices.Contains(neededAlgorithms, manifest.HashAlgorithmPhashDCT) { // Create phash and put it in a byte array p.Hashes.PhashDCT = make([]byte, 8) binary.BigEndian.PutUint64(p.Hashes.PhashDCT, phash.DTC(img)) } - if slices.Contains(algorithms, blurHashAlgorithm) { + if slices.Contains(neededAlgorithms, blurHashAlgorithm) { // Create the blurhash blurhash, _ := blurhash.Encode(5, 5, img) p.Hashes.BlurHash = blurhash @@ -343,7 +360,7 @@ func Image(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm // TODO: rewrite more cleanly s2hash := sha256.New() mdhash := md5.New() - if slices.Contains(algorithms, manifest.HashAlgorithmSHA256) && slices.Contains(algorithms, manifest.HashAlgorithmMD5) { + if slices.Contains(neededAlgorithms, manifest.HashAlgorithmSHA256) && slices.Contains(neededAlgorithms, manifest.HashAlgorithmMD5) { mw := io.MultiWriter(s2hash, mdhash) if _, err := io.Copy(mw, file); err != nil { return nil, errors.Wrap(err, "failed computing SHA256 and MD5 hashes") @@ -351,13 +368,13 @@ func Image(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm p.Hashes.Sha256 = s2hash.Sum(nil) p.Hashes.Md5 = mdhash.Sum(nil) } else { - if slices.Contains(algorithms, manifest.HashAlgorithmSHA256) { + if slices.Contains(neededAlgorithms, manifest.HashAlgorithmSHA256) { if _, err := io.Copy(s2hash, file); err != nil { return nil, errors.Wrap(err, "failed computing SHA256 hash") } p.Hashes.Sha256 = s2hash.Sum(nil) } - if slices.Contains(algorithms, manifest.HashAlgorithmMD5) { + if slices.Contains(neededAlgorithms, manifest.HashAlgorithmMD5) { if _, err := io.Copy(mdhash, file); err != nil { return nil, errors.Wrap(err, "failed computing MD5 hash") } @@ -387,3 +404,51 @@ func isWEBPAnimated(file io.Reader) (bool, error) { } return frames > 1, nil } + +// MatchImage compares the link with the given hashes to determine if they match. +func MatchImage(link manifest.Link, hashes manifest.HashList) (bool, error) { + if link.MediaType == nil || !link.MediaType.IsBitmap() { + return false, errors.New("link is not to an image that can be matched") + } + + linkHashes := link.Properties.Hash() + if len(linkHashes) == 0 { + // No hashes in the link, we can't match it + return false, nil + } + for _, hash := range hashes { + if v, ok := linkHashes.Find(hash.Algorithm); ok { + if v.Equal(hash) { + // Simple equality + return true, nil + } + + // Special distance-based matching for perceptual hashes + if v.Algorithm == manifest.HashAlgorithmPhashDCT { + phashVal, err := base64.StdEncoding.DecodeString(v.Value) + if err != nil { + return false, errors.Wrap(err, "failed decoding perceptual hash value of link") + } + if len(phashVal) != 8 { + return false, errors.New("perceptual hash value of link is not 8 bytes in length") + } + linkPerceptualHash := binary.BigEndian.Uint64(phashVal) + + phashVal, err = base64.StdEncoding.DecodeString(hash.Value) + if err != nil { + return false, errors.Wrap(err, "failed decoding provided perceptual hash value") + } + if len(phashVal) != 8 { + return false, errors.New("provided perceptual hash value is not 8 bytes in length") + } + providedPerceptualHash := binary.BigEndian.Uint64(phashVal) + + if phash.Distance(linkPerceptualHash, providedPerceptualHash) == 0 { + return true, nil + } + } + } + } + + return false, nil +} diff --git a/pkg/analyzer/image_test.go b/pkg/analyzer/image_test.go new file mode 100644 index 0000000..0783d6a --- /dev/null +++ b/pkg/analyzer/image_test.go @@ -0,0 +1,151 @@ +package analyzer + +import ( + "os" + "testing" + + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/mediatype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInspectImage(t *testing.T) { + fs := os.DirFS("testdata/") + catLink := manifest.Link{ + Href: manifest.MustNewHREFFromString("catsink.jpg", false), + MediaType: &mediatype.JPEG, + } + + link, err := InspectImage(fs, catLink, []manifest.HashAlgorithm{}) + require.NoError(t, err) + require.NotNil(t, link) + assert.Equal(t, uint(615), link.Width) + assert.Equal(t, uint(458), link.Height) + assert.Equal(t, uint(36710), link.Size) + assert.False(t, link.Properties.Get("animated").(bool)) + assert.Empty(t, link.Properties.Hash()) + + link, err = InspectImage(fs, manifest.Link{ + Href: manifest.MustNewHREFFromString("animated.webp", false), + MediaType: &mediatype.WEBP, + }, []manifest.HashAlgorithm{}) + require.NoError(t, err) + require.NotNil(t, link) + assert.Equal(t, uint(1000), link.Width) + assert.Equal(t, uint(1000), link.Height) + assert.Equal(t, uint(5764), link.Size) + assert.True(t, link.Properties.Get("animated").(bool)) + + link, err = InspectImage(fs, manifest.Link{ + Href: manifest.MustNewHREFFromString("animated.png", false), + MediaType: &mediatype.PNG, + }, []manifest.HashAlgorithm{}) + require.NoError(t, err) + require.NotNil(t, link) + assert.Equal(t, uint(1000), link.Width) + assert.Equal(t, uint(1000), link.Height) + assert.Equal(t, uint(2932), link.Size) + assert.True(t, link.Properties.Get("animated").(bool)) + + _, err = InspectImage(fs, manifest.Link{ + Href: manifest.MustNewHREFFromString("corrupt.png", false), + MediaType: &mediatype.PNG, + }, []manifest.HashAlgorithm{}) + require.Error(t, err) + + _, err = InspectImage(fs, manifest.Link{ + Href: manifest.MustNewHREFFromString("frame1.jxl", false), + MediaType: &mediatype.JXL, + }, []manifest.HashAlgorithm{}) + require.ErrorContains(t, err, "JXL file format is currently unsupported") + + link, err = InspectImage(fs, catLink, []manifest.HashAlgorithm{ + manifest.HashAlgorithmBlake2b, // This is expected to not to anything + manifest.HashAlgorithmSHA256, + }) + require.NoError(t, err) + require.NotNil(t, link) + if assert.Len(t, link.Properties.Hash(), 1) { + assert.True(t, link.Properties.Hash()[0].Equal(manifest.HashValue{ + Algorithm: manifest.HashAlgorithmSHA256, + Value: "nzGm6cNL7fAadGSoFdtLzg/Z3MFqe3/fiWUZF9CPAKY=", + })) + } + + link, err = InspectImage(fs, catLink, []manifest.HashAlgorithm{ + manifest.HashAlgorithmPhashDCT, + }) + require.NoError(t, err) + require.NotNil(t, link) + if assert.Len(t, link.Properties.Hash(), 1) { + assert.True(t, link.Properties.Hash()[0].Equal(manifest.HashValue{ + Algorithm: manifest.HashAlgorithmPhashDCT, + Value: "TL5pWb0AIL8=", + })) + } +} + +func TestMatchImage(t *testing.T) { + fs := os.DirFS("testdata/") + + ok, err := MatchImage(manifest.Link{ + Href: manifest.MustNewHREFFromString("audio.mp3", false), + MediaType: &mediatype.MP3, + }, manifest.HashList{}) + require.ErrorContains(t, err, "link is not to an image that can be matched") + require.False(t, ok) + + link, err := InspectImage(fs, manifest.Link{ + Href: manifest.MustNewHREFFromString("catsink.jpg", false), + MediaType: &mediatype.JPEG, + }, []manifest.HashAlgorithm{ + manifest.HashAlgorithmSHA256, + manifest.HashAlgorithmPhashDCT, + }) + require.NoError(t, err) + require.NotNil(t, link) + ok, err = MatchImage(*link, manifest.HashList{ + manifest.HashValue{ + Algorithm: manifest.HashAlgorithmSHA256, + Value: "nzGm6cNL7fAadGSoFdtLzg/Z3MFqe3/fiWUZF9CPAKY=", + }, + }) + require.NoError(t, err) + require.True(t, ok) + ok, err = MatchImage(*link, manifest.HashList{ + manifest.HashValue{ + Algorithm: manifest.HashAlgorithmSHA256, + Value: "xxxxxxxxfAadGSoFdtLzg/Z3MFqe3/fiWUZF9CPAKY=", + }, + }) + require.NoError(t, err) + require.False(t, ok) + + link1, err := InspectImage(fs, manifest.Link{ + Href: manifest.MustNewHREFFromString("frame1.png", false), + MediaType: &mediatype.PNG, + }, []manifest.HashAlgorithm{manifest.HashAlgorithmPhashDCT}) + require.NoError(t, err) + require.NotNil(t, link1) + link2, err := InspectImage(fs, manifest.Link{ + Href: manifest.MustNewHREFFromString("frame2.png", false), + MediaType: &mediatype.PNG, + }, []manifest.HashAlgorithm{manifest.HashAlgorithmPhashDCT}) + require.NoError(t, err) + require.NotNil(t, link2) + if assert.Len(t, link1.Properties.Hash(), 1) && assert.Len(t, link2.Properties.Hash(), 1) { + hashes1 := link1.Properties.Hash() + hashes2 := link2.Properties.Hash() + + // Too similar, they match + ok, err = MatchImage(*link1, hashes2) + require.NoError(t, err) + assert.True(t, ok) + + // Pretty different, no match + ok, err = MatchImage(*link, hashes1) + require.NoError(t, err) + assert.False(t, ok) + } +} diff --git a/pkg/analyzer/testdata/animated.png b/pkg/analyzer/testdata/animated.png new file mode 100644 index 0000000..ec64c5a Binary files /dev/null and b/pkg/analyzer/testdata/animated.png differ diff --git a/pkg/analyzer/testdata/animated.webp b/pkg/analyzer/testdata/animated.webp new file mode 100644 index 0000000..2675892 Binary files /dev/null and b/pkg/analyzer/testdata/animated.webp differ diff --git a/pkg/analyzer/testdata/catsink.jpg b/pkg/analyzer/testdata/catsink.jpg new file mode 100644 index 0000000..f6adcf5 Binary files /dev/null and b/pkg/analyzer/testdata/catsink.jpg differ diff --git a/pkg/analyzer/testdata/corrupt.png b/pkg/analyzer/testdata/corrupt.png new file mode 100644 index 0000000..09b19dc Binary files /dev/null and b/pkg/analyzer/testdata/corrupt.png differ diff --git a/pkg/analyzer/testdata/frame1.jxl b/pkg/analyzer/testdata/frame1.jxl new file mode 100644 index 0000000..720fe25 Binary files /dev/null and b/pkg/analyzer/testdata/frame1.jxl differ diff --git a/pkg/analyzer/testdata/frame1.png b/pkg/analyzer/testdata/frame1.png new file mode 100644 index 0000000..28cbd9c Binary files /dev/null and b/pkg/analyzer/testdata/frame1.png differ diff --git a/pkg/analyzer/testdata/frame2.png b/pkg/analyzer/testdata/frame2.png new file mode 100644 index 0000000..9e06b8c Binary files /dev/null and b/pkg/analyzer/testdata/frame2.png differ diff --git a/pkg/fetcher/fs.go b/pkg/fetcher/fs.go index 90be8eb..66835e7 100644 --- a/pkg/fetcher/fs.go +++ b/pkg/fetcher/fs.go @@ -106,6 +106,14 @@ func (f *fsResource) Read(b []byte) (int, error) { } return len(bin), rerr } + // Out-of-range indexes are clamped to the available length automatically when calling `Read` + // That means we need to find the EOF ourselves by comparing the length requested and returned + if len(bin) < len(b) { + if len(bin) > 0 { + copy(b, bin) + } + return len(bin), io.EOF + } return copy(b, bin), nil } @@ -165,7 +173,7 @@ func (f fsFetcher) Open(name string) (fs.File, error) { return &fsResource{r: r, ctx: f.ctx}, nil } -// Turn a [Fetcher] into a [fs.FS] filesystem +// Turn a [Fetcher] into a [fs.FS] virtual filesystem func ToFS(ctx context.Context, f Fetcher) fsFetcher { return fsFetcher{f, ctx} } diff --git a/pkg/manifest/properties.go b/pkg/manifest/properties.go index 5344997..2bfa04c 100644 --- a/pkg/manifest/properties.go +++ b/pkg/manifest/properties.go @@ -102,11 +102,14 @@ func (p Properties) Layout() EPUBLayout { } func (p Properties) Encryption() *Encryption { - mp, ok := p.Get("encrypted").(map[string]interface{}) + v := p.Get("encrypted") + if v == nil { + return nil + } + mp, ok := v.(map[string]interface{}) if mp == nil || !ok { return nil } - enc, err := EncryptionFromJSON(mp) if err != nil { return nil @@ -115,11 +118,8 @@ func (p Properties) Encryption() *Encryption { } func (p Properties) Contains() []string { - if p == nil { - return nil - } - v, ok := p["contains"] - if !ok { + v := p.Get("contains") + if v == nil { return nil } cv, ok := v.([]string) @@ -130,11 +130,8 @@ func (p Properties) Contains() []string { } func (p Properties) Hash() HashList { - if p == nil { - return nil - } - v, ok := p["hash"] - if !ok { + v := p.Get("hash") + if v == nil { return nil } cv, ok := v.([]interface{}) diff --git a/pkg/manifest/properties_hash.go b/pkg/manifest/properties_hash.go index 7f5ac06..21b883f 100644 --- a/pkg/manifest/properties_hash.go +++ b/pkg/manifest/properties_hash.go @@ -1,6 +1,10 @@ package manifest -import "github.com/pkg/errors" +import ( + "crypto/subtle" + + "github.com/pkg/errors" +) type HashAlgorithm string @@ -10,6 +14,7 @@ type HashAlgorithm string const ( HashAlgorithmBlake2b HashAlgorithm = "blake2b" HashAlgorithmBlake2s HashAlgorithm = "blake2s" + HashAlgorithmBlake3 HashAlgorithm = "blake3" HashAlgorithmSHA512 HashAlgorithm = "sha512" HashAlgorithmSHA256 HashAlgorithm = "sha256" HashAlgorithmSHA1 HashAlgorithm = "sha1" @@ -24,8 +29,31 @@ type HashValue struct { Value string `json:"value"` } +func (h HashValue) String() string { + return string(h.Algorithm) + ":" + h.Value +} + +func (h HashValue) Equal(other HashValue) bool { + if h.Algorithm != other.Algorithm { + return false + } + + // Cast the strings to []byte because we don't have a standard encoding to decode from for the values + // We should probably decide on one, such as base64 std encoding + return subtle.ConstantTimeCompare([]byte(h.Value), []byte(other.Value)) == 1 +} + type HashList []HashValue +func (h HashList) Find(algorithm HashAlgorithm) (HashValue, bool) { + for _, hash := range h { + if hash.Algorithm == algorithm { + return hash, true + } + } + return HashValue{}, false +} + func (h HashList) Value(algorithm HashAlgorithm) (string, bool) { for _, hash := range h { if hash.Algorithm == algorithm { @@ -62,3 +90,14 @@ func HashListFromJSONArray(rawJsonArray []interface{}) (HashList, error) { } return hashes, nil } + +func (h HashList) ToJSONArray() []interface{} { + jsonArray := make([]interface{}, 0, len(h)) + for _, hash := range h { + jsonArray = append(jsonArray, map[string]interface{}{ + "algorithm": hash.Algorithm, + "value": hash.Value, + }) + } + return jsonArray +} diff --git a/pkg/streamer/a11y_infer.go b/pkg/streamer/a11y_infer.go index 690f8e9..8e64390 100644 --- a/pkg/streamer/a11y_infer.go +++ b/pkg/streamer/a11y_infer.go @@ -1,12 +1,19 @@ package streamer import ( + "context" + "slices" + + "github.com/readium/go-toolkit/pkg/analyzer" + "github.com/readium/go-toolkit/pkg/fetcher" "github.com/readium/go-toolkit/pkg/internal/extensions" "github.com/readium/go-toolkit/pkg/manifest" "github.com/readium/go-toolkit/pkg/mediatype" + "github.com/readium/go-toolkit/pkg/pub" ) -func inferA11yMetadataFromManifest(mf manifest.Manifest) *manifest.A11y { +func inferA11yMetadataInPublicationManifest(ctx context.Context, pub *pub.Publication, ignorableImages manifest.HashList) (*manifest.A11y, error) { + mf := pub.Manifest inferredA11y := manifest.NewA11y() var manifestA11y manifest.A11y @@ -48,13 +55,46 @@ func inferA11yMetadataFromManifest(mf manifest.Manifest) *manifest.A11y { mf.Metadata.Presentation != nil && *mf.Metadata.Presentation.Layout == manifest.EPUBLayoutReflowable { isTextual = true + + var hashAlgorithms []manifest.HashAlgorithm + for _, hash := range ignorableImages { + if !slices.Contains(hashAlgorithms, hash.Algorithm) { + hashAlgorithms = append(hashAlgorithms, hash.Algorithm) + } + } + ffs := fetcher.ToFS(ctx, pub.Fetcher) + for _, link := range allResources { mt := link.MediaType if mt.IsAudio() || mt.IsVideo() || - (mt.IsBitmap() && !extensions.Contains(link.Rels, "cover")) || mt.Matches(&mediatype.PDF) { - + isTextual = false + break + } else if mt.IsBitmap() && !extensions.Contains(link.Rels, "cover") { + if len(ignorableImages) > 0 { + // We may want to consider doing this in parallel in a future version, + // as it could be reading each resource in sequence from a remote source. + link, err := analyzer.InspectImage(ffs, link, hashAlgorithms) + if err != nil { + return nil, err + } + hashes := link.Properties.Hash() + canIgnore := false + for _, ignorable := range ignorableImages { + if v, ok := hashes.Find(ignorable.Algorithm); ok { + if v.Equal(ignorable) { + canIgnore = true + break + } + } + } + + // If image is ignored, isTextual remains true + if canIgnore { + continue + } + } isTextual = false break } @@ -143,7 +183,7 @@ func inferA11yMetadataFromManifest(mf manifest.Manifest) *manifest.A11y { } if inferredA11y.IsEmpty() { - return nil + return nil, nil } - return &inferredA11y + return &inferredA11y, nil } diff --git a/pkg/streamer/a11y_infer_test.go b/pkg/streamer/a11y_infer_test.go index 13f9ab1..63a313a 100644 --- a/pkg/streamer/a11y_infer_test.go +++ b/pkg/streamer/a11y_infer_test.go @@ -1,10 +1,13 @@ package streamer 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" ) @@ -27,7 +30,8 @@ func TestReturnsAdditionalInferredA11yMetadata(t *testing.T) { inferreddA11y.AccessModes = []manifest.A11yAccessMode{manifest.A11yAccessModeTextual} inferreddA11y.AccessModesSufficient = [][]manifest.A11yPrimaryAccessMode{{manifest.A11yPrimaryAccessModeTextual}} - res := inferA11yMetadataFromManifest(m) + res, err := inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, nil, nil), nil) + assert.NoError(t, err) assert.Equal(t, &inferreddA11y, res) // Original manifest should not be modified. @@ -57,7 +61,8 @@ func TestInferVisualAccessMode(t *testing.T) { func assertAccessMode(t *testing.T, accessMode manifest.A11yAccessMode, extension string, mt mediatype.MediaType) { testManifest := func(m manifest.Manifest) { - res := inferA11yMetadataFromManifest(m) + res, err := inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, nil, nil), nil) + assert.NoError(t, err) assert.NotNil(t, res) assert.Contains(t, res.AccessModes, accessMode) } @@ -82,7 +87,8 @@ func TestInferTextualAccessModeAndAccessModeSufficientFromProfile(t *testing.T) Accessibility: &a11y, }, } - res := inferA11yMetadataFromManifest(m) + res, err := inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, nil, nil), nil) + assert.NoError(t, err) assert.NotNil(t, res) assert.Contains(t, res.AccessModes, manifest.A11yAccessModeTextual) assert.Contains(t, res.AccessModesSufficient, []manifest.A11yPrimaryAccessMode{manifest.A11yPrimaryAccessModeTextual}) @@ -97,7 +103,8 @@ func TestInferTextualAccessModeAndAccessModeSufficientFromProfile(t *testing.T) // (inspect "resources" and "readingOrder" in RWPM) func TestInferTextualAccessModeAndAccessModeSufficientFromLackOfMedia(t *testing.T) { testManifest := func(contains bool, m manifest.Manifest) { - res := inferA11yMetadataFromManifest(m) + res, err := inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, nil, nil), nil) + assert.NoError(t, err) assert.NotNil(t, res) ams := []manifest.A11yPrimaryAccessMode{manifest.A11yPrimaryAccessModeTextual} @@ -166,17 +173,62 @@ func TestDontInferTextualAccessModeAndAccessModeSufficientFromLackOfMediaForFXL( TableOfContents: []manifest.Link{newLink(mediatype.HTML, "html")}, } - res := inferA11yMetadataFromManifest(m) + res, err := inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, nil, nil), nil) + assert.NoError(t, err) assert.NotNil(t, res) ams := []manifest.A11yPrimaryAccessMode{manifest.A11yPrimaryAccessModeTextual} assert.NotContains(t, res.AccessModes, manifest.A11yAccessModeTextual) assert.NotContains(t, res.AccessModesSufficient, ams) } +func TestInferTextualAccessModeWithIgnoredImages(t *testing.T) { + testHash := manifest.HashList{ + manifest.HashValue{ + Algorithm: manifest.HashAlgorithmSHA256, + Value: "nzGm6cNL7fAadGSoFdtLzg/Z3MFqe3/fiWUZF9CPAKY=", + }, + } + l := newLink(mediatype.PNG, "png") + l.Properties = manifest.Properties{ + "hash": testHash.ToJSONArray(), + } + + cover := newLink(mediatype.JPEG, "jpg") + cover.Rels = manifest.Strings{"cover"} + + m := manifest.Manifest{ + Metadata: manifest.Metadata{ + ConformsTo: manifest.Profiles{manifest.ProfileEPUB}, + Presentation: newEPUBPresentation(manifest.EPUBLayoutReflowable), + }, + ReadingOrder: []manifest.Link{ + newLink(mediatype.HTML, "html"), + }, + Resources: []manifest.Link{ + cover, + l, + }, + } + f := fetcher.NewFileFetcher("file.png", "testdata/file.png") + + res, err := inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, f, nil), nil) + assert.NoError(t, err) + assert.NotContains(t, res.AccessModes, manifest.A11yAccessModeTextual) + assert.Contains(t, res.AccessModes, manifest.A11yAccessModeVisual) + assert.NotContains(t, res.AccessModesSufficient, []manifest.A11yPrimaryAccessMode{manifest.A11yPrimaryAccessModeTextual}) + + res, err = inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, f, nil), testHash) + assert.NoError(t, err) + assert.Contains(t, res.AccessModes, manifest.A11yAccessModeTextual) + assert.Contains(t, res.AccessModes, manifest.A11yAccessModeVisual) + assert.Contains(t, res.AccessModesSufficient, []manifest.A11yPrimaryAccessMode{manifest.A11yPrimaryAccessModeTextual}) +} + // If the publication contains only references to audio resources (inspect "resources" and "readingOrder" in RWPM) func TestInferAuditoryAccessModeSufficient(t *testing.T) { testManifest := func(contains bool, m manifest.Manifest) { - res := inferA11yMetadataFromManifest(m) + res, err := inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, nil, nil), nil) + assert.NoError(t, err) if res == nil && !contains { return } @@ -222,7 +274,8 @@ func TestInferAuditoryAccessModeSufficient(t *testing.T) { // If the publication contains only references to image or video resources (inspect "resources" and "readingOrder" in RWPM) func TestInferVisualAccessModeSufficient(t *testing.T) { testManifest := func(contains bool, m manifest.Manifest) { - res := inferA11yMetadataFromManifest(m) + res, err := inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, nil, nil), nil) + assert.NoError(t, err) if res == nil && !contains { return } @@ -335,7 +388,8 @@ func TestInferFeatureDisplayTransformability(t *testing.T) { ReadingOrder: []manifest.Link{newLink(mediatype.HTML, "html")}, } - res := inferA11yMetadataFromManifest(m) + res, err := inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, nil, nil), nil) + assert.NoError(t, err) assert.NotNil(t, res) if contains { assert.Contains(t, res.Features, manifest.A11yFeatureDisplayTransformability) @@ -365,7 +419,8 @@ func TestInferFeatureSynchronizedAudioText(t *testing.T) { } func assertFeature(t *testing.T, m manifest.Manifest, feature manifest.A11yFeature) { - res := inferA11yMetadataFromManifest(m) + res, err := inferA11yMetadataInPublicationManifest(context.TODO(), pub.New(m, nil, nil), nil) + assert.NoError(t, err) assert.NotNil(t, res) assert.Contains(t, res.Features, feature) } diff --git a/pkg/streamer/streamer.go b/pkg/streamer/streamer.go index f12a7af..55b3c85 100644 --- a/pkg/streamer/streamer.go +++ b/pkg/streamer/streamer.go @@ -22,10 +22,11 @@ import ( // ones. This can also be used to provide an alternative configuration of a // default parser. type Streamer struct { - parsers []parser.PublicationParser - inferA11yMetadata InferA11yMetadata - inferPageCount bool - archiveFactory archive.ArchiveFactory + parsers []parser.PublicationParser + inferA11yMetadata InferA11yMetadata + inferPageCount bool + inferIgnoredImages manifest.HashList + archiveFactory archive.ArchiveFactory // TODO pdfFactory httpClient *http.Client // onCreatePublication @@ -36,6 +37,7 @@ type Config struct { IgnoreDefaultParsers bool // When true, only parsers provided in parsers will be used. InferA11yMetadata InferA11yMetadata // When not empty, additional accessibility metadata will be infered from the manifest. InferPageCount bool // When true, will infer `Metadata.NumberOfPages` from the generated position list. + InferIgnoredImages manifest.HashList // An optional list of hashes of images, to use in finding images that can be ignored when inferring accessibility metadata. ArchiveFactory archive.ArchiveFactory // Opens an archive (e.g. ZIP, RAR), optionally protected by credentials. HttpClient *http.Client // Service performing HTTP requests. } @@ -114,7 +116,11 @@ func (s Streamer) Open(ctx context.Context, a asset.PublicationAsset, credential pub := builder.Build() - s.inferA11yMetadataInPublication(pub) + err = s.inferA11yMetadataInPublication(ctx, pub) + if err != nil { + fetcher.Close() + return nil, errors.Wrap(err, "failed inferring accessibility metadata in publication") + } if s.inferPageCount && pub.Manifest.Metadata.NumberOfPages == nil { pageCount := uint(len(pub.Positions(ctx))) @@ -126,13 +132,16 @@ func (s Streamer) Open(ctx context.Context, a asset.PublicationAsset, credential return pub, nil } -func (s *Streamer) inferA11yMetadataInPublication(pub *pub.Publication) { +func (s *Streamer) inferA11yMetadataInPublication(ctx context.Context, pub *pub.Publication) error { if s.inferA11yMetadata == InferA11yMetadataNo { - return + return nil + } + inferredA11y, err := inferA11yMetadataInPublicationManifest(ctx, pub, s.inferIgnoredImages) + if err != nil { + return errors.Wrap(err, "failed inferring accessibility metadata in publication manifest") } - inferredA11y := inferA11yMetadataFromManifest(pub.Manifest) if inferredA11y == nil { - return + return nil } switch s.inferA11yMetadata { @@ -147,6 +156,7 @@ func (s *Streamer) inferA11yMetadataInPublication(pub *pub.Publication) { pub.Manifest.Metadata.SetOtherMetadata(manifest.InferredAccessibilityMetadataKey, inferredA11y) case InferA11yMetadataNo: - return + return nil } + return nil } diff --git a/pkg/streamer/testdata/file.png b/pkg/streamer/testdata/file.png new file mode 100644 index 0000000..28cbd9c Binary files /dev/null and b/pkg/streamer/testdata/file.png differ