Skip to content

Commit 6cc1f5e

Browse files
authored
Support ignoring images based on hash when inferring nonvisual reading (#228)
1 parent 7da38ec commit 6cc1f5e

17 files changed

+425
-47
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
44

55
**Warning:** Features marked as *alpha* may change or be removed in a future release without notice. Use with caution.
66

7+
## [0.9.1] - 2025-05-05
8+
9+
### Added
10+
11+
- New config option available when creating a `Streamer`: `InferIgnoredImages`, a list of hashes of images to ignore when when inferring nonvisual reading
12+
- `analyzer.MatchImage` function that compares an image link's hashes with given hashes to check for a match
13+
- `HashValue` has new `String` and `Equal` convenience functions. `HashList` has a new `Find` convenience function.
14+
15+
### Changed
16+
17+
- Renamed `analyzer.Image` to `analyzer.InspectImage`
18+
- Slight adjustments to behavior of manifest properties functions
19+
720
## [0.9.0] - 2025-04-30
821

922
### Removed

pkg/analyzer/image.go

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func (p *imageProperties) EnhanceLink(link *manifest.Link) {
8181
}
8282
hashes.Deduplicate()
8383

84-
link.Properties["hash"] = hashes
84+
link.Properties["hash"] = hashes.ToJSONArray()
8585
link.Properties["animated"] = p.Animated
8686
}
8787

@@ -101,14 +101,31 @@ func hasVisualAlgorithm(hashes []manifest.HashAlgorithm) bool {
101101
return visualHash
102102
}
103103

104-
// Image inspects an image located in the provided filesystem, using the provided link's [manifest.HREF]
104+
// InspectImage inspects an image located in the provided filesystem, using the provided link's [manifest.HREF]
105105
// as a path. Additional properties from the link, such as the [mediatype.MediaType], may be used, and should
106106
// be included. A copy of the provided link will be returned, with the `size`, `width`, `height` and
107107
// `properties.animated` attributes set. A slice of [manifest.HashAlgorithm] can be provided, in which case
108108
// the returned link will also have `properties.hash` set with the computed hashes. Currently, the supported
109109
// algorithms are: [manifest.HashAlgorithmSHA256], [manifest.HashAlgorithmMD5], [manifest.HashAlgorithmPhashDCT],
110110
// and `https://blurha.sh` (BlurHash). The latter two are visual hashes, which are more computationally expensive.
111-
func Image(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm) (*manifest.Link, error) {
111+
func InspectImage(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm) (*manifest.Link, error) {
112+
113+
// Skip any supplied algorithms for hashes that have already been computed in the link properties
114+
neededAlgorithms := make([]manifest.HashAlgorithm, 0, len(algorithms))
115+
existingHashes := link.Properties.Hash()
116+
for _, algorithm := range algorithms {
117+
exists := false
118+
for _, hash := range existingHashes {
119+
if hash.Algorithm == algorithm {
120+
exists = true
121+
break
122+
}
123+
}
124+
if !exists && !slices.Contains(neededAlgorithms, algorithm) {
125+
neededAlgorithms = append(neededAlgorithms, algorithm)
126+
}
127+
}
128+
112129
path := link.Href.String()
113130
file, err := system.Open(path)
114131
if err != nil {
@@ -236,7 +253,7 @@ func Image(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm
236253
if err != nil {
237254
return nil, errors.Wrap(err, "failed reopening file")
238255
}
239-
visualHash := hasVisualAlgorithm(algorithms)
256+
visualHash := hasVisualAlgorithm(neededAlgorithms)
240257
hashVisually := func(img image.Image) {
241258
if !visualHash {
242259
return
@@ -248,12 +265,12 @@ func Image(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm
248265
img = imaging.Resize(img, 128, 0, imaging.Lanczos)
249266
}
250267

251-
if slices.Contains(algorithms, manifest.HashAlgorithmPhashDCT) {
268+
if slices.Contains(neededAlgorithms, manifest.HashAlgorithmPhashDCT) {
252269
// Create phash and put it in a byte array
253270
p.Hashes.PhashDCT = make([]byte, 8)
254271
binary.BigEndian.PutUint64(p.Hashes.PhashDCT, phash.DTC(img))
255272
}
256-
if slices.Contains(algorithms, blurHashAlgorithm) {
273+
if slices.Contains(neededAlgorithms, blurHashAlgorithm) {
257274
// Create the blurhash
258275
blurhash, _ := blurhash.Encode(5, 5, img)
259276
p.Hashes.BlurHash = blurhash
@@ -343,21 +360,21 @@ func Image(system fs.FS, link manifest.Link, algorithms []manifest.HashAlgorithm
343360
// TODO: rewrite more cleanly
344361
s2hash := sha256.New()
345362
mdhash := md5.New()
346-
if slices.Contains(algorithms, manifest.HashAlgorithmSHA256) && slices.Contains(algorithms, manifest.HashAlgorithmMD5) {
363+
if slices.Contains(neededAlgorithms, manifest.HashAlgorithmSHA256) && slices.Contains(neededAlgorithms, manifest.HashAlgorithmMD5) {
347364
mw := io.MultiWriter(s2hash, mdhash)
348365
if _, err := io.Copy(mw, file); err != nil {
349366
return nil, errors.Wrap(err, "failed computing SHA256 and MD5 hashes")
350367
}
351368
p.Hashes.Sha256 = s2hash.Sum(nil)
352369
p.Hashes.Md5 = mdhash.Sum(nil)
353370
} else {
354-
if slices.Contains(algorithms, manifest.HashAlgorithmSHA256) {
371+
if slices.Contains(neededAlgorithms, manifest.HashAlgorithmSHA256) {
355372
if _, err := io.Copy(s2hash, file); err != nil {
356373
return nil, errors.Wrap(err, "failed computing SHA256 hash")
357374
}
358375
p.Hashes.Sha256 = s2hash.Sum(nil)
359376
}
360-
if slices.Contains(algorithms, manifest.HashAlgorithmMD5) {
377+
if slices.Contains(neededAlgorithms, manifest.HashAlgorithmMD5) {
361378
if _, err := io.Copy(mdhash, file); err != nil {
362379
return nil, errors.Wrap(err, "failed computing MD5 hash")
363380
}
@@ -387,3 +404,51 @@ func isWEBPAnimated(file io.Reader) (bool, error) {
387404
}
388405
return frames > 1, nil
389406
}
407+
408+
// MatchImage compares the link with the given hashes to determine if they match.
409+
func MatchImage(link manifest.Link, hashes manifest.HashList) (bool, error) {
410+
if link.MediaType == nil || !link.MediaType.IsBitmap() {
411+
return false, errors.New("link is not to an image that can be matched")
412+
}
413+
414+
linkHashes := link.Properties.Hash()
415+
if len(linkHashes) == 0 {
416+
// No hashes in the link, we can't match it
417+
return false, nil
418+
}
419+
for _, hash := range hashes {
420+
if v, ok := linkHashes.Find(hash.Algorithm); ok {
421+
if v.Equal(hash) {
422+
// Simple equality
423+
return true, nil
424+
}
425+
426+
// Special distance-based matching for perceptual hashes
427+
if v.Algorithm == manifest.HashAlgorithmPhashDCT {
428+
phashVal, err := base64.StdEncoding.DecodeString(v.Value)
429+
if err != nil {
430+
return false, errors.Wrap(err, "failed decoding perceptual hash value of link")
431+
}
432+
if len(phashVal) != 8 {
433+
return false, errors.New("perceptual hash value of link is not 8 bytes in length")
434+
}
435+
linkPerceptualHash := binary.BigEndian.Uint64(phashVal)
436+
437+
phashVal, err = base64.StdEncoding.DecodeString(hash.Value)
438+
if err != nil {
439+
return false, errors.Wrap(err, "failed decoding provided perceptual hash value")
440+
}
441+
if len(phashVal) != 8 {
442+
return false, errors.New("provided perceptual hash value is not 8 bytes in length")
443+
}
444+
providedPerceptualHash := binary.BigEndian.Uint64(phashVal)
445+
446+
if phash.Distance(linkPerceptualHash, providedPerceptualHash) == 0 {
447+
return true, nil
448+
}
449+
}
450+
}
451+
}
452+
453+
return false, nil
454+
}

pkg/analyzer/image_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package analyzer
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/readium/go-toolkit/pkg/manifest"
8+
"github.com/readium/go-toolkit/pkg/mediatype"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestInspectImage(t *testing.T) {
14+
fs := os.DirFS("testdata/")
15+
catLink := manifest.Link{
16+
Href: manifest.MustNewHREFFromString("catsink.jpg", false),
17+
MediaType: &mediatype.JPEG,
18+
}
19+
20+
link, err := InspectImage(fs, catLink, []manifest.HashAlgorithm{})
21+
require.NoError(t, err)
22+
require.NotNil(t, link)
23+
assert.Equal(t, uint(615), link.Width)
24+
assert.Equal(t, uint(458), link.Height)
25+
assert.Equal(t, uint(36710), link.Size)
26+
assert.False(t, link.Properties.Get("animated").(bool))
27+
assert.Empty(t, link.Properties.Hash())
28+
29+
link, err = InspectImage(fs, manifest.Link{
30+
Href: manifest.MustNewHREFFromString("animated.webp", false),
31+
MediaType: &mediatype.WEBP,
32+
}, []manifest.HashAlgorithm{})
33+
require.NoError(t, err)
34+
require.NotNil(t, link)
35+
assert.Equal(t, uint(1000), link.Width)
36+
assert.Equal(t, uint(1000), link.Height)
37+
assert.Equal(t, uint(5764), link.Size)
38+
assert.True(t, link.Properties.Get("animated").(bool))
39+
40+
link, err = InspectImage(fs, manifest.Link{
41+
Href: manifest.MustNewHREFFromString("animated.png", false),
42+
MediaType: &mediatype.PNG,
43+
}, []manifest.HashAlgorithm{})
44+
require.NoError(t, err)
45+
require.NotNil(t, link)
46+
assert.Equal(t, uint(1000), link.Width)
47+
assert.Equal(t, uint(1000), link.Height)
48+
assert.Equal(t, uint(2932), link.Size)
49+
assert.True(t, link.Properties.Get("animated").(bool))
50+
51+
_, err = InspectImage(fs, manifest.Link{
52+
Href: manifest.MustNewHREFFromString("corrupt.png", false),
53+
MediaType: &mediatype.PNG,
54+
}, []manifest.HashAlgorithm{})
55+
require.Error(t, err)
56+
57+
_, err = InspectImage(fs, manifest.Link{
58+
Href: manifest.MustNewHREFFromString("frame1.jxl", false),
59+
MediaType: &mediatype.JXL,
60+
}, []manifest.HashAlgorithm{})
61+
require.ErrorContains(t, err, "JXL file format is currently unsupported")
62+
63+
link, err = InspectImage(fs, catLink, []manifest.HashAlgorithm{
64+
manifest.HashAlgorithmBlake2b, // This is expected to not to anything
65+
manifest.HashAlgorithmSHA256,
66+
})
67+
require.NoError(t, err)
68+
require.NotNil(t, link)
69+
if assert.Len(t, link.Properties.Hash(), 1) {
70+
assert.True(t, link.Properties.Hash()[0].Equal(manifest.HashValue{
71+
Algorithm: manifest.HashAlgorithmSHA256,
72+
Value: "nzGm6cNL7fAadGSoFdtLzg/Z3MFqe3/fiWUZF9CPAKY=",
73+
}))
74+
}
75+
76+
link, err = InspectImage(fs, catLink, []manifest.HashAlgorithm{
77+
manifest.HashAlgorithmPhashDCT,
78+
})
79+
require.NoError(t, err)
80+
require.NotNil(t, link)
81+
if assert.Len(t, link.Properties.Hash(), 1) {
82+
assert.True(t, link.Properties.Hash()[0].Equal(manifest.HashValue{
83+
Algorithm: manifest.HashAlgorithmPhashDCT,
84+
Value: "TL5pWb0AIL8=",
85+
}))
86+
}
87+
}
88+
89+
func TestMatchImage(t *testing.T) {
90+
fs := os.DirFS("testdata/")
91+
92+
ok, err := MatchImage(manifest.Link{
93+
Href: manifest.MustNewHREFFromString("audio.mp3", false),
94+
MediaType: &mediatype.MP3,
95+
}, manifest.HashList{})
96+
require.ErrorContains(t, err, "link is not to an image that can be matched")
97+
require.False(t, ok)
98+
99+
link, err := InspectImage(fs, manifest.Link{
100+
Href: manifest.MustNewHREFFromString("catsink.jpg", false),
101+
MediaType: &mediatype.JPEG,
102+
}, []manifest.HashAlgorithm{
103+
manifest.HashAlgorithmSHA256,
104+
manifest.HashAlgorithmPhashDCT,
105+
})
106+
require.NoError(t, err)
107+
require.NotNil(t, link)
108+
ok, err = MatchImage(*link, manifest.HashList{
109+
manifest.HashValue{
110+
Algorithm: manifest.HashAlgorithmSHA256,
111+
Value: "nzGm6cNL7fAadGSoFdtLzg/Z3MFqe3/fiWUZF9CPAKY=",
112+
},
113+
})
114+
require.NoError(t, err)
115+
require.True(t, ok)
116+
ok, err = MatchImage(*link, manifest.HashList{
117+
manifest.HashValue{
118+
Algorithm: manifest.HashAlgorithmSHA256,
119+
Value: "xxxxxxxxfAadGSoFdtLzg/Z3MFqe3/fiWUZF9CPAKY=",
120+
},
121+
})
122+
require.NoError(t, err)
123+
require.False(t, ok)
124+
125+
link1, err := InspectImage(fs, manifest.Link{
126+
Href: manifest.MustNewHREFFromString("frame1.png", false),
127+
MediaType: &mediatype.PNG,
128+
}, []manifest.HashAlgorithm{manifest.HashAlgorithmPhashDCT})
129+
require.NoError(t, err)
130+
require.NotNil(t, link1)
131+
link2, err := InspectImage(fs, manifest.Link{
132+
Href: manifest.MustNewHREFFromString("frame2.png", false),
133+
MediaType: &mediatype.PNG,
134+
}, []manifest.HashAlgorithm{manifest.HashAlgorithmPhashDCT})
135+
require.NoError(t, err)
136+
require.NotNil(t, link2)
137+
if assert.Len(t, link1.Properties.Hash(), 1) && assert.Len(t, link2.Properties.Hash(), 1) {
138+
hashes1 := link1.Properties.Hash()
139+
hashes2 := link2.Properties.Hash()
140+
141+
// Too similar, they match
142+
ok, err = MatchImage(*link1, hashes2)
143+
require.NoError(t, err)
144+
assert.True(t, ok)
145+
146+
// Pretty different, no match
147+
ok, err = MatchImage(*link, hashes1)
148+
require.NoError(t, err)
149+
assert.False(t, ok)
150+
}
151+
}

pkg/analyzer/testdata/animated.png

2.86 KB
Loading

pkg/analyzer/testdata/animated.webp

5.63 KB
Binary file not shown.

pkg/analyzer/testdata/catsink.jpg

35.8 KB
Loading

pkg/analyzer/testdata/corrupt.png

693 Bytes
Loading

pkg/analyzer/testdata/frame1.jxl

590 Bytes
Binary file not shown.

pkg/analyzer/testdata/frame1.png

2.6 KB
Loading

pkg/analyzer/testdata/frame2.png

2.67 KB
Loading

pkg/fetcher/fs.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ func (f *fsResource) Read(b []byte) (int, error) {
106106
}
107107
return len(bin), rerr
108108
}
109+
// Out-of-range indexes are clamped to the available length automatically when calling `Read`
110+
// That means we need to find the EOF ourselves by comparing the length requested and returned
111+
if len(bin) < len(b) {
112+
if len(bin) > 0 {
113+
copy(b, bin)
114+
}
115+
return len(bin), io.EOF
116+
}
109117
return copy(b, bin), nil
110118
}
111119

@@ -165,7 +173,7 @@ func (f fsFetcher) Open(name string) (fs.File, error) {
165173
return &fsResource{r: r, ctx: f.ctx}, nil
166174
}
167175

168-
// Turn a [Fetcher] into a [fs.FS] filesystem
176+
// Turn a [Fetcher] into a [fs.FS] virtual filesystem
169177
func ToFS(ctx context.Context, f Fetcher) fsFetcher {
170178
return fsFetcher{f, ctx}
171179
}

pkg/manifest/properties.go

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,14 @@ func (p Properties) Layout() EPUBLayout {
102102
}
103103

104104
func (p Properties) Encryption() *Encryption {
105-
mp, ok := p.Get("encrypted").(map[string]interface{})
105+
v := p.Get("encrypted")
106+
if v == nil {
107+
return nil
108+
}
109+
mp, ok := v.(map[string]interface{})
106110
if mp == nil || !ok {
107111
return nil
108112
}
109-
110113
enc, err := EncryptionFromJSON(mp)
111114
if err != nil {
112115
return nil
@@ -115,11 +118,8 @@ func (p Properties) Encryption() *Encryption {
115118
}
116119

117120
func (p Properties) Contains() []string {
118-
if p == nil {
119-
return nil
120-
}
121-
v, ok := p["contains"]
122-
if !ok {
121+
v := p.Get("contains")
122+
if v == nil {
123123
return nil
124124
}
125125
cv, ok := v.([]string)
@@ -130,11 +130,8 @@ func (p Properties) Contains() []string {
130130
}
131131

132132
func (p Properties) Hash() HashList {
133-
if p == nil {
134-
return nil
135-
}
136-
v, ok := p["hash"]
137-
if !ok {
133+
v := p.Get("hash")
134+
if v == nil {
138135
return nil
139136
}
140137
cv, ok := v.([]interface{})

0 commit comments

Comments
 (0)