From 76418dfcc8803cc67ae676985c9596dba131a34c Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 1 Jun 2026 01:31:01 -0700 Subject: [PATCH 1/4] add more sophisticated DRM detection for EPUB --- pkg/archive/archive_zip.go | 6 +- pkg/parser/epub/factory.go | 5 +- pkg/parser/epub/parser.go | 34 ++++- pkg/parser/epub/parser_encryption.go | 34 ++--- pkg/parser/epub/parser_encryption_test.go | 17 +-- pkg/parser/epub/parser_test.go | 125 ++++++++++++++++++ pkg/protection/drm.go | 14 -- pkg/protection/epub.go | 142 +++++++++++++++++++++ pkg/protection/epub_test.go | 40 ++++++ pkg/protection/protection.go | 9 ++ pkg/protection/testdata/fake-adept.epub | Bin 0 -> 4216 bytes pkg/protection/testdata/fake-bn.epub | Bin 0 -> 4227 bytes pkg/protection/testdata/fake-fairplay.epub | Bin 0 -> 3487 bytes pkg/protection/testdata/fake-kobo.epub | Bin 0 -> 2449 bytes pkg/protection/testdata/fake-lcp.epub | Bin 0 -> 4260 bytes pkg/protection/testdata/yahoo.ypub | Bin 0 -> 17641 bytes pkg/protection/type.go | 31 +++++ pkg/protection/type_string.go | 29 +++++ 18 files changed, 424 insertions(+), 62 deletions(-) create mode 100644 pkg/parser/epub/parser_test.go delete mode 100644 pkg/protection/drm.go create mode 100644 pkg/protection/epub.go create mode 100644 pkg/protection/epub_test.go create mode 100644 pkg/protection/protection.go create mode 100644 pkg/protection/testdata/fake-adept.epub create mode 100644 pkg/protection/testdata/fake-bn.epub create mode 100644 pkg/protection/testdata/fake-fairplay.epub create mode 100644 pkg/protection/testdata/fake-kobo.epub create mode 100644 pkg/protection/testdata/fake-lcp.epub create mode 100644 pkg/protection/testdata/yahoo.ypub create mode 100644 pkg/protection/type.go create mode 100644 pkg/protection/type_string.go diff --git a/pkg/archive/archive_zip.go b/pkg/archive/archive_zip.go index 995d4073..1f5590a2 100644 --- a/pkg/archive/archive_zip.go +++ b/pkg/archive/archive_zip.go @@ -11,15 +11,15 @@ import ( "path" "sync" - "github.com/chocolatkey/gzran" "github.com/pkg/errors" + "github.com/readium/zran" ) type gozipArchiveEntry struct { file *zip.File minimizeReads bool - gi gzran.Index + gi zran.Index gm sync.Mutex } @@ -115,7 +115,7 @@ func (e *gozipArchiveEntry) Read(start int64, end int64) ([]byte, error) { // This special reader lets us restore the decompressor state at known offsets // which is useful when a client has already requested previous parts of the file, // such as when a web browser requests subsequent byte ranges for media playback. - fzr, err := gzran.NewDReader(bytes.NewReader(compressedData)) // Default interval = 1MB, same as current ZRandCutoff + fzr, err := zran.NewDReader(bytes.NewReader(compressedData)) // Default interval = 1MB, same as current ZRandCutoff if err != nil { return nil, err } diff --git a/pkg/parser/epub/factory.go b/pkg/parser/epub/factory.go index dd488a53..d3632820 100644 --- a/pkg/parser/epub/factory.go +++ b/pkg/parser/epub/factory.go @@ -3,14 +3,13 @@ package epub import ( "github.com/readium/go-toolkit/pkg/internal/extensions" "github.com/readium/go-toolkit/pkg/manifest" - "github.com/readium/go-toolkit/pkg/util/url" ) type PublicationFactory struct { FallbackTitle string PackageDocument PackageDocument NavigationData map[string]manifest.LinkList - EncryptionData map[url.URL]manifest.Encryption + EncryptionData map[string]manifest.Encryption DisplayOptions map[string]string itemById map[string]Item @@ -193,7 +192,7 @@ func (f PublicationFactory) computePropertiesAndRels(item Item, itemref *ItemRef rels = extensions.AddToSet(rels, "cover") } - if edat, ok := f.EncryptionData[item.Href]; ok { + if edat, ok := f.EncryptionData[item.Href.Normalize().String()]; ok { properties["encrypted"] = edat.ToMap() // ToMap makes it JSON-like } diff --git a/pkg/parser/epub/parser.go b/pkg/parser/epub/parser.go index 93e5f668..fa66031a 100644 --- a/pkg/parser/epub/parser.go +++ b/pkg/parser/epub/parser.go @@ -9,8 +9,8 @@ import ( "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/protection" "github.com/readium/go-toolkit/pkg/pub" - "github.com/readium/go-toolkit/pkg/util/url" ) type Parser struct { @@ -51,11 +51,27 @@ func (p Parser) Parse(ctx context.Context, asset asset.PublicationAsset, f fetch return nil, errors.Wrap(err, "invalid OPF file") } + // Detect the container-level DRM scheme. This is done unconditionally, + // not gated on the presence of META-INF/encryption.xml, because schemes + // like Adobe ADEPT, Barnes & Noble, Apple FairPlay and Kobo announce + // themselves through other well-known files (rights.xml / sinf.xml). + // TODO: surface the publication-level scheme on the manifest itself so + // consumers can detect protection even when encryption.xml is absent. + scheme, err := protection.IdentifyEPUBProtection(ctx, f) + if err != nil { + return nil, errors.Wrap(err, "failed identifying EPUB protection scheme") + } + + encryptionData, err := parseEncryptionData(ctx, f, scheme.URI()) + if err != nil { + return nil, errors.Wrap(err, "failed parsing encryption data") + } + manifest := PublicationFactory{ FallbackTitle: fallbackTitle, PackageDocument: *packageDocument, NavigationData: parseNavigationData(ctx, *packageDocument, f), - EncryptionData: parseEncryptionData(ctx, f), + EncryptionData: encryptionData, DisplayOptions: parseDisplayOptions(ctx, f), }.Create() @@ -74,12 +90,16 @@ func (p Parser) Parse(ctx context.Context, asset asset.PublicationAsset, f fetch return pub.NewBuilder(manifest, ffetcher, builder), nil } -func parseEncryptionData(ctx context.Context, f fetcher.Fetcher) (ret map[url.URL]manifest.Encryption) { - n, err := fetcher.ReadResourceAsXML(ctx, f.Get(ctx, manifest.Link{Href: manifest.MustNewHREFFromString("META-INF/encryption.xml", false)})) - if err != nil { - return +// parseEncryptionData parses META-INF/encryption.xml when present and stamps +// each entry with the supplied DRM scheme URI (typically obtained by calling +// [protection.IdentifyEPUBProtection] at a higher level). A missing +// encryption.xml is normal and returns (nil, nil). +func parseEncryptionData(ctx context.Context, f fetcher.Fetcher, scheme string) (map[string]manifest.Encryption, error) { + n, rerr := fetcher.ReadResourceAsXML(ctx, f.Get(ctx, manifest.Link{Href: manifest.MustNewHREFFromString("META-INF/encryption.xml", false)})) + if rerr != nil { + return nil, nil } - return ParseEncryption(n) + return ParseEncryption(n, scheme), nil } func parseNavigationData(ctx context.Context, packageDocument PackageDocument, f fetcher.Fetcher) (ret map[string]manifest.LinkList) { diff --git a/pkg/parser/epub/parser_encryption.go b/pkg/parser/epub/parser_encryption.go index 9d74a148..19fdb411 100644 --- a/pkg/parser/epub/parser_encryption.go +++ b/pkg/parser/epub/parser_encryption.go @@ -5,7 +5,6 @@ import ( "github.com/antchfx/xmlquery" "github.com/readium/go-toolkit/pkg/manifest" - "github.com/readium/go-toolkit/pkg/protection" "github.com/readium/go-toolkit/pkg/util/url" ) @@ -13,51 +12,38 @@ var ( xpEncEncData = mustCompileNS("//enc:EncryptedData") xpEncCipherData = mustCompileNS("enc:CipherData") xpEncCipherRef = mustCompileNS("enc:CipherReference") - xpEncKeyInfo = mustCompileNS("ds:KeyInfo") - xpEncRetrieval = mustCompileNS("ds:RetrievalMethod") xpEncMethod = mustCompileNS("enc:EncryptionMethod") xpEncProps = mustCompileNS("enc:EncryptionProperties") xpEncProp = mustCompileNS("enc:EncryptionProperty") xpEncCompress = mustCompileNS("comp:Compression") ) -func ParseEncryption(document *xmlquery.Node) (ret map[url.URL]manifest.Encryption) { +func ParseEncryption(document *xmlquery.Node, scheme string) (ret map[string]manifest.Encryption) { for _, node := range xmlquery.QuerySelectorAll(document, xpEncEncData) { - u, e := parseEncryptedData(node) + key, e := parseEncryptedData(node, scheme) if e != nil { if ret == nil { - ret = make(map[url.URL]manifest.Encryption) + ret = make(map[string]manifest.Encryption) } - ret[u] = *e + ret[key] = *e } } return } -func parseEncryptedData(node *xmlquery.Node) (url.URL, *manifest.Encryption) { +func parseEncryptedData(node *xmlquery.Node, scheme string) (string, *manifest.Encryption) { cdat := xmlquery.QuerySelector(node, xpEncCipherData) if cdat == nil { - return nil, nil + return "", nil } cipherref := xmlquery.QuerySelector(cdat, xpEncCipherRef) if cipherref == nil { - return nil, nil + return "", nil } resourceURI := cipherref.SelectAttr("URI") - retrievalMethod := "" - if keyinfo := xmlquery.QuerySelector(node, xpEncKeyInfo); keyinfo != nil { - if r := xmlquery.QuerySelector(keyinfo, xpEncRetrieval); r != nil { - retrievalMethod = r.SelectAttr("URI") - } - } - ret := &manifest.Encryption{ - // TODO: No profile? https://github.com/readium/kotlin-toolkit/blob/develop/readium/streamer/src/main/java/org/readium/r2/streamer/parser/epub/EncryptionParser.kt#L40 - } - - if retrievalMethod == "license.lcpl#/encryption/content_key" { - ret.Scheme = protection.SchemeLCP + Scheme: scheme, } if encryptionmethod := xmlquery.QuerySelector(node, xpEncMethod); encryptionmethod != nil { @@ -74,10 +60,10 @@ func parseEncryptedData(node *xmlquery.Node) (url.URL, *manifest.Encryption) { ru, err := url.FromEPUBHref(resourceURI) if err != nil { - return nil, nil + return "", nil } - return ru, ret + return ru.Normalize().String(), ret } func parseEncryptionProperties(encryptionProperties *xmlquery.Node) (int64, string) { diff --git a/pkg/parser/epub/parser_encryption_test.go b/pkg/parser/epub/parser_encryption_test.go index 6b32dd52..cce8557e 100644 --- a/pkg/parser/epub/parser_encryption_test.go +++ b/pkg/parser/epub/parser_encryption_test.go @@ -6,24 +6,19 @@ import ( "github.com/readium/go-toolkit/pkg/fetcher" "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/protection" "github.com/readium/go-toolkit/pkg/util/url" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func loadEncryption(ctx context.Context, name string) (map[string]manifest.Encryption, error) { +func loadEncryption(ctx context.Context, name string, scheme protection.Scheme) (map[string]manifest.Encryption, error) { n, rerr := fetcher.ReadResourceAsXML(ctx, fetcher.NewFileResource(manifest.Link{}, "./testdata/encryption/encryption-"+name+".xml")) if rerr != nil { return nil, rerr.Cause } - enc := ParseEncryption(n) - ret := make(map[string]manifest.Encryption) - for k, v := range enc { - ret[k.String()] = v - } - - return ret, nil + return ParseEncryption(n, scheme.URI()), nil } var testEncMap = map[string]manifest.Encryption{ @@ -42,19 +37,19 @@ var testEncMap = map[string]manifest.Encryption{ } func TestEncryptionParserNamespacePrefixes(t *testing.T) { - e, err := loadEncryption(t.Context(), "lcp-prefixes") + e, err := loadEncryption(t.Context(), "lcp-prefixes", protection.LCP) require.NoError(t, err) assert.Equal(t, testEncMap, e) } func TestEncryptionParserDefaultNamespaces(t *testing.T) { - e, err := loadEncryption(t.Context(), "lcp-xmlns") + e, err := loadEncryption(t.Context(), "lcp-xmlns", protection.LCP) require.NoError(t, err) assert.Equal(t, testEncMap, e) } func TestEncryptionParserUnknownRetrievalMethod(t *testing.T) { - e, err := loadEncryption(t.Context(), "unknown-method") + e, err := loadEncryption(t.Context(), "unknown-method", protection.NoDRM) require.NoError(t, err) assert.Equal(t, map[string]manifest.Encryption{ url.MustURLFromString("OEBPS/images/image.jpeg").String(): { diff --git a/pkg/parser/epub/parser_test.go b/pkg/parser/epub/parser_test.go new file mode 100644 index 00000000..94a93373 --- /dev/null +++ b/pkg/parser/epub/parser_test.go @@ -0,0 +1,125 @@ +package epub + +import ( + "context" + "errors" + "testing" + + "github.com/readium/go-toolkit/pkg/asset" + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/mediatype" + "github.com/readium/go-toolkit/pkg/protection" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeEPUBAsset is a minimal [asset.PublicationAsset] for parser tests. +// CreateFetcher is never invoked because the tests construct the fetcher +// themselves and pass it to [Parser.Parse] directly. +type fakeEPUBAsset struct{ name string } + +func (a fakeEPUBAsset) Name() string { return a.name } +func (a fakeEPUBAsset) MediaType(context.Context) mediatype.MediaType { return mediatype.EPUB } +func (a fakeEPUBAsset) CreateFetcher(context.Context, asset.Dependencies, string) (fetcher.Fetcher, error) { + return nil, errors.New("unused in tests") +} + +func openProtectionFixture(t *testing.T, file string) *fetcher.ArchiveFetcher { + t.Helper() + f, err := fetcher.NewArchiveFetcherFromPath(t.Context(), "../../protection/testdata/"+file) + require.NoError(t, err) + t.Cleanup(f.Close) + return f +} + +// TestParserEndToEnd exercises [Parser.Parse] against every DRM fixture in +// pkg/protection/testdata, asserting the parse succeeds and surfaces the +// expected publication metadata. expectedScheme is the manifest.Encryption +// scheme URI that every encrypted resource should carry — empty means either +// no encryption.xml is present (so no resource should be tagged) or the +// encryption is generic (no DRM scheme attached). +func TestParserEndToEnd(t *testing.T) { + for _, tt := range []struct { + name string + file string + title string + readingOrderSize int + hasEncryptionXML bool + expectedScheme string + }{ + {"Adobe ADEPT", "fake-adept.epub", "Fake Adept DRM", 1, false, ""}, + {"Barnes & Noble", "fake-bn.epub", "Fake B&N DRM", 1, false, ""}, + {"Apple FairPlay", "fake-fairplay.epub", "Fake Fairplay DRM", 1, false, ""}, + {"Kobo", "fake-kobo.epub", "Fake Kobo DRM", 1, false, ""}, + {"Readium LCP", "fake-lcp.epub", "The Level 999 Villager Chapter 3", 35, true, protection.SchemeLCP}, + {"Generic encryption (Yahoo)", "yahoo.ypub", "週刊少年マガジン 2019年8号[2019年1月23日発売]", 540, true, ""}, + } { + t.Run(tt.name, func(t *testing.T) { + f := openProtectionFixture(t, tt.file) + builder, err := NewParser(nil).Parse(t.Context(), fakeEPUBAsset{name: tt.file}, f) + require.NoError(t, err) + require.NotNil(t, builder) + + m := builder.Manifest + assert.Equal(t, tt.title, m.Metadata.Title()) + assert.Lenf(t, m.ReadingOrder, tt.readingOrderSize, + "expected reading order size %d, got %d", tt.readingOrderSize, len(m.ReadingOrder)) + + encryptedCount := 0 + for _, link := range append(m.ReadingOrder, m.Resources...) { + enc, ok := link.Properties["encrypted"].(map[string]interface{}) + if !ok { + continue + } + encryptedCount++ + if tt.expectedScheme == "" { + _, hasScheme := enc["scheme"] + assert.Falsef(t, hasScheme, "%s should not have a scheme", link.Href.String()) + } else { + assert.Equalf(t, tt.expectedScheme, enc["scheme"], "scheme mismatch for %s", link.Href.String()) + } + } + if tt.hasEncryptionXML { + assert.Greater(t, encryptedCount, 0, "expected at least one resource to carry encryption properties") + } else { + assert.Zero(t, encryptedCount, "no resource should carry encryption properties") + } + }) + } +} + +// TestParseEncryptionDataScheme exercises the same two-step the Parser uses: +// [protection.IdentifyEPUBProtection] followed by parseEncryptionData, and +// verifies the detected scheme reaches every encryption.xml entry. +func TestParseEncryptionDataScheme(t *testing.T) { + for _, tt := range []struct { + name string + file string + expectScheme string + expectEntries bool + }{ + {"Readium LCP", "fake-lcp.epub", protection.SchemeLCP, true}, + {"Generic encryption (Yahoo)", "yahoo.ypub", "", true}, + // EPUBs without META-INF/encryption.xml yield no entries at all. + {"Adobe ADEPT (no encryption.xml)", "fake-adept.epub", "", false}, + {"Kobo (no encryption.xml)", "fake-kobo.epub", "", false}, + } { + t.Run(tt.name, func(t *testing.T) { + f := openProtectionFixture(t, tt.file) + + scheme, err := protection.IdentifyEPUBProtection(t.Context(), f) + require.NoError(t, err) + + enc, err := parseEncryptionData(t.Context(), f, scheme.URI()) + require.NoError(t, err) + if !tt.expectEntries { + assert.Empty(t, enc) + return + } + require.NotEmpty(t, enc) + for u, e := range enc { + assert.Equalf(t, tt.expectScheme, e.Scheme, "scheme mismatch for %s", u) + } + }) + } +} diff --git a/pkg/protection/drm.go b/pkg/protection/drm.go deleted file mode 100644 index 8e7f1157..00000000 --- a/pkg/protection/drm.go +++ /dev/null @@ -1,14 +0,0 @@ -package protection - -const ( - SchemeLCP = "http://readium.org/2014/01/lcp" - SchemeAdept = "http://ns.adobe.com/adept" -) - -// TODO replace with ContentProtection API -/* type DRMLicense interface { - EncryptionProfile() string - Decipher(data []byte) []byte - CanCopy() bool - Copy(text string) string -} */ diff --git a/pkg/protection/epub.go b/pkg/protection/epub.go new file mode 100644 index 00000000..78d5d212 --- /dev/null +++ b/pkg/protection/epub.go @@ -0,0 +1,142 @@ +package protection + +import ( + "context" + "strings" + + "github.com/antchfx/xmlquery" + "github.com/antchfx/xpath" + "github.com/pkg/errors" + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/util/url" +) + +// Well-known XML namespaces used by EPUB DRM containers. +const ( + namespaceENC = "http://www.w3.org/2001/04/xmlenc#" + namespaceSIG = "http://www.w3.org/2000/09/xmldsig#" +) + +// Well-known file paths used by EPUB DRM containers. +const ( + pathLCPLicense = "META-INF/license.lcpl" + pathEncryption = "META-INF/encryption.xml" + pathAdeptRights = "META-INF/rights.xml" + pathFairplaySinf = "META-INF/sinf.xml" + pathKoboRights = "rights.xml" + lcpRetrievalURI = "license.lcpl#/encryption/content_key" + barnesAndNobleTag = "barnesandnoble" +) + +var xmlNS = map[string]string{ + "enc": namespaceENC, + "ds": namespaceSIG, + "adept": "http://ns.adobe.com/adept", + "fairplay": "http://itunes.apple.com/ns/epub", +} + +var ( + xpLCPRetrieval = mustCompileNS(`//enc:EncryptedData/ds:KeyInfo/ds:RetrievalMethod[@URI="` + lcpRetrievalURI + `"]`) + xpAdeptOperator = mustCompileNS("//adept:operatorURL") + xpFairplaySinf = mustCompileNS("//fairplay:sinf") + xpKdrm = xpath.MustCompile("//kdrm") +) + +func mustCompileNS(expr string) *xpath.Expr { + e, err := xpath.CompileWithNS(expr, xmlNS) + if err != nil { + panic("protection: invalid xpath " + expr + ": " + err.Error()) + } + return e +} + +// IdentifyEPUBProtection inspects the well-known DRM metadata files inside an +// EPUB container and returns the detected protection [Scheme]. Returns [NoDRM] +// when no protection metadata is present. +func IdentifyEPUBProtection(ctx context.Context, f fetcher.Fetcher) (Scheme, error) { + links, err := f.Links(ctx) + if err != nil { + return NoDRM, err + } + + hasLink := func(path string) (*manifest.Link, bool) { + u, uerr := url.URLFromString(path) + if uerr != nil { + return nil, false + } + l := links.FirstWithHref(u) + return l, l != nil + } + + readXML := func(link *manifest.Link) (*xmlquery.Node, error) { + doc, rerr := fetcher.ReadResourceAsXML(ctx, f.Get(ctx, *link)) + if rerr != nil { + if rerr.Code == fetcher.CodeInternalServerError { + return nil, nil + } + return nil, errors.Wrap(rerr.Cause, "unable to read "+link.Href.String()) + } + return doc, nil + } + + // LCP: presence of the license file is the strongest signal. + if _, ok := hasLink(pathLCPLicense); ok { + return LCP, nil + } + + // Apple FairPlay: META-INF/sinf.xml containing . + if link, ok := hasLink(pathFairplaySinf); ok { + doc, derr := readXML(link) + if derr != nil { + return NoDRM, derr + } + if doc != nil && xmlquery.QuerySelector(doc, xpFairplaySinf) != nil { + return Fairplay, nil + } + } + + // Adobe ADEPT (and Barnes & Noble): META-INF/rights.xml with . + if link, ok := hasLink(pathAdeptRights); ok { + doc, derr := readXML(link) + if derr != nil { + return NoDRM, derr + } + if doc != nil { + if op := xmlquery.QuerySelector(doc, xpAdeptOperator); op != nil { + if strings.Contains(strings.ToLower(op.InnerText()), barnesAndNobleTag) { + return BarnesAndNoble, nil + } + return Adept, nil + } + } + } + + // Kobo: rights.xml at the container root containing . + if link, ok := hasLink(pathKoboRights); ok { + doc, derr := readXML(link) + if derr != nil { + return NoDRM, derr + } + if doc != nil && xmlquery.QuerySelector(doc, xpKdrm) != nil { + return Kobo, nil + } + } + + // Fall back to META-INF/encryption.xml: it may reveal LCP via the + // retrieval method, or indicate generic/unknown encryption otherwise. + if link, ok := hasLink(pathEncryption); ok { + doc, derr := readXML(link) + if derr != nil { + return NoDRM, derr + } + if doc != nil { + if xmlquery.QuerySelector(doc, xpLCPRetrieval) != nil { + return LCP, nil + } + return Generic, nil + } + } + + return NoDRM, nil +} diff --git a/pkg/protection/epub_test.go b/pkg/protection/epub_test.go new file mode 100644 index 00000000..30da86ea --- /dev/null +++ b/pkg/protection/epub_test.go @@ -0,0 +1,40 @@ +package protection + +import ( + "testing" + + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIdentifyEPUBProtection(t *testing.T) { + for _, tt := range []struct { + name string + file string + want Scheme + }{ + {"Readium LCP", "fake-lcp.epub", LCP}, + {"Adobe ADEPT", "fake-adept.epub", Adept}, + {"Barnes & Noble", "fake-bn.epub", BarnesAndNoble}, + {"Apple FairPlay", "fake-fairplay.epub", Fairplay}, + {"Kobo", "fake-kobo.epub", Kobo}, + {"Generic encryption", "yahoo.ypub", Generic}, + } { + t.Run(tt.name, func(t *testing.T) { + f, err := fetcher.NewArchiveFetcherFromPath(t.Context(), "./testdata/"+tt.file) + require.NoError(t, err) + defer f.Close() + + got, err := IdentifyEPUBProtection(t.Context(), f) + require.NoError(t, err) + assert.Equal(t, tt.want, got, "unexpected scheme for %s", tt.file) + }) + } +} + +func TestIdentifyEPUBProtectionNoDRM(t *testing.T) { + got, err := IdentifyEPUBProtection(t.Context(), fetcher.EmptyFetcher{}) + require.NoError(t, err) + assert.Equal(t, NoDRM, got) +} diff --git a/pkg/protection/protection.go b/pkg/protection/protection.go new file mode 100644 index 00000000..f60f13a6 --- /dev/null +++ b/pkg/protection/protection.go @@ -0,0 +1,9 @@ +package protection + +const ( + SchemeLCP = "http://readium.org/2014/01/lcp" + SchemeAdept = "http://ns.adobe.com/adept" + SchemeFairPlay = "http://itunes.apple.com/ns/epub" +) + +// TODO: ContentProtection API diff --git a/pkg/protection/testdata/fake-adept.epub b/pkg/protection/testdata/fake-adept.epub new file mode 100644 index 0000000000000000000000000000000000000000..63d955b912065d605ce1c6dda403d811a3a1ad6f GIT binary patch literal 4216 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 w#8Isd4hZO&_#P6MiXHT#G1IeoNH2?qr literal 0 HcmV?d00001 diff --git a/pkg/protection/testdata/fake-bn.epub b/pkg/protection/testdata/fake-bn.epub new file mode 100644 index 0000000000000000000000000000000000000000..785b1d128897ae3a7504000a5683f728d773355a GIT binary patch literal 4227 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 literal 0 HcmV?d00001 diff --git a/pkg/protection/testdata/fake-lcp.epub b/pkg/protection/testdata/fake-lcp.epub new file mode 100644 index 0000000000000000000000000000000000000000..c92f255a62223938630bcf7dbbec5c03b05dddca GIT binary patch literal 4260 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 Scheme(len(_Scheme_index)-1) { + return "Scheme(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Scheme_name[_Scheme_index[i]:_Scheme_index[i+1]] +} From c463e8f86a681918382188f2ff71cb0cf07b9304 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 1 Jun 2026 01:32:13 -0700 Subject: [PATCH 2/4] push go.mod update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f24b1ec9..5aec7776 100644 --- a/go.mod +++ b/go.mod @@ -13,13 +13,13 @@ require ( github.com/aws/smithy-go v1.25.1 github.com/azr/phash v0.2.0 github.com/bbrks/go-blurhash v1.2.0 - github.com/chocolatkey/gzran v0.0.0-20251204101541-d8891e235711 github.com/deckarep/golang-set v1.8.0 github.com/disintegration/imaging v1.6.2 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/kettek/apng v0.0.0-20220823221153-ff692776a607 github.com/pdfcpu/pdfcpu v0.12.1 github.com/pkg/errors v0.9.1 + github.com/readium/zran v0.0.0-20260525212206-dcf56adb2c0e github.com/relvacode/iso8601 v1.7.0 github.com/stretchr/testify v1.11.1 github.com/trimmer-io/go-xmp v1.0.0 diff --git a/go.sum b/go.sum index f9d0d42d..b366b5e6 100644 --- a/go.sum +++ b/go.sum @@ -86,8 +86,6 @@ github.com/bbrks/go-blurhash v1.2.0/go.mod h1:r4N4/ViVMa2h6Ex6e1aoCWMTkykYWS/VXv github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chocolatkey/gzran v0.0.0-20251204101541-d8891e235711 h1:KXBH2rdtVs70qr55arSwgrXZq6QasYgox1GbYdi3kRg= -github.com/chocolatkey/gzran v0.0.0-20251204101541-d8891e235711/go.mod h1:jk2T+gAWOv82T5A5XU+h/bA+9ngcj+DkHNrP/Ktyt88= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -199,6 +197,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/readium/zran v0.0.0-20260525212206-dcf56adb2c0e h1:DQJkuzoHhmE+FGtWRanEanh2IpiXRxwmR4OgQwiD5vA= +github.com/readium/zran v0.0.0-20260525212206-dcf56adb2c0e/go.mod h1:3znXhDJYPPRNT6KgvPVhTmzAiqUqzEPKUPhPXrPSdB0= github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo= github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= From e00909e915c8abd8d867eb68de68801617be4359 Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 6 Jun 2026 00:14:00 -0700 Subject: [PATCH 3/4] Improved audiobook parsing (#309) --- .gitattributes | 8 + .github/workflows/build.yml | 5 + .gitignore | 4 +- go.mod | 14 +- go.sum | 34 +- pkg/analyzer/image_test.go | 2 +- pkg/fetcher/fetcher_file.go | 3 +- pkg/fetcher/fetcher_file_test.go | 2 +- pkg/fetcher/fetcher_http.go | 3 +- pkg/fetcher/reader.go | 18 +- pkg/manifest/a11y.go | 2 +- pkg/manifest/manifest.go | 10 +- pkg/mediatype/sniffer.go | 31 +- pkg/mediatype/types.go | 11 +- pkg/mediatype/types_matcher.go | 26 +- pkg/parser/audio/cover.go | 80 +++ pkg/parser/audio/duration_formats.go | 556 ++++++++++++++++++ pkg/parser/audio/duration_probe_test.go | 149 +++++ pkg/parser/audio/formats_test.go | 179 ++++++ pkg/parser/audio/metadata.go | 111 ++++ pkg/parser/audio/mp4.go | 366 ++++++++++++ pkg/parser/audio/ogg.go | 122 ++++ pkg/parser/audio/parser.go | 198 +++++++ pkg/parser/audio/parser_test.go | 414 +++++++++++++ pkg/parser/audio/playlist.go | 230 ++++++++ pkg/parser/audio/probe.go | 120 ++++ pkg/parser/audio/readcache.go | 166 ++++++ pkg/parser/audio/readcache_test.go | 99 ++++ pkg/parser/audio/rich.go | 244 ++++++++ .../testdata/AroundTheWorldInEightyDays.m4b | 3 + pkg/parser/audio/testdata/art_letters.zab | 3 + .../art_letters/artofletters_00_lynd.opus | 3 + .../art_letters/artofletters_01_lynd.opus | 3 + .../art_letters/artofletters_02_lynd.opus | 3 + pkg/parser/audio/testdata/luvsic.cue | 71 +++ pkg/parser/audio/testdata/luvsic.m3u | 27 + pkg/parser/audio/toc.go | 108 ++++ .../{parser_image.go => image/parser.go} | 9 +- .../parser_test.go} | 2 +- .../image/testdata/image/futuristic_tales.cbz | 3 + .../testdata/image/futuristic_tales.jpg | Bin pkg/parser/parser_audio.go | 113 ---- .../testdata/image/futuristic_tales.cbz | Bin 719861 -> 0 bytes pkg/parser/utils.go | 11 +- .../parser.go} | 12 +- pkg/streamer/a11y_infer_test.go | 18 +- pkg/streamer/streamer.go | 9 +- 47 files changed, 3407 insertions(+), 198 deletions(-) create mode 100644 .gitattributes create mode 100644 pkg/parser/audio/cover.go create mode 100644 pkg/parser/audio/duration_formats.go create mode 100644 pkg/parser/audio/duration_probe_test.go create mode 100644 pkg/parser/audio/formats_test.go create mode 100644 pkg/parser/audio/metadata.go create mode 100644 pkg/parser/audio/mp4.go create mode 100644 pkg/parser/audio/ogg.go create mode 100644 pkg/parser/audio/parser.go create mode 100644 pkg/parser/audio/parser_test.go create mode 100644 pkg/parser/audio/playlist.go create mode 100644 pkg/parser/audio/probe.go create mode 100644 pkg/parser/audio/readcache.go create mode 100644 pkg/parser/audio/readcache_test.go create mode 100644 pkg/parser/audio/rich.go create mode 100644 pkg/parser/audio/testdata/AroundTheWorldInEightyDays.m4b create mode 100644 pkg/parser/audio/testdata/art_letters.zab create mode 100644 pkg/parser/audio/testdata/art_letters/artofletters_00_lynd.opus create mode 100644 pkg/parser/audio/testdata/art_letters/artofletters_01_lynd.opus create mode 100644 pkg/parser/audio/testdata/art_letters/artofletters_02_lynd.opus create mode 100644 pkg/parser/audio/testdata/luvsic.cue create mode 100644 pkg/parser/audio/testdata/luvsic.m3u create mode 100644 pkg/parser/audio/toc.go rename pkg/parser/{parser_image.go => image/parser.go} (94%) rename pkg/parser/{parser_image_test.go => image/parser_test.go} (99%) create mode 100644 pkg/parser/image/testdata/image/futuristic_tales.cbz rename pkg/parser/{ => image}/testdata/image/futuristic_tales.jpg (100%) delete mode 100644 pkg/parser/parser_audio.go delete mode 100644 pkg/parser/testdata/image/futuristic_tales.cbz rename pkg/parser/{parser_readium_webpub.go => webpub/parser.go} (85%) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8ead7d05 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +*.psd filter=lfs diff=lfs merge=lfs -text +*.zab filter=lfs diff=lfs merge=lfs -text +*.opus filter=lfs diff=lfs merge=lfs -text +*.audiobook filter=lfs diff=lfs merge=lfs -text +*.webpub filter=lfs diff=lfs merge=lfs -text +*.m4b filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.cbz filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 654d1696..72c7d67f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,6 +20,11 @@ jobs: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + lfs: true + + - name: Pull LFS objects + run: git lfs pull - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.gitignore b/.gitignore index e60b3492..c112c1aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ +.DS_Store publications/* -*.old \ No newline at end of file +*.old +test/ \ No newline at end of file diff --git a/go.mod b/go.mod index 5aec7776..0ca139f4 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( cloud.google.com/go/storage v1.62.2 + github.com/abema/go-mp4 v1.6.0 github.com/agext/regexp v1.3.0 github.com/andybalholm/cascadia v1.3.3 github.com/antchfx/xmlquery v1.5.1 @@ -14,6 +15,7 @@ require ( github.com/azr/phash v0.2.0 github.com/bbrks/go-blurhash v1.2.0 github.com/deckarep/golang-set v1.8.0 + github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 github.com/disintegration/imaging v1.6.2 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/kettek/apng v0.0.0-20220823221153-ff692776a607 @@ -25,9 +27,10 @@ require ( github.com/trimmer-io/go-xmp v1.0.0 go4.org v0.0.0-20230225012048-214862532bf5 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 - golang.org/x/image v0.39.0 - golang.org/x/net v0.52.0 - golang.org/x/text v0.36.0 + golang.org/x/image v0.41.0 + golang.org/x/net v0.55.0 + golang.org/x/sync v0.20.0 + golang.org/x/text v0.37.0 google.golang.org/api v0.274.0 ) @@ -80,10 +83,9 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - golang.org/x/crypto v0.50.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index b366b5e6..81541707 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/abema/go-mp4 v1.6.0 h1:aXw6240IdFHH5laJuiN992nWMHFkPAREm0yCTAFsceE= +github.com/abema/go-mp4 v1.6.0/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= github.com/agext/regexp v1.3.0 h1:6+9tp+S41TU48gFNV47bX+pp1q7WahGofw6JccmsCDs= github.com/agext/regexp v1.3.0/go.mod h1:6phv1gViOJXWcTfpxOi9VMS+MaSAo+SUDf7do3ur1HA= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -92,11 +94,15 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4= github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= +github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg= +github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -158,6 +164,7 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= @@ -184,9 +191,12 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= +github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= github.com/pdfcpu/pdfcpu v0.12.1 h1:HwoN72zJCj+pPbfMDChYBTZrT7SY0VwgUzqeaId3I20= github.com/pdfcpu/pdfcpu v0.12.1/go.mod h1:7KPpVLMavcpliPrtN6o7Kuk3cFtYq8nii3SJnnsK7ps= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -212,6 +222,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= github.com/trimmer-io/go-xmp v1.0.0 h1:zY8bolSga5kOjBAaHS6hrdxLgEoYuT875xTy0QDwZWs= github.com/trimmer-io/go-xmp v1.0.0/go.mod h1:Aaptr9sp1lLv7UnCAdQ+gSHZyY2miYaKmcNVj7HRBwA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -250,8 +261,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -265,8 +276,8 @@ golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5N golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -309,8 +320,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -354,8 +365,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -377,8 +388,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -466,7 +477,10 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/analyzer/image_test.go b/pkg/analyzer/image_test.go index 0783d6a7..309d04f5 100644 --- a/pkg/analyzer/image_test.go +++ b/pkg/analyzer/image_test.go @@ -91,7 +91,7 @@ func TestMatchImage(t *testing.T) { ok, err := MatchImage(manifest.Link{ Href: manifest.MustNewHREFFromString("audio.mp3", false), - MediaType: &mediatype.MP3, + MediaType: &mediatype.MPEGAudio, }, manifest.HashList{}) require.ErrorContains(t, err, "link is not to an image that can be matched") require.False(t, ok) diff --git a/pkg/fetcher/fetcher_file.go b/pkg/fetcher/fetcher_file.go index 1b9fe07a..d7c6ec92 100644 --- a/pkg/fetcher/fetcher_file.go +++ b/pkg/fetcher/fetcher_file.go @@ -43,7 +43,8 @@ func (f *FileFetcher) Links(ctx context.Context) (manifest.LinkList, error) { return err } - href, err := manifest.NewHREFFromString(filepath.ToSlash(filepath.Join(href, strings.TrimPrefix(apath, xpath))), false) + rel := strings.TrimPrefix(strings.TrimPrefix(apath, xpath), string(filepath.Separator)) + href, err := manifest.NewHREFFromString(filepath.ToSlash(filepath.Join(href, rel)), false) if err != nil { return err } diff --git a/pkg/fetcher/fetcher_file_test.go b/pkg/fetcher/fetcher_file_test.go index 966f85e1..32a9fd30 100644 --- a/pkg/fetcher/fetcher_file_test.go +++ b/pkg/fetcher/fetcher_file_test.go @@ -164,7 +164,7 @@ func TestFileFetcherLinks(t *testing.T) { mustContain := manifest.LinkList{{ Href: manifest.MustNewHREFFromString("dir_href/subdirectory/hello.mp3", false), - MediaType: &mediatype.MP3, + MediaType: &mediatype.MPEGAudio, }, { Href: manifest.MustNewHREFFromString("dir_href/subdirectory/text2.txt", false), MediaType: &mediatype.Text, diff --git a/pkg/fetcher/fetcher_http.go b/pkg/fetcher/fetcher_http.go index 18244f0f..dc3e87f1 100644 --- a/pkg/fetcher/fetcher_http.go +++ b/pkg/fetcher/fetcher_http.go @@ -171,7 +171,8 @@ func (r *httpResource) Read(ctx context.Context, start int64, end int64) ([]byte if err != nil { return nil, Other(err) } - if resp.StatusCode != http.StatusPartialContent { + + if (start == 0 && end == 0 && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent) || ((start != 0 || end != 0) && resp.StatusCode != http.StatusPartialContent) { ex := httpStatusToException(resp.StatusCode) if ex == nil { return nil, Other(errors.New("unexpected HTTP status code: " + strconv.Itoa(resp.StatusCode))) diff --git a/pkg/fetcher/reader.go b/pkg/fetcher/reader.go index b020df20..eee1829d 100644 --- a/pkg/fetcher/reader.go +++ b/pkg/fetcher/reader.go @@ -3,6 +3,7 @@ package fetcher import ( "context" "errors" + "io" ) // For opening a fetcher.Resource as a io.ReadSeeker @@ -51,14 +52,21 @@ func (rs *ResourceReadSeeker) Seek(offset int64, whence int) (int64, error) { } } -// Seek implements io.ReadSeeker +// Read implements io.Reader func (rs *ResourceReadSeeker) Read(p []byte) (n int, err error) { - bin, errx := rs.r.Read(context.TODO(), rs.offset, rs.offset+int64(len(p))) + if len(p) == 0 { + return 0, nil + } + bin, errx := rs.r.Read(context.TODO(), rs.offset, rs.offset+int64(len(p))-1) if errx != nil { - err = errx - return + return 0, errx } n = copy(p, bin) rs.offset += int64(n) - return + if n == 0 { + // No more bytes available: signal end-of-file so that consumers relying + // on the io.Reader contract (e.g. io.ReadFull/io.CopyN) terminate. + return 0, io.EOF + } + return n, nil } diff --git a/pkg/manifest/a11y.go b/pkg/manifest/a11y.go index 69d00be3..b830db03 100644 --- a/pkg/manifest/a11y.go +++ b/pkg/manifest/a11y.go @@ -17,7 +17,7 @@ type A11y struct { Certification *A11yCertification `json:"certification,omitempty"` // Certification of accessible publications. Summary string `json:"summary,omitempty"` // A human-readable summary of specific accessibility features or deficiencies, consistent with the other accessibility metadata but expressing subtleties such as "short descriptions are present but long descriptions will be needed for non-visual users" or "short descriptions are present and no long descriptions are needed." AccessModes []A11yAccessMode `json:"accessMode,omitempty"` // The human sensory perceptual system or cognitive faculty through which a person may process or perceive information. - AccessModesSufficient [][]A11yPrimaryAccessMode `json:"accessModeSufficient,omitempty"` // A list of single or combined accessModes that are sufficient to understand all the intellectual content of a resource. + AccessModesSufficient [][]A11yPrimaryAccessMode `json:"accessModeSufficient,omitempty"` // A list of single or combined accessModes that are sufficient to understand all the intellectual content of a resource. Features []A11yFeature `json:"feature,omitempty"` // Content features of the resource, such as accessible media, alternatives and supported enhancements for accessibility. Hazards []A11yHazard `json:"hazard,omitempty"` // A characteristic of the described resource that is physiologically dangerous to some users. Exemptions []A11yExemption `json:"exemption,omitempty"` // Justifications for non-conformance based on exemptions in a given jurisdiction. diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index 1ac4de96..8b28acc2 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -29,8 +29,14 @@ func (m Manifest) ConformsTo(profile Profile) bool { return false } + // Maybe we shouldn't trust the author of a WebPub? Who knows + if slices.Contains(m.Metadata.ConformsTo, profile) { + return true + } + switch profile { case ProfileAudiobook: + // Note that we aren't checking for duration or bitrate as the profile requires return m.ReadingOrder.AllAreAudio() case ProfileDivina: return m.ReadingOrder.AllAreBitmap() @@ -41,10 +47,6 @@ func (m Manifest) ConformsTo(profile Profile) bool { } case ProfilePDF: return m.ReadingOrder.AllMatchMediaType(&mediatype.PDF) - default: - if slices.Contains(m.Metadata.ConformsTo, profile) { - return true - } } return false } diff --git a/pkg/mediatype/sniffer.go b/pkg/mediatype/sniffer.go index ffabe5b8..5e3d54fa 100644 --- a/pkg/mediatype/sniffer.go +++ b/pkg/mediatype/sniffer.go @@ -81,9 +81,10 @@ func SniffOPDS(ctx context.Context, context SnifferContext) *MediaType { // OPDS 1 (Heavy) if cxml := context.ContentAsXML(); cxml != nil { if cxml.XMLName.Space == "http://www.w3.org/2005/Atom" { - if cxml.XMLName.Local == "feed" { + switch cxml.XMLName.Local { + case "feed": return &OPDS1 - } else if cxml.XMLName.Local == "entry" { + case "entry": return &OPDS1Entry } } @@ -114,7 +115,7 @@ func SniffLCPLicense(ctx context.Context, context SnifferContext) *MediaType { // Sniffs a bitmap image. func SniffBitmap(ctx context.Context, context SnifferContext) *MediaType { - if context.HasFileExtension("avif") || context.HasMediaType("image/avif") { + if context.HasFileExtension("avif", "avifs") || context.HasMediaType("image/avif") { return &AVIF } if context.HasFileExtension("bmp", "dib") || context.HasMediaType("image/bmp", "image/x-bmp") { @@ -149,24 +150,28 @@ func SniffAudio(ctx context.Context, context SnifferContext) *MediaType { if context.HasFileExtension("aac") || context.HasMediaType("audio/aac") { return &AAC } - if context.HasFileExtension("aiff") || context.HasMediaType("audio/aiff") { + if context.HasFileExtension("aiff", "aif", "aifc") || context.HasMediaType("audio/aiff") { return &AIFF } - // TODO flac, m4a + if context.HasFileExtension("flac") || context.HasMediaType("audio/flac") { + return &FLAC + } if context.HasFileExtension("mp3") || context.HasMediaType("audio/mpeg") { - return &MP3 + return &MPEGAudio + } + if context.HasFileExtension("mp4", "m4a", "m4b", "m4p", "m4r", "alac") || context.HasMediaType("audio/mp4") { + return &MP4 } - if context.HasFileExtension("ogg", "oga") || context.HasMediaType("audio/ogg") { + if context.HasFileExtension("ogg", "oga", "mogg") || context.HasMediaType("audio/ogg") { return &OGG } if context.HasFileExtension("opus") || context.HasMediaType("audio/opus") { return &OPUS } - if context.HasFileExtension("wav") || context.HasMediaType("audio/wav") { + if context.HasFileExtension("wav", "wave") || context.HasMediaType("audio/wav", "audio/x-wav", "audio/wave") { return &WAV } if context.HasFileExtension("webm") || context.HasMediaType("audio/webm") { - // Note: .webm extension could also be a video return &WEBMAudio } @@ -292,8 +297,12 @@ var cbz_extensions = map[string]struct{}{ // Authorized extensions for resources in a ZAB archive (Zipped Audio Book). var zab_extensions = map[string]struct{}{ - "aac": {}, "aiff": {}, "alac": {}, "flac": {}, "m4a": {}, "m4b": {}, "mp3": {}, "ogg": {}, "oga": {}, "mogg": {}, "opus": {}, "wav": {}, "webm": {}, // Audio - "asx": {}, "bio": {}, "m3u": {}, "m3u8": {}, "pla": {}, "pls": {}, "smil": {}, "vlc": {}, "wpl": {}, "xspf": {}, "zpl": {}, // Playlist + "aac": {}, "aiff": {}, "aif": {}, "aifc": {}, "alac": {}, "flac": {}, + "m4a": {}, "m4b": {}, "mp3": {}, "mp4": {}, "m4r": {}, "m4p": {}, + "ogg": {}, "oga": {}, "mogg": {}, "opus": {}, "wav": {}, "wave": {}, + "webm": {}, // Audio + "asx": {}, "bio": {}, "m3u": {}, "m3u8": {}, "pla": {}, "pls": {}, + "smil": {}, "vlc": {}, "wpl": {}, "xspf": {}, "zpl": {}, "cue": {}, "log": {}, // Playlist } // Sniffs a simple Archive-based format, like Comic Book Archive or Zipped Audio Book. diff --git a/pkg/mediatype/types.go b/pkg/mediatype/types.go index 169ed22a..a4a2dd5e 100644 --- a/pkg/mediatype/types.go +++ b/pkg/mediatype/types.go @@ -11,8 +11,9 @@ var CBZ, _ = New("application/vnd.comicbook+zip", "Comic Book ZIP Archive", "cbz var CBR, _ = New("application/vnd.comicbook-rar", "Comic Book RAR Archive", "cbr") var CSS, _ = New("text/css", "Cascading Style Sheets", "css") var EPUB, _ = New("application/epub+zip", "EPUB", "epub") +var FLAC, _ = New("audio/flac", "Free Lossless Audio Codec", "flac") var GIF, _ = New("image/gif", "GIF Image", "gif") -var GZ, _ = New("application/gzip", "GZipped content", "gz") +var GZ, _ = New("application/gzip", "GZipped data", "gz") var HTML, _ = New("text/html", "Hypertext Markup Language", "html") var JavaScript, _ = New("text/javascript", "JavaScript", "js") var JPEG, _ = New("image/jpeg", "JPEG Image", "jpeg") @@ -23,11 +24,12 @@ var LCPProtectedAudiobook, _ = New("application/audiobook+lcp", "LCP Protected A var LCPProtectedPDF, _ = New("application/pdf+lcp", "LCP Protected PDF", "lcpdf") var LCPStatusDocument, _ = New("application/vnd.readium.license.status.v1.0+json", "LCP Status Document", "") var LPF, _ = New("application/lpf+zip", "Lightweight Packaging Format", "lpf") -var MP3, _ = New("audio/mpeg", "MP3 Audio", "mp3") -var MPEG, _ = New("video/mpeg", "MPEG Video", "mpeg") +var MPEGAudio, _ = New("audio/mpeg", "MP3 Audio", "mp3") +var MP4, _ = New("audio/mp4", "MP4 Audio", "mp4") +var MPEGVideo, _ = New("video/mpeg", "MPEG Video", "mpeg") var NCX, _ = New("application/x-dtbncx+xml", "Navigation Control File", "ncx") var OGG, _ = New("audio/ogg", "OGG Audio", "oga") -var OGV, _ = New("video/ogg", "OGG Audio", "ogv") +var OGV, _ = New("video/ogg", "OGG Video", "ogv") var OPDS1, _ = New("application/atom+xml;profile=opds-catalog", "OPDS 1 Catalog", "") var OPDS1Entry, _ = New("application/atom+xml;type=entry;profile=opds-catalog", "OPDS 1 Catalog Entry", "") var OPDS2, _ = New("application/opds+json", "OPDS 2 Catalog", "") @@ -38,6 +40,7 @@ var OPUS, _ = New("audio/opus", "OPUS Audio", "opus") var OTF, _ = New("font/otf", "OpenType Font", "otf") var PDF, _ = New("application/pdf", "PDF", "pdf") var PNG, _ = New("image/png", "Portable Network Graphics", "png") +var RAR, _ = New("application/vnd.rar", "RAR Archive", "rar") var ReadiumAudiobook, _ = New("application/audiobook+zip", "Readium Audiobook", "audiobook") var ReadiumAudiobookManifest, _ = New("application/audiobook+json", "Readium Audiobook", "json") var ReadiumContentDocument, _ = New("application/vnd.readium.content+json", "Readium Content Document", "") diff --git a/pkg/mediatype/types_matcher.go b/pkg/mediatype/types_matcher.go index 58fc32d3..86e1cf7a 100644 --- a/pkg/mediatype/types_matcher.go +++ b/pkg/mediatype/types_matcher.go @@ -15,6 +15,7 @@ var knownMatches = map[string]*MediaType{ "application/vnd.comicbook-rar": &CBR, "text/css": &CSS, "application/epub+zip": &EPUB, + "audio/flac": &FLAC, "image/gif": &GIF, "application/gzip": &GZ, "text/html": &HTML, @@ -22,27 +23,28 @@ var knownMatches = map[string]*MediaType{ "image/jpeg": &JPEG, "application/json": &JSON, "image/jxl": &JXL, - "application/vnd.readium.lcp.license.v1.0+json": &LCPLicenseDocument, - "application/audiobook+lcp": &LCPProtectedAudiobook, - "application/pdf+lcp": &LCPProtectedPDF, - "application/vnd.readium.license.status.v1.0+json": &LCPStatusDocument, - "application/lpf+zip": &LPF, - "audio/mpeg": &MP3, - "video/mpeg": &MPEG, - "application/x-dtbncx+xml": &NCX, - "audio/ogg": &OGG, - "video/ogg": &OGV, - "application/atom+xml;profile=opds-catalog": &OPDS1, + "application/vnd.readium.lcp.license.v1.0+json": &LCPLicenseDocument, + "application/audiobook+lcp": &LCPProtectedAudiobook, + "application/pdf+lcp": &LCPProtectedPDF, + "application/vnd.readium.license.status.v1.0+json": &LCPStatusDocument, + "application/lpf+zip": &LPF, + "audio/mpeg": &MPEGAudio, + "audio/mp4": &MP4, + "video/mpeg": &MPEGVideo, + "application/x-dtbncx+xml": &NCX, + "audio/ogg": &OGG, + "video/ogg": &OGV, + "application/atom+xml;profile=opds-catalog": &OPDS1, "application/atom+xml;type=entry;profile=opds-catalog": &OPDS1Entry, "application/opds+json": &OPDS2, "application/opds-publication+json": &OPDS2Publication, "application/opds-authentication+json": &OPDSAuthentication, "application/oebps-package+xml": &OPF, "audio/opus": &OPUS, - "audio/ogg;codecs=opus": &OPUS, "font/otf": &OTF, "application/pdf": &PDF, "image/png": &PNG, + "application/vnd.rar": &RAR, "application/audiobook+zip": &ReadiumAudiobook, "application/audiobook+json": &ReadiumAudiobookManifest, "application/vnd.readium.content+json": &ReadiumContentDocument, diff --git a/pkg/parser/audio/cover.go b/pkg/parser/audio/cover.go new file mode 100644 index 00000000..49f02f6e --- /dev/null +++ b/pkg/parser/audio/cover.go @@ -0,0 +1,80 @@ +package audio + +import ( + "bytes" + "context" + "image" + // Register decoders so cover dimensions can be read. + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "github.com/dhowden/tag" + "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" +) + +// coverHref is the synthetic HREF under which an extracted cover image is served. +const coverHref = "~readium/cover" + +// coverServiceFactory builds a [pub.ServiceFactory] serving the cover image +// embedded in an audio file's tags. It returns nil when there is no usable +// cover. +func coverServiceFactory(pic *tag.Picture) pub.ServiceFactory { + if pic == nil || len(pic.Data) == 0 { + return nil + } + + ext := pic.Ext + if ext == "" { + ext = "jpg" + } + + var mt *mediatype.MediaType + if pic.MIMEType != "" { + if m, err := mediatype.NewOfString(pic.MIMEType); err == nil { + mt = &m + } + } + if mt == nil { + mt = mediatype.OfExtension(ext) + } + + link := manifest.Link{ + Href: manifest.MustNewHREFFromString(coverHref+"."+ext, false), + MediaType: mt, + Rels: manifest.Strings{"cover"}, + } + + if cfg, _, err := image.DecodeConfig(bytes.NewReader(pic.Data)); err == nil { + link.Width = uint(cfg.Width) + link.Height = uint(cfg.Height) + } + + data := pic.Data + return func(_ pub.Context, _ bool) pub.Service { + return coverService{link: link, data: data} + } +} + +// coverService exposes an in-memory cover image as a publication resource. +type coverService struct { + link manifest.Link + data []byte +} + +func (s coverService) Links() manifest.LinkList { + return manifest.LinkList{s.link} +} + +func (s coverService) Get(_ context.Context, link manifest.Link) (fetcher.Resource, bool) { + if !link.URL(nil, nil).Equivalent(s.link.URL(nil, nil)) { + return nil, false + } + data := s.data + return fetcher.NewBytesResource(s.link, func() []byte { return data }), true +} + +func (s coverService) Close() {} diff --git a/pkg/parser/audio/duration_formats.go b/pkg/parser/audio/duration_formats.go new file mode 100644 index 00000000..5aafe0a8 --- /dev/null +++ b/pkg/parser/audio/duration_formats.go @@ -0,0 +1,556 @@ +package audio + +import ( + "bytes" + "context" + "encoding/binary" + "math" + + "github.com/readium/go-toolkit/pkg/fetcher" +) + +// skipID3v2 returns the byte offset right after a leading ID3v2 tag, or 0 if the +// resource doesn't start with one. ID3v2 tags can be prepended to MP3, FLAC and +// AAC streams. +func skipID3v2(ctx context.Context, res fetcher.Resource) int64 { + header := readRange(ctx, res, 0, 10) + if len(header) < 10 || !bytes.HasPrefix(header, []byte("ID3")) { + return 0 + } + // The size is a 28-bit synch-safe integer (7 bits per byte). + size := int64(header[6]&0x7F)<<21 | int64(header[7]&0x7F)<<14 | int64(header[8]&0x7F)<<7 | int64(header[9]&0x7F) + footer := int64(0) + if header[5]&0x10 != 0 { // footer present flag + footer = 10 + } + return 10 + size + footer +} + +// probeFLACDuration reads the FLAC STREAMINFO metadata block to compute the +// duration from the total sample count and sample rate. +func probeFLACDuration(ctx context.Context, res fetcher.Resource) float64 { + base := skipID3v2(ctx, res) + magic := readRange(ctx, res, base, 4) + if !bytes.Equal(magic, []byte("fLaC")) { + return 0 + } + // STREAMINFO is always the first metadata block: 4-byte block header + // followed by 34 bytes of payload. + block := readRange(ctx, res, base+4, 4+34) + if len(block) < 4+34 { + return 0 + } + if block[0]&0x7F != 0 { // block type 0 == STREAMINFO + return 0 + } + si := block[4:] + // 20 bits sample rate, 3 bits channels, 5 bits bits-per-sample, 36 bits total + // samples, packed starting at byte 10 of STREAMINFO. + sampleRate := uint32(si[10])<<12 | uint32(si[11])<<4 | uint32(si[12])>>4 + totalSamples := uint64(si[13]&0x0F)<<32 | uint64(si[14])<<24 | uint64(si[15])<<16 | uint64(si[16])<<8 | uint64(si[17]) + if sampleRate == 0 || totalSamples == 0 { + return 0 + } + return float64(totalSamples) / float64(sampleRate) +} + +// probeWAVDuration walks the RIFF chunks of a WAV file to compute the duration +// from the `data` chunk size and the byte rate declared in the `fmt ` chunk. +func probeWAVDuration(ctx context.Context, res fetcher.Resource, size int64) float64 { + header := readRange(ctx, res, 0, 12) + if len(header) < 12 || !bytes.Equal(header[0:4], []byte("RIFF")) || !bytes.Equal(header[8:12], []byte("WAVE")) { + return 0 + } + + var byteRate uint32 + var sampleRate uint32 + var channels uint16 + var bitsPerSample uint16 + var dataSize int64 = -1 + var factSamples int64 = -1 + + // Parsing is bounded by both the resource size and the RIFF-declared chunk + // range (bytes 4-7), so trailing garbage after the RIFF payload is ignored. + limit := riffLimit(int64(binary.LittleEndian.Uint32(header[4:8])), size) + offset := int64(12) + for offset+8 <= limit { + chunkHeader := readRange(ctx, res, offset, 8) + if len(chunkHeader) < 8 { + break + } + id := string(chunkHeader[0:4]) + chunkSize := int64(binary.LittleEndian.Uint32(chunkHeader[4:8])) + body := offset + 8 + + switch id { + case "fmt ": + fmtBody := readRange(ctx, res, body, min64(chunkSize, 16)) + if len(fmtBody) >= 16 { + channels = binary.LittleEndian.Uint16(fmtBody[2:4]) + sampleRate = binary.LittleEndian.Uint32(fmtBody[4:8]) + byteRate = binary.LittleEndian.Uint32(fmtBody[8:12]) + bitsPerSample = binary.LittleEndian.Uint16(fmtBody[14:16]) + } + case "fact": + factBody := readRange(ctx, res, body, 4) + if len(factBody) >= 4 { + factSamples = int64(binary.LittleEndian.Uint32(factBody[0:4])) + } + case "data": + dataSize = chunkSize + } + + // Chunks are word-aligned: an odd size is followed by a pad byte. + next := body + chunkSize + if chunkSize%2 == 1 { + next++ + } + if next <= offset { // guard against overflow or a non-advancing chunk + break + } + offset = next + } + + // Prefer the exact sample count from `fact` (used by compressed WAV). + if factSamples > 0 && sampleRate > 0 { + return float64(factSamples) / float64(sampleRate) + } + if dataSize > 0 && byteRate > 0 { + return float64(dataSize) / float64(byteRate) + } + // Fall back to reconstructing the byte rate for PCM. + if dataSize > 0 && sampleRate > 0 && channels > 0 && bitsPerSample > 0 { + computed := float64(sampleRate) * float64(channels) * float64(bitsPerSample) / 8 + if computed > 0 { + return float64(dataSize) / computed + } + } + return 0 +} + +// probeAIFFDuration reads the COMM chunk of an AIFF/AIFC file to compute the +// duration from the number of sample frames and the sample rate. +func probeAIFFDuration(ctx context.Context, res fetcher.Resource, size int64) float64 { + header := readRange(ctx, res, 0, 12) + if len(header) < 12 || !bytes.Equal(header[0:4], []byte("FORM")) { + return 0 + } + form := string(header[8:12]) + if form != "AIFF" && form != "AIFC" { + return 0 + } + + limit := riffLimit(int64(binary.BigEndian.Uint32(header[4:8])), size) + offset := int64(12) + for offset+8 <= limit { + chunkHeader := readRange(ctx, res, offset, 8) + if len(chunkHeader) < 8 { + break + } + id := string(chunkHeader[0:4]) + chunkSize := int64(binary.BigEndian.Uint32(chunkHeader[4:8])) + body := offset + 8 + + if id == "COMM" { + commBody := readRange(ctx, res, body, 18) + if len(commBody) >= 18 { + numSampleFrames := binary.BigEndian.Uint32(commBody[2:6]) + sampleRate := decodeExtendedFloat(commBody[8:18]) + if numSampleFrames > 0 && sampleRate > 0 { + return float64(numSampleFrames) / sampleRate + } + } + return 0 + } + + next := body + chunkSize + if chunkSize%2 == 1 { + next++ + } + if next <= offset { // guard against overflow or a non-advancing chunk + break + } + offset = next + } + return 0 +} + +// riffLimit returns the smaller of the resource size and the RIFF/FORM-declared +// payload end (the declared content size plus the 8-byte top-level header), +// clamping to the resource size when the declaration is absent or too large. +func riffLimit(declaredSize, size int64) int64 { + end := declaredSize + 8 + if end >= 12 && end < size { + return end + } + return size +} + +// decodeExtendedFloat decodes an 80-bit IEEE 754 extended-precision float, as +// used for the sample rate in AIFF COMM chunks. +func decodeExtendedFloat(b []byte) float64 { + if len(b) < 10 { + return 0 + } + sign := 1.0 + if b[0]&0x80 != 0 { + sign = -1.0 + } + exponent := int(uint16(b[0]&0x7F)<<8 | uint16(b[1])) + mantissa := binary.BigEndian.Uint64(b[2:10]) + if exponent == 0 && mantissa == 0 { + return 0 + } + return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63)) +} + +var mp3SampleRates = [4][3]uint32{ + {11025, 12000, 8000}, // MPEG 2.5 + {0, 0, 0}, // reserved + {22050, 24000, 16000}, // MPEG 2 + {44100, 48000, 32000}, // MPEG 1 +} + +// mp3Bitrates is indexed by [versionBit][layerBit][bitrateIndex] in kbps, where +// versionBit is 1 for MPEG1 and 0 for MPEG2/2.5, and layerBit is 1/2/3. +var mp3Bitrates = map[[3]int][16]uint32{ + {1, 1, 0}: {0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0}, // V1 L1 + {1, 2, 0}: {0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0}, // V1 L2 + {1, 3, 0}: {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}, // V1 L3 + {0, 1, 0}: {0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0}, // V2 L1 + {0, 2, 0}: {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}, // V2 L2/L3 +} + +// probeMP3Duration computes the duration of an MP3 stream. It honours a VBR +// header (Xing/Info/VBRI) when present, and otherwise assumes constant bitrate. +func probeMP3Duration(ctx context.Context, res fetcher.Resource, size int64) float64 { + start := skipID3v2(ctx, res) + + // Find the first valid frame header within a small window after the tag. + window := readRange(ctx, res, start, 8192) + frameOff := -1 + var header []byte + for i := 0; i+4 <= len(window); i++ { + if window[i] == 0xFF && window[i+1]&0xE0 == 0xE0 { + if h := parseMP3Header(window[i : i+4]); h != nil { + frameOff = i + header = window[i : i+4] + break + } + } + } + if frameOff < 0 { + return 0 + } + + versionBits := (header[1] >> 3) & 0x03 + layerBits := (header[1] >> 1) & 0x03 + sampleRate := mp3SampleRates[versionBits][(header[2]>>2)&0x03] + if sampleRate == 0 { + return 0 + } + samplesPerFrame := mp3SamplesPerFrame(versionBits, layerBits) + + // Side-information size determines where a VBR header sits in the frame. + mpeg1 := versionBits == 3 + channels := (header[3] >> 6) & 0x03 + mono := channels == 3 + var sideInfo int + switch { + case mpeg1 && mono: + sideInfo = 17 + case mpeg1: + sideInfo = 32 + case mono: + sideInfo = 9 + default: + sideInfo = 17 + } + + frameStart := start + int64(frameOff) + frame := readRange(ctx, res, frameStart, 1024) + if frame != nil { + // Xing/Info header. + xingOff := 4 + sideInfo + if xingOff+8 <= len(frame) { + tag := string(frame[xingOff : xingOff+4]) + if tag == "Xing" || tag == "Info" { + flags := binary.BigEndian.Uint32(frame[xingOff+4 : xingOff+8]) + if flags&0x01 != 0 && xingOff+12 <= len(frame) { + frames := binary.BigEndian.Uint32(frame[xingOff+8 : xingOff+12]) + if frames > 0 { + return float64(frames) * float64(samplesPerFrame) / float64(sampleRate) + } + } + } + } + // VBRI header, always 32 bytes after the frame header. + vbriOff := 4 + 32 + if vbriOff+18 <= len(frame) && string(frame[vbriOff:vbriOff+4]) == "VBRI" { + frames := binary.BigEndian.Uint32(frame[vbriOff+14 : vbriOff+18]) + if frames > 0 { + return float64(frames) * float64(samplesPerFrame) / float64(sampleRate) + } + } + } + + // Constant-bitrate fallback. + bitrate := mp3Bitrate(versionBits, layerBits, (header[2]>>4)&0x0F) + if bitrate == 0 { + return 0 + } + audioBytes := size - frameStart + if audioBytes <= 0 { + return 0 + } + return float64(audioBytes) * 8 / (float64(bitrate) * 1000) +} + +func parseMP3Header(b []byte) []byte { + if len(b) < 4 || b[0] != 0xFF || b[1]&0xE0 != 0xE0 { + return nil + } + version := (b[1] >> 3) & 0x03 + layer := (b[1] >> 1) & 0x03 + bitrateIdx := (b[2] >> 4) & 0x0F + sampleIdx := (b[2] >> 2) & 0x03 + if version == 1 || layer == 0 || bitrateIdx == 0 || bitrateIdx == 15 || sampleIdx == 3 { + return nil // reserved/invalid combinations + } + return b +} + +func mp3SamplesPerFrame(versionBits, layerBits uint8) int { + switch layerBits { + case 3: // Layer I + return 384 + case 2: // Layer II + return 1152 + default: // Layer III + if versionBits == 3 { // MPEG1 + return 1152 + } + return 576 + } +} + +func mp3Bitrate(versionBits, layerBits, index uint8) uint32 { + verKey := 0 + if versionBits == 3 { + verKey = 1 + } + layer := int(4 - layerBits) // layerBits 3->1, 2->2, 1->3 + key := [3]int{verKey, layer, 0} + if verKey == 0 && (layer == 2 || layer == 3) { + key = [3]int{0, 2, 0} + } + table, ok := mp3Bitrates[key] + if !ok || int(index) >= len(table) { + return 0 + } + return table[index] +} + +// EBML / Matroska element IDs used to locate the WebM duration. +const ( + ebmlSegment = 0x18538067 + ebmlInfo = 0x1549A966 + ebmlTimecodeScale = 0x2AD7B1 + ebmlDuration = 0x4489 +) + +// probeWebMDuration parses the EBML structure of a WebM/Matroska file to read +// the Duration element from the Segment Info, scaled by the TimecodeScale. +func probeWebMDuration(ctx context.Context, res fetcher.Resource, size int64) float64 { + // The Segment Info element is normally near the beginning of the file. + readLen := int64(1 << 20) + if readLen > size { + readLen = size + } + buf := readRange(ctx, res, 0, readLen) + if len(buf) < 4 || !bytes.HasPrefix(buf, []byte{0x1A, 0x45, 0xDF, 0xA3}) { + return 0 + } + + timecodeScale := uint64(1000000) // default: 1 ms in nanoseconds + var duration float64 + + var walk func(b []byte, depth int) + walk = func(b []byte, depth int) { + pos := 0 + for pos < len(b) { + id, idLen := ebmlReadID(b[pos:]) + if idLen == 0 { + return + } + pos += idLen + length, sizeLen := ebmlReadSize(b[pos:]) + if sizeLen == 0 { + return + } + pos += sizeLen + // Clamp to the available window. This also handles "unknown size" + // master elements (a live/streamed Segment), whose declared length + // spans the rest of the stream. + end := pos + int(length) + if length < 0 || end > len(b) { + end = len(b) + } + data := b[pos:end] + switch id { + case ebmlSegment, ebmlInfo: + if depth < 3 { + walk(data, depth+1) + } + case ebmlTimecodeScale: + timecodeScale = ebmlReadUint(data) + case ebmlDuration: + duration = ebmlReadFloat(data) + } + pos = end + } + } + walk(buf, 0) + + if duration <= 0 { + return 0 + } + return duration * float64(timecodeScale) / 1e9 +} + +// ebmlReadID reads a variable-length EBML element ID, keeping the length marker. +func ebmlReadID(b []byte) (uint64, int) { + if len(b) == 0 || b[0] == 0 { + return 0, 0 + } + length := 1 + for mask := byte(0x80); mask != 0; mask >>= 1 { + if b[0]&mask != 0 { + break + } + length++ + } + if length > 4 || length > len(b) { + return 0, 0 + } + var id uint64 + for i := 0; i < length; i++ { + id = id<<8 | uint64(b[i]) + } + return id, length +} + +// ebmlReadSize reads a variable-length EBML data size, stripping the marker bit. +func ebmlReadSize(b []byte) (int64, int) { + if len(b) == 0 || b[0] == 0 { + return -1, 0 + } + length := 1 + mask := byte(0x80) + for mask != 0 { + if b[0]&mask != 0 { + break + } + length++ + mask >>= 1 + } + if length > 8 || length > len(b) { + return -1, 0 + } + value := uint64(b[0] & (mask - 1)) + for i := 1; i < length; i++ { + value = value<<8 | uint64(b[i]) + } + return int64(value), length +} + +func ebmlReadUint(b []byte) uint64 { + var v uint64 + for _, x := range b { + v = v<<8 | uint64(x) + } + return v +} + +func ebmlReadFloat(b []byte) float64 { + switch len(b) { + case 4: + return float64(math.Float32frombits(binary.BigEndian.Uint32(b))) + case 8: + return math.Float64frombits(binary.BigEndian.Uint64(b)) + } + return 0 +} + +var aacSampleRates = [16]uint32{ + 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, + 16000, 12000, 11025, 8000, 7350, 0, 0, 0, +} + +// aacScanWindow caps how many bytes of a raw AAC stream are read into memory. +// ADTS has no global header, so for longer streams the duration is extrapolated +// from the average frame size measured within this window. +const aacScanWindow = 16 << 20 // 16 MiB + +// probeAACDuration counts ADTS frames in a raw AAC stream to compute the +// duration. Each frame carries 1024 samples per raw data block. +func probeAACDuration(ctx context.Context, res fetcher.Resource, size int64) float64 { + start := skipID3v2(ctx, res) + if size <= start { + return 0 + } + remaining := size - start + readLen := remaining + if readLen > aacScanWindow { + readLen = aacScanWindow + } + data := readRange(ctx, res, start, readLen) + if len(data) < 7 { + return 0 + } + + var sampleRate uint32 + var totalSamples uint64 + pos := 0 + consumed := 0 + for pos+7 <= len(data) { + if data[pos] != 0xFF || data[pos+1]&0xF0 != 0xF0 { + pos++ + continue + } + freqIdx := (data[pos+2] >> 2) & 0x0F + rate := aacSampleRates[freqIdx] + frameLen := int(uint32(data[pos+3]&0x03)<<11 | uint32(data[pos+4])<<3 | uint32(data[pos+5])>>5) + if rate == 0 || frameLen < 7 { + pos++ + continue + } + if pos+frameLen > len(data) { + break // frame extends past the read window + } + if sampleRate == 0 { + sampleRate = rate + } + blocks := uint64(data[pos+6]&0x03) + 1 + totalSamples += 1024 * blocks + pos += frameLen + consumed = pos + } + + if sampleRate == 0 || totalSamples == 0 || consumed == 0 { + return 0 + } + samples := float64(totalSamples) + if readLen < remaining { + // Extrapolate to the full stream, assuming a roughly uniform frame rate. + samples *= float64(remaining) / float64(consumed) + } + return samples / float64(sampleRate) +} + +func min64(a, b int64) int64 { + if a < b { + return a + } + return b +} diff --git a/pkg/parser/audio/duration_probe_test.go b/pkg/parser/audio/duration_probe_test.go new file mode 100644 index 00000000..96b4d6bc --- /dev/null +++ b/pkg/parser/audio/duration_probe_test.go @@ -0,0 +1,149 @@ +package audio + +import ( + "encoding/binary" + "math" + "testing" + + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/stretchr/testify/assert" +) + +// bytesResource wraps a byte slice as a fetcher.Resource for probe tests. +func bytesResource(b []byte) (fetcher.Resource, int64) { + link := manifest.Link{Href: manifest.MustNewHREFFromString("probe", false)} + return fetcher.NewBytesResource(link, func() []byte { return b }), int64(len(b)) +} + +func TestProbeWAVDuration(t *testing.T) { + le := binary.LittleEndian + var buf []byte + put16 := func(v uint16) { buf = le.AppendUint16(buf, v) } + put32 := func(v uint32) { buf = le.AppendUint32(buf, v) } + + data := make([]byte, 8000) // 1s of 8000 Hz / mono / 8-bit PCM -> byteRate 8000 + buf = append(buf, "RIFF"...) + put32(uint32(4 + (8 + 16) + (8 + len(data)))) + buf = append(buf, "WAVE"...) + buf = append(buf, "fmt "...) + put32(16) + put16(1) // PCM + put16(1) // channels + put32(8000) // sample rate + put32(8000) // byte rate + put16(1) // block align + put16(8) // bits per sample + buf = append(buf, "data"...) + put32(uint32(len(data))) + buf = append(buf, data...) + + res, size := bytesResource(buf) + assert.InDelta(t, 1.0, probeWAVDuration(t.Context(), res, size), 0.0001) +} + +// Trailing garbage past the RIFF-declared size must not corrupt the duration. +func TestProbeWAVIgnoresTrailingGarbage(t *testing.T) { + le := binary.LittleEndian + var buf []byte + put16 := func(v uint16) { buf = le.AppendUint16(buf, v) } + put32 := func(v uint32) { buf = le.AppendUint32(buf, v) } + + data := make([]byte, 8000) + buf = append(buf, "RIFF"...) + put32(uint32(4 + (8 + 16) + (8 + len(data)))) // declared size excludes garbage + buf = append(buf, "WAVE"...) + buf = append(buf, "fmt "...) + put32(16) + put16(1) + put16(1) + put32(8000) + put32(8000) + put16(1) + put16(8) + buf = append(buf, "data"...) + put32(uint32(len(data))) + buf = append(buf, data...) + + // Append a bogus "data" chunk that would corrupt the duration if parsed. + buf = append(buf, "data"...) + put32(99999) + + res, size := bytesResource(buf) + assert.InDelta(t, 1.0, probeWAVDuration(t.Context(), res, size), 0.0001) +} + +func TestProbeAIFFDuration(t *testing.T) { + be := binary.BigEndian + var buf []byte + put16 := func(v uint16) { buf = be.AppendUint16(buf, v) } + put32 := func(v uint32) { buf = be.AppendUint32(buf, v) } + // 80-bit IEEE extended representation of 8000.0 + sampleRate := []byte{0x40, 0x0B, 0xFA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + + buf = append(buf, "FORM"...) + put32(4 + (8 + 18)) + buf = append(buf, "AIFF"...) + buf = append(buf, "COMM"...) + put32(18) + put16(1) // channels + put32(8000) // sample frames -> 1s at 8000 Hz + put16(8) // sample size + buf = append(buf, sampleRate...) + + res, size := bytesResource(buf) + assert.InDelta(t, 1.0, probeAIFFDuration(t.Context(), res, size), 0.0001) +} + +func TestProbeFLACDuration(t *testing.T) { + streamInfo := make([]byte, 34) + // Packed fields at byte 10: 8000 Hz sample rate, 8000 total samples. + streamInfo[10] = 0x01 + streamInfo[11] = 0xF4 + streamInfo[12] = 0x00 + streamInfo[13] = 0xF0 // bps low nibble + total-samples high nibble (0) + streamInfo[14] = 0x00 + streamInfo[15] = 0x00 + streamInfo[16] = 0x1F + streamInfo[17] = 0x40 // total samples low 32 bits = 0x1F40 = 8000 + + var buf []byte + buf = append(buf, "fLaC"...) + buf = append(buf, 0x80, 0x00, 0x00, 0x22) // last block, type 0 (STREAMINFO), len 34 + buf = append(buf, streamInfo...) + + res, _ := bytesResource(buf) + assert.InDelta(t, 1.0, probeFLACDuration(t.Context(), res), 0.0001) +} + +func TestProbeAACDuration(t *testing.T) { + // Two header-only ADTS frames at 44100 Hz (freq index 4), frame length 7. + frame := []byte{0xFF, 0xF1, 0x50, 0x80, 0x00, 0xE0, 0x00} + buf := append(append([]byte{}, frame...), frame...) + + res, size := bytesResource(buf) + expected := 2 * 1024.0 / 44100.0 + assert.InDelta(t, expected, probeAACDuration(t.Context(), res, size), 0.0001) +} + +func TestProbeWebMDuration(t *testing.T) { + durationData := make([]byte, 4) + binary.BigEndian.PutUint32(durationData, math.Float32bits(5000.0)) // 5000 * 1ms = 5s + + var info []byte + info = append(info, 0x2A, 0xD7, 0xB1, 0x84, 0x00, 0x0F, 0x42, 0x40) // TimecodeScale = 1,000,000 + info = append(info, 0x44, 0x89, 0x84) // Duration, 4-byte payload + info = append(info, durationData...) + + var segment []byte + segment = append(segment, 0x15, 0x49, 0xA9, 0x66, byte(0x80|len(info))) + segment = append(segment, info...) + + var buf []byte + buf = append(buf, 0x1A, 0x45, 0xDF, 0xA3, 0x80) // EBML header, empty + buf = append(buf, 0x18, 0x53, 0x80, 0x67, byte(0x80|len(segment))) // Segment + buf = append(buf, segment...) + + res, size := bytesResource(buf) + assert.InDelta(t, 5.0, probeWebMDuration(t.Context(), res, size), 0.0001) +} diff --git a/pkg/parser/audio/formats_test.go b/pkg/parser/audio/formats_test.go new file mode 100644 index 00000000..0e1fec91 --- /dev/null +++ b/pkg/parser/audio/formats_test.go @@ -0,0 +1,179 @@ +package audio + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseM3U(t *testing.T) { + content := []byte("#EXTM3U\n" + + "#EXTINF:62,Chapter One\r\n" + + "track01.opus\n" + + "# a stray comment\n" + + "#EXTINF:120,Chapter Two\n" + + "sub/track02.opus\n") + entries := parseM3U(content) + require.Len(t, entries, 2) + assert.Equal(t, playlistEntry{Path: "track01.opus", Title: "Chapter One"}, entries[0]) + assert.Equal(t, playlistEntry{Path: "sub/track02.opus", Title: "Chapter Two"}, entries[1]) +} + +func TestParsePLS(t *testing.T) { + content := []byte("[playlist]\n" + + "File1=track01.mp3\nTitle1=Intro\nLength1=30\n" + + "File2=track02.mp3\nTitle2=Outro\nLength2=45\n" + + "NumberOfEntries=2\n") + entries := parsePLS(content) + require.Len(t, entries, 2) + assert.Equal(t, playlistEntry{Path: "track01.mp3", Title: "Intro"}, entries[0]) + assert.Equal(t, playlistEntry{Path: "track02.mp3", Title: "Outro"}, entries[1]) +} + +func TestParseXSPF(t *testing.T) { + content := []byte(` + + + track01.flacOne + track02.flacTwo + +`) + entries := parseXSPF(content) + require.Len(t, entries, 2) + assert.Equal(t, playlistEntry{Path: "track01.flac", Title: "One"}, entries[0]) + assert.Equal(t, playlistEntry{Path: "track02.flac", Title: "Two"}, entries[1]) +} + +// Real-world fixtures: a 13-FILE CUE sheet and the matching M3U playlist. +func TestParseCUEFixture(t *testing.T) { + content, err := os.ReadFile("./testdata/luvsic.cue") + require.NoError(t, err) + entries := parseCUE(content) + require.Len(t, entries, 13, "one entry per FILE/TRACK pair") + assert.Equal(t, playlistEntry{Path: "01 - Luv(sic).wav", Title: "Luv(sic)", Start: 0}, entries[0]) + // Quoted filename containing doubled single-quotes is preserved verbatim. + assert.Equal(t, "07 - Luv(sic) 12'' Remix.wav", entries[6].Path) + assert.Equal(t, "Luv(sic) 12' Remix", entries[6].Title) + assert.Equal(t, playlistEntry{Path: "13 - Perfect Circle.wav", Title: "Perfect Circle", Start: 0}, entries[12]) +} + +func TestParseM3UFixture(t *testing.T) { + content, err := os.ReadFile("./testdata/luvsic.m3u") + require.NoError(t, err) + entries := parseM3U(content) + require.Len(t, entries, 13) + assert.Equal(t, playlistEntry{Path: "01 - Luv(sic).flac", Title: "Nujabes feat. Shing02 - Luv(sic)"}, entries[0]) + assert.Equal(t, "07 - Luv(sic) 12'' Remix.flac", entries[6].Path) + assert.Equal(t, `Nujabes feat. Shing02 - Luv(sic) 12" Remix`, entries[6].Title) + assert.Equal(t, "13 - Perfect Circle.flac", entries[12].Path) +} + +func TestParseCUE(t *testing.T) { + content := []byte(`REM GENRE Audiobook +PERFORMER "Robert Lynd" +TITLE "The Art of Letters" +FILE "art_letters.flac" WAVE + TRACK 01 AUDIO + TITLE "Dedication" + PERFORMER "Robert Lynd" + INDEX 01 00:00:00 + TRACK 02 AUDIO + TITLE "Mr. Pepys" + INDEX 00 17:28:00 + INDEX 01 17:30:00 +`) + entries := parseCUE(content) + require.Len(t, entries, 2) + // Album-level TITLE is ignored; per-track INDEX 01 (not 00) sets the start. + assert.Equal(t, playlistEntry{Path: "art_letters.flac", Title: "Dedication", Start: 0}, entries[0]) + assert.Equal(t, playlistEntry{Path: "art_letters.flac", Title: "Mr. Pepys", Start: 1050}, entries[1]) +} + +func TestParseCUEMultipleFiles(t *testing.T) { + content := []byte("FILE \"a.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"A\"\n INDEX 01 00:00:00\n" + + "FILE \"b.wav\" WAVE\n TRACK 02 AUDIO\n TITLE \"B\"\n INDEX 01 00:00:00\n") + entries := parseCUE(content) + require.Len(t, entries, 2) + assert.Equal(t, "a.wav", entries[0].Path) + assert.Equal(t, "b.wav", entries[1].Path) +} + +func TestParseCUETime(t *testing.T) { + v, ok := parseCUETime("01:30:37") // 1m 30s 37 frames (75/s) + require.True(t, ok) + assert.InDelta(t, 90+37.0/75, v, 0.0001) + + _, ok = parseCUETime("bad") + assert.False(t, ok) +} + +func TestParsePlaylistUnsupported(t *testing.T) { + assert.Nil(t, parsePlaylist("txt", []byte("not a playlist"))) + assert.Nil(t, parsePlaylist("log", []byte("rip log, not a playlist"))) +} + +func TestPlaylistEntryName(t *testing.T) { + assert.Equal(t, "track 01.opus", playlistEntryName("audio/track%2001.opus?x=1")) + assert.Equal(t, "track.mp3", playlistEntryName("C:\\music\\track.mp3")) +} + +func TestFormatFragmentTime(t *testing.T) { + assert.Equal(t, "0", formatFragmentTime(0)) + assert.Equal(t, "1647.2", formatFragmentTime(1647.2)) + assert.Equal(t, "62.011", formatFragmentTime(62.0109)) +} + +func TestParseTimecode(t *testing.T) { + v, ok := parseTimecode("01:02:03.500") + require.True(t, ok) + assert.InDelta(t, 3723.5, v, 0.001) + + _, ok = parseTimecode("bad") + assert.False(t, ok) +} + +func TestVorbisChapters(t *testing.T) { + tags := &audioTags{Raw: map[string]interface{}{ + "CHAPTER000": "00:00:00.000", + "CHAPTER000NAME": "Opening", + "CHAPTER001": "00:01:30.000", + "CHAPTER001NAME": "Middle", + "title": "ignored", + }} + chapters := vorbisChapters(tags) + require.Len(t, chapters, 2) + assert.Equal(t, chapterEntry{Title: "Opening", Start: 0}, chapters[0]) + assert.Equal(t, chapterEntry{Title: "Middle", Start: 90}, chapters[1]) +} + +func TestDecodeExtendedFloat(t *testing.T) { + // 80-bit IEEE extended representation of 44100.0 + b := []byte{0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0} + assert.InDelta(t, 44100.0, decodeExtendedFloat(b), 0.001) +} + +func TestEBMLReadSize(t *testing.T) { + // 0x81 -> length 1, value 1 + v, n := ebmlReadSize([]byte{0x81}) + assert.Equal(t, 1, n) + assert.Equal(t, int64(1), v) + + // 0x4002 -> length 2, value 2 + v, n = ebmlReadSize([]byte{0x40, 0x02}) + assert.Equal(t, 2, n) + assert.Equal(t, int64(2), v) +} + +func TestEBMLReadID(t *testing.T) { + id, n := ebmlReadID([]byte{0x2A, 0xD7, 0xB1}) + assert.Equal(t, 3, n) + assert.Equal(t, uint64(ebmlTimecodeScale), id) +} + +func TestMP3SamplesPerFrame(t *testing.T) { + assert.Equal(t, 1152, mp3SamplesPerFrame(3, 1)) // MPEG1 Layer III + assert.Equal(t, 576, mp3SamplesPerFrame(2, 1)) // MPEG2 Layer III + assert.Equal(t, 384, mp3SamplesPerFrame(3, 3)) // Layer I +} diff --git a/pkg/parser/audio/metadata.go b/pkg/parser/audio/metadata.go new file mode 100644 index 00000000..3345b60d --- /dev/null +++ b/pkg/parser/audio/metadata.go @@ -0,0 +1,111 @@ +package audio + +import ( + "strings" + + "github.com/dhowden/tag" + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/manifest" +) + +// audioTags holds the metadata extracted from a single audio file's tags +// (ID3, MP4 atoms, Vorbis comments, …). +type audioTags struct { + Title string + Album string + Artist string + AlbumArtist string + Composer string + Genre string + Comment string + Year int + Picture *tag.Picture + Raw map[string]any +} + +// readAudioTags reads the embedded tags of an audio resource. It returns nil +// when no tags could be parsed (which is not an error: many audio files are +// untagged). +func readAudioTags(res fetcher.Resource) *audioTags { + m, err := tag.ReadFrom(fetcher.NewResourceReadSeeker(res)) + if err != nil { + return nil + } + return &audioTags{ + Title: strings.TrimSpace(m.Title()), + Album: strings.TrimSpace(m.Album()), + Artist: strings.TrimSpace(m.Artist()), + AlbumArtist: strings.TrimSpace(m.AlbumArtist()), + Composer: strings.TrimSpace(m.Composer()), + Genre: strings.TrimSpace(m.Genre()), + Comment: strings.TrimSpace(m.Comment()), + Year: m.Year(), + Picture: m.Picture(), + Raw: m.Raw(), + } +} + +// applyTagsToMetadata enriches the publication metadata using the tags of the +// first audio file. Only fields that are present in the tags are set, so an +// untagged file leaves the manifest untouched. +// +// For audiobooks the album typically holds the book title while the per-track +// title holds the chapter name, so the album is preferred for the publication +// title. +func applyTagsToMetadata(m *manifest.Metadata, t *audioTags) { + if t == nil { + return + } + + if title := firstNonEmpty(t.Album, t.Title); title != "" { + m.LocalizedTitle = manifest.NewLocalizedStringFromString(title) + if m.Type == "" { + m.Type = "http://schema.org/Audiobook" + } + } + + if author := firstNonEmpty(t.Artist, t.Composer); author != "" { + m.Authors = []manifest.Contributor{{ + LocalizedName: manifest.NewLocalizedStringFromString(author), + }} + } + + // When the album artist differs from the author, treat it as the narrator, + // which is the usual convention for audiobooks. + if t.AlbumArtist != "" && t.AlbumArtist != t.Artist { + m.Narrators = []manifest.Contributor{{ + LocalizedName: manifest.NewLocalizedStringFromString(t.AlbumArtist), + }} + } + + if t.Comment != "" { + m.Description = t.Comment + } else if d := rawString(t.Raw, "description"); d != "" { + m.Description = d + } + + if t.Genre != "" { + m.Subjects = []manifest.Subject{{ + LocalizedName: manifest.NewLocalizedStringFromString(t.Genre), + }} + } +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func rawString(raw map[string]any, key string) string { + if raw == nil { + return "" + } + if v, ok := raw[key].(string); ok { + return strings.TrimSpace(v) + } + return "" +} diff --git a/pkg/parser/audio/mp4.go b/pkg/parser/audio/mp4.go new file mode 100644 index 00000000..d7fc975c --- /dev/null +++ b/pkg/parser/audio/mp4.go @@ -0,0 +1,366 @@ +package audio + +import ( + "context" + "encoding/binary" + "sort" + "strings" + "unicode/utf16" + + mp4 "github.com/abema/go-mp4" + "github.com/readium/go-toolkit/pkg/fetcher" +) + +// chapterCoalesceGap is the largest gap between two chapter text samples that is +// still read in a single request. It keeps over-reading small while collapsing a +// chapter track stored contiguously into one read. +const chapterCoalesceGap = 64 << 10 + +// probeMP4 extracts the total duration (in seconds) and any embedded chapters +// from an ISO-BMFF / MP4 container (m4a, m4b, m4p, mp4, …). +// +// The duration is read from the movie header (`mvhd`). Chapters are read from a +// QuickTime/iTunes chapter track: a track whose media handler is `text`, whose +// samples are length-prefixed UTF strings located in `mdat`, and whose +// per-sample timing comes from the `stts` table. +func probeMP4(ctx context.Context, res fetcher.Resource, extractChapters bool) (duration float64, chapters []chapterEntry, err error) { + rs := fetcher.NewResourceReadSeeker(res) + + // Total duration from the movie header. + if boxes, e := mp4.ExtractBoxWithPayload(rs, nil, mp4.BoxPath{mp4.BoxTypeMoov(), mp4.BoxTypeMvhd()}); e == nil && len(boxes) > 0 { + if mvhd, ok := boxes[0].Payload.(*mp4.Mvhd); ok && mvhd.Timescale > 0 { + duration = float64(mvhd.GetDuration()) / float64(mvhd.Timescale) + } + } + + if extractChapters { + chapters = extractMP4Chapters(ctx, res, rs) + } + return duration, chapters, nil +} + +// extractMP4Chapters returns the chapters of an MP4 file, or nil if it has none. +// +// A Nero chapter list (`moov/udta/chpl`) is preferred when present: it lives in +// the movie header, which has already been fetched, so it costs no extra reads. +// Otherwise it falls back to a QuickTime text chapter track, whose title samples +// live in `mdat` and may be scattered throughout the file. +func extractMP4Chapters(ctx context.Context, res fetcher.Resource, rs *fetcher.ResourceReadSeeker) []chapterEntry { + if chapters := extractNeroChapters(ctx, res, rs); len(chapters) > 0 { + return chapters + } + + traks, err := mp4.ExtractBox(rs, nil, mp4.BoxPath{mp4.BoxTypeMoov(), mp4.BoxTypeTrak()}) + if err != nil { + return nil + } + + for _, trak := range traks { + // Only consider tracks whose media handler is "text" (chapter tracks). + hdlrs, err := mp4.ExtractBoxWithPayload(rs, trak, mp4.BoxPath{mp4.BoxTypeMdia(), mp4.BoxTypeHdlr()}) + if err != nil || len(hdlrs) == 0 { + continue + } + hdlr, ok := hdlrs[0].Payload.(*mp4.Hdlr) + if !ok || string(hdlr.HandlerType[:]) != "text" { + continue + } + + // Media timescale, used to convert sample durations to seconds. + timescale := uint32(0) + if mdhds, err := mp4.ExtractBoxWithPayload(rs, trak, mp4.BoxPath{mp4.BoxTypeMdia(), mp4.BoxTypeMdhd()}); err == nil && len(mdhds) > 0 { + if mdhd, ok := mdhds[0].Payload.(*mp4.Mdhd); ok { + timescale = mdhd.Timescale + } + } + if timescale == 0 { + continue + } + + stblPath := mp4.BoxPath{mp4.BoxTypeMdia(), mp4.BoxTypeMinf(), mp4.BoxTypeStbl()} + + // Sample durations (stts) -> cumulative start times. + var sampleDeltas []uint32 + if boxes, err := mp4.ExtractBoxWithPayload(rs, trak, append(stblPath, mp4.BoxTypeStts())); err == nil && len(boxes) > 0 { + if stts, ok := boxes[0].Payload.(*mp4.Stts); ok { + for _, e := range stts.Entries { + for i := uint32(0); i < e.SampleCount; i++ { + sampleDeltas = append(sampleDeltas, e.SampleDelta) + } + } + } + } + + // Sample sizes (stsz). + var sampleSizes []uint32 + if boxes, err := mp4.ExtractBoxWithPayload(rs, trak, append(stblPath, mp4.BoxTypeStsz())); err == nil && len(boxes) > 0 { + if stsz, ok := boxes[0].Payload.(*mp4.Stsz); ok { + if stsz.SampleSize != 0 { + sampleSizes = make([]uint32, stsz.SampleCount) + for i := range sampleSizes { + sampleSizes[i] = stsz.SampleSize + } + } else { + sampleSizes = stsz.EntrySize + } + } + } + + // Chunk offsets (stco / co64). + var chunkOffsets []uint64 + if boxes, err := mp4.ExtractBoxWithPayload(rs, trak, append(stblPath, mp4.BoxTypeStco())); err == nil && len(boxes) > 0 { + if stco, ok := boxes[0].Payload.(*mp4.Stco); ok { + for _, o := range stco.ChunkOffset { + chunkOffsets = append(chunkOffsets, uint64(o)) + } + } + } + if len(chunkOffsets) == 0 { + if boxes, err := mp4.ExtractBoxWithPayload(rs, trak, append(stblPath, mp4.BoxTypeCo64())); err == nil && len(boxes) > 0 { + if co64, ok := boxes[0].Payload.(*mp4.Co64); ok { + chunkOffsets = co64.ChunkOffset + } + } + } + + // Samples-to-chunk mapping (stsc). + var stscEntries []mp4.StscEntry + if boxes, err := mp4.ExtractBoxWithPayload(rs, trak, append(stblPath, mp4.BoxTypeStsc())); err == nil && len(boxes) > 0 { + if stsc, ok := boxes[0].Payload.(*mp4.Stsc); ok { + stscEntries = stsc.Entries + } + } + + offsets := sampleOffsets(sampleSizes, chunkOffsets, stscEntries) + if len(offsets) == 0 { + continue + } + + // Per-sample start time, in sample (time) order. + starts := make([]float64, len(offsets)) + var cumulative uint64 + for i := range offsets { + starts[i] = float64(cumulative) / float64(timescale) + if i < len(sampleDeltas) { + cumulative += uint64(sampleDeltas[i]) + } + } + + titles := readChapterTitles(ctx, res, offsets, sampleSizes) + + chapters := make([]chapterEntry, 0, len(offsets)) + for i := range offsets { + if titles[i] == "" { + continue + } + chapters = append(chapters, chapterEntry{Title: titles[i], Start: starts[i]}) + } + if len(chapters) > 0 { + return chapters + } + } + return nil +} + +// sampleOffsets resolves the absolute byte offset of every sample from the +// sample-size, chunk-offset and sample-to-chunk tables. +func sampleOffsets(sampleSizes []uint32, chunkOffsets []uint64, stsc []mp4.StscEntry) []uint64 { + if len(sampleSizes) == 0 || len(chunkOffsets) == 0 { + return nil + } + + // Expand stsc into a "samples per chunk" value for each chunk. + samplesPerChunk := make([]uint32, len(chunkOffsets)) + for ci := range chunkOffsets { + chunkNum := uint32(ci) + 1 + spc := uint32(1) + for _, e := range stsc { + if chunkNum >= e.FirstChunk { + spc = e.SamplesPerChunk + } else { + break + } + } + samplesPerChunk[ci] = spc + } + + offsets := make([]uint64, 0, len(sampleSizes)) + si := 0 + for ci, chunkOff := range chunkOffsets { + off := chunkOff + for j := uint32(0); j < samplesPerChunk[ci] && si < len(sampleSizes); j++ { + offsets = append(offsets, off) + off += uint64(sampleSizes[si]) + si++ + } + if si >= len(sampleSizes) { + break + } + } + return offsets +} + +// readChapterTitles reads and decodes every chapter text sample, returning the +// titles indexed by sample. +// +// Chapter title samples are tiny (a few dozen bytes) but can be scattered across +// mdat. Reading each one through the block cache would pull a whole 256 KiB block +// per title, so instead this reuses already-cached blocks where possible and +// otherwise reads the exact sample bytes from the underlying resource, coalescing +// neighbouring samples into a single read. +func readChapterTitles(ctx context.Context, res fetcher.Resource, offsets []uint64, sizes []uint32) []string { + titles := make([]string, len(offsets)) + if len(offsets) == 0 { + return titles + } + + // Underlying resource for exact reads, plus the cache (if any) to reuse. + var cache *readCache + raw := res + if rc, ok := res.(*readCache); ok { + cache = rc + raw = rc.Resource + } + + // Process samples in offset order so neighbours can be coalesced. + order := make([]int, len(offsets)) + for i := range order { + order[i] = i + } + sort.Slice(order, func(a, b int) bool { return offsets[order[a]] < offsets[order[b]] }) + + for i := 0; i < len(order); { + runStart := offsets[order[i]] + runEnd := runStart + uint64(sizes[order[i]]) // exclusive + j := i + 1 + for j < len(order) { + off := offsets[order[j]] + if off > runEnd+chapterCoalesceGap { + break + } + if e := off + uint64(sizes[order[j]]); e > runEnd { + runEnd = e + } + j++ + } + + lo, hi := int64(runStart), int64(runEnd)-1 + var data []byte + if cache != nil { + if b, hit := cache.cachedSlice(lo, hi); hit { + data = b + } + } + if data == nil { + if b, err := raw.Read(ctx, lo, hi); err == nil { + data = b + } + } + + for k := i; k < j; k++ { + idx := order[k] + start := int64(offsets[idx]) - lo + end := start + int64(sizes[idx]) + if start >= 0 && end <= int64(len(data)) { + titles[idx] = decodeChapterTitle(data[start:end]) + } + } + i = j + } + return titles +} + +// decodeChapterTitle decodes a timed-text sample: a 16-bit big-endian length +// followed by the text payload, which may be UTF-8 or (with a BOM) UTF-16. +func decodeChapterTitle(sample []byte) string { + if len(sample) < 2 { + return "" + } + textLen := int(binary.BigEndian.Uint16(sample[:2])) + if textLen <= 0 || 2+textLen > len(sample) { + // Be lenient: fall back to whatever bytes follow the length prefix. + textLen = len(sample) - 2 + } + return decodeText(sample[2 : 2+textLen]) +} + +// extractNeroChapters reads a Nero chapter list (`moov/udta/chpl`) when present. +// The box lives in the movie header (already fetched), so this costs no extra +// reads. Returns nil when there is no chpl box. +func extractNeroChapters(ctx context.Context, res fetcher.Resource, rs *fetcher.ResourceReadSeeker) []chapterEntry { + boxes, err := mp4.ExtractBox(rs, nil, mp4.BoxPath{mp4.BoxTypeMoov(), mp4.BoxTypeUdta(), mp4.StrToBoxType("chpl")}) + if err != nil || len(boxes) == 0 { + return nil + } + info := boxes[0] + data, rerr := res.Read(ctx, int64(info.Offset+info.HeaderSize), int64(info.Offset+info.Size)-1) + if rerr != nil { + return nil + } + return parseNeroChapters(data) +} + +// parseNeroChapters parses a Nero `chpl` box payload: a FullBox header, an +// 8-bit chapter count (preceded by a 4-byte reserved field in version 1), then +// per chapter an 8-byte start time in 100 ns units and a length-prefixed UTF-8 +// title. +func parseNeroChapters(b []byte) []chapterEntry { + if len(b) < 5 { + return nil + } + version := b[0] + pos := 4 // version (1) + flags (3) + if version != 0 { + if len(b) < pos+5 { + return nil + } + pos += 4 // reserved + } + count := int(b[pos]) + pos++ + + chapters := make([]chapterEntry, 0, count) + for c := 0; c < count; c++ { + if pos+9 > len(b) { + break + } + start := binary.BigEndian.Uint64(b[pos : pos+8]) + pos += 8 + titleLen := int(b[pos]) + pos++ + if pos+titleLen > len(b) { + break + } + title := strings.TrimSpace(decodeText(b[pos : pos+titleLen])) + pos += titleLen + if title != "" { + chapters = append(chapters, chapterEntry{Title: title, Start: float64(start) / 1e7}) + } + } + return chapters +} + +// decodeText decodes a chapter title, honouring an optional UTF-16 byte-order +// mark and otherwise assuming UTF-8. +func decodeText(b []byte) string { + if len(b) >= 2 { + switch { + case b[0] == 0xFE && b[1] == 0xFF: + return decodeUTF16(b[2:], binary.BigEndian) + case b[0] == 0xFF && b[1] == 0xFE: + return decodeUTF16(b[2:], binary.LittleEndian) + } + } + return string(b) +} + +func decodeUTF16(b []byte, order binary.ByteOrder) string { + if len(b)%2 != 0 { + b = b[:len(b)-1] + } + u16 := make([]uint16, len(b)/2) + for i := range u16 { + u16[i] = order.Uint16(b[i*2:]) + } + return string(utf16.Decode(u16)) +} diff --git a/pkg/parser/audio/ogg.go b/pkg/parser/audio/ogg.go new file mode 100644 index 00000000..63dad86b --- /dev/null +++ b/pkg/parser/audio/ogg.go @@ -0,0 +1,122 @@ +package audio + +import ( + "bytes" + "context" + "encoding/binary" + + "github.com/readium/go-toolkit/pkg/fetcher" +) + +const oggCapturePattern = "OggS" + +// probeOggDuration computes the duration (in seconds) of an Ogg-encapsulated +// stream (Opus or Vorbis). It reads the identification header from the start of +// the stream to determine the sample rate, then the last page from the tail to +// read the final granule position. This avoids scanning the whole file. +func probeOggDuration(ctx context.Context, res fetcher.Resource, size int64) float64 { + if size <= 0 { + return 0 + } + + headLen := min(int64(8192), size) + head, err := res.Read(ctx, 0, headLen-1) + if err != nil || len(head) == 0 { + return 0 + } + + rate, preSkip, ok := oggIdentification(head) + if !ok || rate == 0 { + return 0 + } + + // Read a window at the end of the file large enough to contain the last page. + tailLen := int64(65536) + if tailLen > size { + tailLen = size + } + tail, err := res.Read(ctx, size-tailLen, size-1) + if err != nil || len(tail) == 0 { + return 0 + } + + granule, ok := lastOggGranule(tail) + if !ok { + return 0 + } + + samples := int64(granule) - int64(preSkip) + if samples <= 0 { + return 0 + } + return float64(samples) / float64(rate) +} + +// oggIdentification parses the first Ogg page and returns the granule sample +// rate and pre-skip (pre-skip is zero for non-Opus streams). +// +// For Opus the granule positions are always expressed at 48 kHz, regardless of +// the original input sample rate, and the pre-skip must be subtracted. For +// Vorbis the granule rate is the audio sample rate declared in the header. +func oggIdentification(head []byte) (rate uint32, preSkip uint16, ok bool) { + body := firstPageBody(head) + if body == nil { + return 0, 0, false + } + + switch { + case len(body) >= 19 && bytes.HasPrefix(body, []byte("OpusHead")): + preSkip = binary.LittleEndian.Uint16(body[10:12]) + return 48000, preSkip, true + case len(body) >= 16 && bytes.HasPrefix(body, []byte("\x01vorbis")): + // version(4) channels(1) then sample rate at offset 12. + return binary.LittleEndian.Uint32(body[12:16]), 0, true + } + return 0, 0, false +} + +// firstPageBody returns the body bytes of the first Ogg page in head. +func firstPageBody(head []byte) []byte { + if !bytes.HasPrefix(head, []byte(oggCapturePattern)) || len(head) < 27 { + return nil + } + nseg := int(head[26]) + if len(head) < 27+nseg { + return nil + } + segTable := head[27 : 27+nseg] + bodyLen := 0 + for _, s := range segTable { + bodyLen += int(s) + } + bodyStart := 27 + nseg + if bodyStart+bodyLen > len(head) { + bodyLen = len(head) - bodyStart + } + return head[bodyStart : bodyStart+bodyLen] +} + +// lastOggGranule scans a tail window for the last Ogg page carrying a valid +// granule position (i.e. not -1, which marks a page where no packet completes). +func lastOggGranule(tail []byte) (uint64, bool) { + pattern := []byte(oggCapturePattern) + var granule uint64 + found := false + for i := 0; i+27 <= len(tail); { + idx := bytes.Index(tail[i:], pattern) + if idx < 0 { + break + } + pos := i + idx + if pos+27 > len(tail) { + break + } + g := binary.LittleEndian.Uint64(tail[pos+6 : pos+14]) + if g != 0xFFFFFFFFFFFFFFFF { + granule = g + found = true + } + i = pos + 4 + } + return granule, found +} diff --git a/pkg/parser/audio/parser.go b/pkg/parser/audio/parser.go new file mode 100644 index 00000000..2fb9ccbc --- /dev/null +++ b/pkg/parser/audio/parser.go @@ -0,0 +1,198 @@ +package audio + +import ( + "context" + "errors" + "path/filepath" + "sort" + "strings" + + "github.com/readium/go-toolkit/pkg/asset" + "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/parser" + "github.com/readium/go-toolkit/pkg/pub" +) + +// Handles parsing of audiobooks from an unstructured archive format containing audio files, such as ZAB (Zipped Audio Book) or a simple ZIP. +// It can also work for a standalone audio file. +type AudioParser struct { + rich bool // Whether to attempt extraction of metadata (duration, cover etc.) from the audio files + + // skipEmbeddedChapters disables building the table of contents from chapter + // markers embedded in the audio files. Extracting them can be expensive on + // remote sources — some files scatter chapter title samples throughout the + // stream, costing one range request each — so callers that prioritize fast + // opening can turn it off. A playlist or per-file titles are still used. + skipEmbeddedChapters bool + + // cacheBlockSize sets the granularity (in bytes) of the per-file read cache + // used while probing. Zero means the default. Larger blocks make fewer, bigger + // range requests; smaller blocks transfer less for scattered reads. + cacheBlockSize int + + // concurrency caps how many audio files are probed in parallel. Zero means + // the default. Higher values hide more per-file latency on remote sources at + // the cost of more in-flight requests. + concurrency int +} + +// Option configures an AudioParser. +type Option func(*AudioParser) + +// WithoutEmbeddedChapters disables extracting the table of contents from chapter +// markers embedded in the audio files (e.g. an MP4 chapter track or Vorbis +// CHAPTER comments). This avoids the extra reads they require; the TOC then +// comes from a playlist or per-file titles when available. +func WithoutEmbeddedChapters() Option { + return func(p *AudioParser) { p.skipEmbeddedChapters = true } +} + +// WithCacheBlockSize sets the block size (in bytes) of the per-file read cache +// used while probing audio files for rich metadata. The default is 256 KiB. A +// value <= 0 is ignored and keeps the default. Larger blocks coalesce more reads +// into each range request (fewer requests, more bytes); smaller blocks transfer +// less when reads are scattered. +func WithCacheBlockSize(size int) Option { + return func(p *AudioParser) { + if size > 0 { + p.cacheBlockSize = size + } + } +} + +// WithConcurrency sets how many audio files are probed in parallel while +// extracting rich metadata. The default is 8. A value <= 0 is ignored and keeps +// the default. Use 1 to probe sequentially. +func WithConcurrency(n int) Option { + return func(p *AudioParser) { + if n > 0 { + p.concurrency = n + } + } +} + +func NewParser() AudioParser { + return AudioParser{} +} + +func NewRichParser(opts ...Option) AudioParser { + p := AudioParser{rich: true} + for _, opt := range opts { + opt(&p) + } + return p +} + +// Parse implements PublicationParser +func (p AudioParser) Parse(ctx context.Context, asset asset.PublicationAsset, fetcher fetcher.Fetcher) (*pub.Builder, error) { + if !p.accepts(ctx, asset, fetcher) { + return nil, nil + } + + links, err := fetcher.Links(ctx) + if err != nil { + return nil, err + } + readingOrder := make(manifest.LinkList, 0, len(links)) + for _, link := range links { + path := link.URL(nil, nil).Path() + + // Filter out all irrelevant files + fext := filepath.Ext(strings.ToLower(path)) + if len(fext) > 1 { + fext = fext[1:] // Remove "." from extension + } + _, contains := allowed_extensions_audio[fext] + if extensions.IsHiddenOrThumbs(path) || !contains { + continue + } + readingOrder = append(readingOrder, link) + } + + if len(readingOrder) == 0 { + return nil, errors.New("no audio file found in the publication") + } + + // Sort in alphabetical order + sort.Slice(readingOrder, func(i, j int) bool { + return readingOrder[i].Href.String() < readingOrder[j].Href.String() + }) + + // Try to figure out the publication's title + title := parser.GuessPublicationTitleFromFileStructure(ctx, fetcher) + if title == "" { + title = asset.Name() + } + + man := manifest.Manifest{ + Context: manifest.Strings{manifest.WebpubManifestContext}, + Metadata: manifest.Metadata{ + Type: "http://schema.org/Audiobook", + LocalizedTitle: manifest.NewLocalizedStringFromString(title), + ConformsTo: manifest.Profiles{manifest.ProfileAudiobook}, + }, + ReadingOrder: readingOrder, + } + + serviceFactories := map[pub.ServiceName]pub.ServiceFactory{} + + // When rich parsing is enabled, probe the audio files for durations, + // bitrates, embedded metadata, a cover and a table of contents. + if p.rich { + if coverFactory := p.enrich(ctx, fetcher, &man); coverFactory != nil { + serviceFactories[pub.CoverService_Name] = coverFactory + } + } + + var builder *pub.ServicesBuilder + if len(serviceFactories) > 0 { + builder = pub.NewServicesBuilder(serviceFactories) + } + return pub.NewBuilder(man, fetcher, builder), nil +} + +var allowed_extensions_audio_extra = map[string]struct{}{ + "asx": {}, "bio": {}, "m3u": {}, "m3u8": {}, "pla": {}, "pls": {}, + "smil": {}, "txt": {}, "vlc": {}, "wpl": {}, "xspf": {}, "zpl": {}, + "cue": {}, "log": {}, +} +var allowed_extensions_audio = map[string]struct{}{ + "aac": {}, "aiff": {}, "aif": {}, "aifc": {}, "alac": {}, "flac": {}, + "m4a": {}, "m4b": {}, "mp3": {}, "mp4": {}, "m4r": {}, "m4p": {}, + "ogg": {}, "oga": {}, "mogg": {}, "opus": {}, "wav": {}, "wave": {}, + "webm": {}, +} + +func (p AudioParser) accepts(ctx context.Context, asset asset.PublicationAsset, fetcher fetcher.Fetcher) bool { + if asset.MediaType(ctx).Equal(&mediatype.ZAB) { + return true + } + links, err := fetcher.Links(ctx) + if err != nil { + // TODO log + return false + } + for _, link := range links { + path := link.URL(nil, nil).Path() + + if extensions.IsHiddenOrThumbs(path) { + continue + } + if link.MediaType.IsBitmap() { + continue + } + fext := filepath.Ext(strings.ToLower(path)) + if len(fext) > 1 { + fext = fext[1:] // Remove "." from extension + } + _, contains1 := allowed_extensions_audio[fext] + _, contains2 := allowed_extensions_audio_extra[fext] + if !contains1 && !contains2 { + return false + } + } + return true +} diff --git a/pkg/parser/audio/parser_test.go b/pkg/parser/audio/parser_test.go new file mode 100644 index 00000000..e8b9e2c4 --- /dev/null +++ b/pkg/parser/audio/parser_test.go @@ -0,0 +1,414 @@ +package audio + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/readium/go-toolkit/pkg/archive" + "github.com/readium/go-toolkit/pkg/asset" + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/pub" + "github.com/readium/go-toolkit/pkg/util/url" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A single-file M4B audiobook with embedded metadata, a cover and a chapter +// (text) track providing 26 chapters. +const m4bPath = "./testdata/AroundTheWorldInEightyDays.m4b" + +const ( + // A ZAB (Zipped Audio Book): a ZIP whose entries all live under a single + // "Art of Letters/" root directory containing three .opus tracks. + zabPath = "./testdata/art_letters.zab" + // The same audiobook exploded into a directory of .opus tracks, with no + // enclosing root folder. + explodedDirPath = "./testdata/art_letters" + // A single track, used to exercise the standalone-audio-file path. + standaloneFilePath = "./testdata/art_letters/artofletters_00_lynd.opus" +) + +// parseAudio builds a fetcher for the asset at path and runs the AudioParser, +// returning its raw result so that rejection and error paths can be asserted. +func parseAudio(t *testing.T, path string) (*pub.Builder, error) { + t.Helper() + u, err := url.FromFilepath(path) + require.NoError(t, err) + a := asset.File(u) + fet, err := a.CreateFetcher(t.Context(), asset.Dependencies{ + ArchiveFactory: archive.NewArchiveFactory(), + }, "") + require.NoError(t, err) + t.Cleanup(fet.Close) + return AudioParser{}.Parse(t.Context(), a, fet) +} + +// withAudioParser parses the asset at path with the AudioParser and passes the +// resulting builder to f, failing the test if the asset is rejected or errors. +func withAudioParser(t *testing.T, path string, f func(*pub.Builder)) { + t.Helper() + b, err := parseAudio(t, path) + require.NoError(t, err) + require.NotNil(t, b, "parser unexpectedly rejected the asset") + f(b) +} + +// readingOrderFilenames returns the decoded final path segment of every reading +// order link. This is robust to whether the href carries an archive or +// directory prefix. +func readingOrderFilenames(ro manifest.LinkList) []string { + names := make([]string, 0, len(ro)) + for _, link := range ro { + names = append(names, link.URL(nil, nil).Filename()) + } + return names +} + +func TestNewParser(t *testing.T) { + assert.Equal(t, AudioParser{}, NewParser()) +} + +func TestNewRichParser(t *testing.T) { + assert.Equal(t, AudioParser{rich: true}, NewRichParser()) + assert.Equal(t, AudioParser{rich: true, skipEmbeddedChapters: true}, NewRichParser(WithoutEmbeddedChapters())) + assert.Equal(t, AudioParser{rich: true, cacheBlockSize: 512 << 10}, NewRichParser(WithCacheBlockSize(512<<10))) + assert.Equal(t, AudioParser{rich: true}, NewRichParser(WithCacheBlockSize(0)), "non-positive cache size is ignored") + assert.Equal(t, AudioParser{rich: true, concurrency: 4}, NewRichParser(WithConcurrency(4))) + assert.Equal(t, AudioParser{rich: true}, NewRichParser(WithConcurrency(0)), "non-positive concurrency is ignored") +} + +// Custom block size and sequential concurrency still produce a correct manifest. +func TestAudioRichWithOptions(t *testing.T) { + u, err := url.FromFilepath(zabPath) + require.NoError(t, err) + a := asset.File(u) + fet, err := a.CreateFetcher(t.Context(), asset.Dependencies{ArchiveFactory: archive.NewArchiveFactory()}, "") + require.NoError(t, err) + t.Cleanup(fet.Close) + + b, err := NewRichParser(WithConcurrency(1), WithCacheBlockSize(64<<10)).Parse(t.Context(), a, fet) + require.NoError(t, err) + require.NotNil(t, b) + m := b.Build().Manifest + require.Len(t, m.ReadingOrder, 3) + for _, l := range m.ReadingOrder { + assert.Greater(t, l.Duration, 0.0) + assert.Greater(t, l.Bitrate, 0.0) + } + assert.Equal(t, "The Art of Letters", m.Metadata.Title()) +} + +// WithoutEmbeddedChapters skips building the TOC from the M4B chapter track, +// while still extracting durations and metadata. +func TestAudioRichWithoutEmbeddedChapters(t *testing.T) { + if _, err := os.Stat(m4bPath); err != nil { + t.Skipf("M4B fixture not available: %v", err) + } + m := parseRichAudioWith(t, m4bPath, WithoutEmbeddedChapters()).Build().Manifest + + require.Len(t, m.ReadingOrder, 1) + assert.Greater(t, m.ReadingOrder[0].Duration, 0.0, "duration is still probed") + assert.Equal(t, "Around the World in Eighty Days", m.Metadata.Title(), "metadata is still extracted") + assert.Empty(t, m.TableOfContents, "no TOC is built from embedded chapters when disabled") +} + +// parseRichAudio runs the rich AudioParser against the asset at path. +func parseRichAudio(t *testing.T, path string) *pub.Builder { + return parseRichAudioWith(t, path) +} + +// parseRichAudioWith runs the rich AudioParser with the given options. +func parseRichAudioWith(t *testing.T, path string, opts ...Option) *pub.Builder { + t.Helper() + u, err := url.FromFilepath(path) + require.NoError(t, err) + a := asset.File(u) + fet, err := a.CreateFetcher(t.Context(), asset.Dependencies{ + ArchiveFactory: archive.NewArchiveFactory(), + }, "") + require.NoError(t, err) + t.Cleanup(fet.Close) + b, err := NewRichParser(opts...).Parse(t.Context(), a, fet) + require.NoError(t, err) + require.NotNil(t, b, "rich parser unexpectedly rejected the asset") + return b +} + +func tocTitles(toc manifest.LinkList) []string { + titles := make([]string, 0, len(toc)) + for _, l := range toc { + titles = append(titles, l.Title) + } + return titles +} + +// Rich parsing of an Opus ZAB sets per-track durations/bitrates, the total +// duration, and pulls the title/author from the first file's Vorbis comments. +func TestAudioRichOpusZAB(t *testing.T) { + m := parseRichAudio(t, zabPath).Build().Manifest + require.Len(t, m.ReadingOrder, 3) + + var sum float64 + for _, l := range m.ReadingOrder { + assert.Greater(t, l.Duration, 0.0, "every track must have a duration") + assert.Greater(t, l.Bitrate, 0.0, "every track must have a bitrate") + assert.Empty(t, l.URL(nil, nil).Fragment(), "reading order must not carry fragments") + sum += l.Duration + } + + // First track is ~62s ("00 - Dedication"). + assert.InDelta(t, 62.0, m.ReadingOrder[0].Duration, 1.0) + + require.NotNil(t, m.Metadata.Duration) + assert.InDelta(t, sum, *m.Metadata.Duration, 0.01, "metadata duration is the sum of the tracks") + + // Title comes from the album tag, author from the artist tag. + assert.Equal(t, "The Art of Letters", m.Metadata.Title()) + require.Len(t, m.Metadata.Authors, 1) + assert.Equal(t, "Robert Lynd", m.Metadata.Authors[0].Name()) + assert.Equal(t, "http://schema.org/Audiobook", m.Metadata.Type) + + // No playlist or embedded chapters, so the TOC lists the per-file titles. + require.Len(t, m.TableOfContents, 3) + assert.Equal(t, "00 - Dedication", m.TableOfContents[0].Title) +} + +// Rich parsing of an M4B reads the movie duration, the iTunes metadata + cover, +// and the chapter track to build the table of contents. +func TestAudioRichM4B(t *testing.T) { + if _, err := os.Stat(m4bPath); err != nil { + t.Skipf("M4B fixture not available: %v", err) + } + + b := parseRichAudio(t, m4bPath) + p := b.Build() + m := p.Manifest + + require.Len(t, m.ReadingOrder, 1) + assert.InDelta(t, 23610.91, m.ReadingOrder[0].Duration, 2.0) + assert.Greater(t, m.ReadingOrder[0].Bitrate, 0.0) + require.NotNil(t, m.Metadata.Duration) + assert.InDelta(t, 23610.91, *m.Metadata.Duration, 2.0) + + assert.Equal(t, "Around the World in Eighty Days", m.Metadata.Title()) + require.Len(t, m.Metadata.Authors, 1) + assert.Equal(t, "Jules Verne", m.Metadata.Authors[0].Name()) + + // Cover extracted from the `covr` atom and exposed via the cover service. + cover := m.Links.FirstWithRel("cover") + require.NotNil(t, cover, "a cover link should be present") + assert.Greater(t, cover.Width, uint(0)) + assert.Greater(t, cover.Height, uint(0)) + data, rerr := p.Get(t.Context(), *cover).Read(t.Context(), 0, 0) + require.Nil(t, rerr) + assert.NotEmpty(t, data, "the cover resource should serve bytes") + + // 26 chapters from the text track; the first starts at the very beginning. + require.Len(t, m.TableOfContents, 26) + assert.Equal(t, "01 - Chapters 01 - 03", m.TableOfContents[0].Title) + assert.Empty(t, m.TableOfContents[0].URL(nil, nil).Fragment(), "first chapter starts at 0") + assert.NotEmpty(t, m.TableOfContents[1].URL(nil, nil).Fragment(), "later chapters carry a time fragment") + assert.True(t, strings.HasPrefix(m.TableOfContents[1].URL(nil, nil).Fragment(), "t="), + "chapter fragments use media-fragment time syntax") +} + +// A playlist file takes precedence over both embedded chapters and the per-file +// fallback when building the table of contents. +func TestAudioRichPlaylistTOCPreferred(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{ + "artofletters_00_lynd.opus", + "artofletters_01_lynd.opus", + "artofletters_02_lynd.opus", + } { + src, err := os.ReadFile(filepath.Join(explodedDirPath, name)) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), src, 0o644)) + } + playlist := strings.Join([]string{ + "#EXTM3U", + "#EXTINF:62,Chapter One", + "artofletters_00_lynd.opus", + "#EXTINF:1050,Chapter Two", + "artofletters_01_lynd.opus", + "#EXTINF:789,Chapter Three", + "artofletters_02_lynd.opus", + "", + }, "\n") + require.NoError(t, os.WriteFile(filepath.Join(dir, "playlist.m3u"), []byte(playlist), 0o644)) + + m := parseRichAudio(t, dir).Build().Manifest + require.Len(t, m.ReadingOrder, 3, "playlist file is not part of the reading order") + require.Len(t, m.TableOfContents, 3) + assert.Equal(t, []string{"Chapter One", "Chapter Two", "Chapter Three"}, tocTitles(m.TableOfContents), + "the TOC should come from the playlist, not the per-file titles") +} + +// A CUE sheet splits a single audio file into tracks, producing a TOC with +// media-fragment time offsets. +func TestAudioRichCUETOC(t *testing.T) { + dir := t.TempDir() + src, err := os.ReadFile(filepath.Join(explodedDirPath, "artofletters_01_lynd.opus")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "book.opus"), src, 0o644)) + + cue := strings.Join([]string{ + `TITLE "The Art of Letters"`, + `PERFORMER "Robert Lynd"`, + `FILE "book.opus" WAVE`, + ` TRACK 01 AUDIO`, + ` TITLE "Opening"`, + ` INDEX 01 00:00:00`, + ` TRACK 02 AUDIO`, + ` TITLE "Halfway"`, + ` INDEX 01 05:00:00`, + "", + }, "\n") + require.NoError(t, os.WriteFile(filepath.Join(dir, "book.cue"), []byte(cue), 0o644)) + + m := parseRichAudio(t, dir).Build().Manifest + require.Len(t, m.ReadingOrder, 1) + require.Len(t, m.TableOfContents, 2) + assert.Equal(t, []string{"Opening", "Halfway"}, tocTitles(m.TableOfContents)) + assert.Empty(t, m.TableOfContents[0].URL(nil, nil).Fragment(), "first track starts at 0") + assert.Equal(t, "t=300", m.TableOfContents[1].URL(nil, nil).Fragment(), + "the second track points into the file at its INDEX time") +} + +func TestAudioZABAccepted(t *testing.T) { + withAudioParser(t, zabPath, func(b *pub.Builder) { + assert.NotNil(t, b) + }) +} + +func TestAudioExplodedDirectoryAccepted(t *testing.T) { + withAudioParser(t, explodedDirPath, func(b *pub.Builder) { + assert.NotNil(t, b) + }) +} + +func TestAudioStandaloneFileAccepted(t *testing.T) { + withAudioParser(t, standaloneFilePath, func(b *pub.Builder) { + assert.NotNil(t, b) + }) +} + +func TestAudioConformsToAudiobookProfile(t *testing.T) { + withAudioParser(t, zabPath, func(b *pub.Builder) { + m := b.Build().Manifest + assert.Equal(t, manifest.Profiles{manifest.ProfileAudiobook}, m.Metadata.ConformsTo) + }) +} + +func TestAudioManifestContext(t *testing.T) { + withAudioParser(t, zabPath, func(b *pub.Builder) { + m := b.Build().Manifest + assert.Equal(t, manifest.Strings{manifest.WebpubManifestContext}, m.Context) + }) +} + +func TestAudioZABReadingOrderSortedAlphabetically(t *testing.T) { + withAudioParser(t, zabPath, func(b *pub.Builder) { + ro := b.Build().Manifest.ReadingOrder + require.Len(t, ro, 3, "every audio track should be in the reading order") + + // Relativize against the archive root to assert the exact, ordered hrefs. + base, _ := url.URLFromDecodedPath("Art of Letters/") + hrefs := make([]string, 0, len(ro)) + for _, link := range ro { + hrefs = append(hrefs, base.Relativize(link.URL(nil, nil)).String()) + } + assert.Exactly(t, []string{ + "artofletters_00_lynd.opus", + "artofletters_01_lynd.opus", + "artofletters_02_lynd.opus", + }, hrefs, "reading order should be sorted alphabetically") + }) +} + +func TestAudioExplodedDirectoryReadingOrder(t *testing.T) { + withAudioParser(t, explodedDirPath, func(b *pub.Builder) { + ro := b.Build().Manifest.ReadingOrder + require.Len(t, ro, 3) + assert.Exactly(t, []string{ + "artofletters_00_lynd.opus", + "artofletters_01_lynd.opus", + "artofletters_02_lynd.opus", + }, readingOrderFilenames(ro), "reading order should be sorted alphabetically") + }) +} + +// Hrefs for files in an exploded directory must be relative (no leading "/"). +func TestAudioExplodedDirectoryRelativeHrefs(t *testing.T) { + m := parseRichAudio(t, explodedDirPath).Build().Manifest + require.Len(t, m.ReadingOrder, 3) + for _, l := range m.ReadingOrder { + assert.False(t, strings.HasPrefix(l.Href.String(), "/"), + "reading order href %q should not be absolute", l.Href.String()) + } + require.NotEmpty(t, m.TableOfContents) + for _, l := range m.TableOfContents { + assert.False(t, strings.HasPrefix(l.Href.String(), "/"), + "toc href %q should not be absolute", l.Href.String()) + } + assert.Equal(t, "artofletters_00_lynd.opus", m.ReadingOrder[0].Href.String()) +} + +func TestAudioStandaloneFile(t *testing.T) { + withAudioParser(t, standaloneFilePath, func(b *pub.Builder) { + m := b.Build().Manifest + require.Len(t, m.ReadingOrder, 1) + assert.Equal(t, "artofletters_00_lynd.opus", m.ReadingOrder[0].URL(nil, nil).Filename()) + assert.Equal(t, manifest.Profiles{manifest.ProfileAudiobook}, m.Metadata.ConformsTo) + // No archive or parent folder to infer a title from, so it falls back to + // the asset's filename. + assert.Equal(t, "artofletters_00_lynd.opus", m.Metadata.Title()) + }) +} + +func TestAudioTitleFromArchiveRootDirectory(t *testing.T) { + withAudioParser(t, zabPath, func(b *pub.Builder) { + assert.Equal(t, "Art of Letters", b.Build().Manifest.Metadata.Title(), + "title should be inferred from the archive's common root directory") + }) +} + +func TestAudioTitleFallsBackToFilename(t *testing.T) { + withAudioParser(t, explodedDirPath, func(b *pub.Builder) { + // The tracks share no common parent folder inside the fetcher, so the + // title falls back to the asset's name (the directory name). + assert.Equal(t, "art_letters", b.Build().Manifest.Metadata.Title()) + }) +} + +func TestAudioReadingOrderHasNoCover(t *testing.T) { + withAudioParser(t, zabPath, func(b *pub.Builder) { + // Unlike the image parser, the audio parser does not promote the first + // resource to a cover. + assert.Nil(t, b.Build().Manifest.ReadingOrder.FirstWithRel("cover")) + }) +} + +func TestAudioRejectsNonAudioArchive(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "book.pdf"), []byte("%PDF-1.7\n"), 0o644)) + + b, err := parseAudio(t, dir) + assert.NoError(t, err) + assert.Nil(t, b, "an asset containing non-audio files should be rejected") +} + +func TestAudioErrorsWhenNoAudioFile(t *testing.T) { + dir := t.TempDir() + // ".txt" is an accepted "extra" extension (e.g. liner notes) but is not an + // actual audio track, so the parser accepts the asset yet finds nothing to + // add to the reading order. + require.NoError(t, os.WriteFile(filepath.Join(dir, "liner-notes.txt"), []byte("Robert Lynd"), 0o644)) + + b, err := parseAudio(t, dir) + assert.Nil(t, b) + require.Error(t, err) + assert.EqualError(t, err, "no audio file found in the publication") +} diff --git a/pkg/parser/audio/playlist.go b/pkg/parser/audio/playlist.go new file mode 100644 index 00000000..93534771 --- /dev/null +++ b/pkg/parser/audio/playlist.go @@ -0,0 +1,230 @@ +package audio + +import ( + "encoding/xml" + "regexp" + "strconv" + "strings" +) + +// playlistEntry is a single reference in a playlist file: a resource path, an +// optional human-readable title and an optional start offset within the +// resource (in seconds), used by formats such as CUE sheets that split a single +// audio file into tracks. +type playlistEntry struct { + Path string + Title string + Start float64 +} + +// parsePlaylist parses the supported playlist formats (M3U/M3U8, PLS, XSPF, CUE) +// into an ordered list of entries. It returns nil for formats it cannot parse, +// in which case callers fall back to other table-of-contents sources. +func parsePlaylist(ext string, content []byte) []playlistEntry { + switch strings.ToLower(ext) { + case "m3u", "m3u8": + return parseM3U(content) + case "pls": + return parsePLS(content) + case "xspf": + return parseXSPF(content) + case "cue": + return parseCUE(content) + default: + return nil + } +} + +var extinfRegexp = regexp.MustCompile(`(?i)^#EXTINF:[^,]*,(.*)$`) + +// parseM3U parses an (extended) M3U playlist. #EXTINF lines provide the title of +// the following resource path. +func parseM3U(content []byte) []playlistEntry { + var entries []playlistEntry + var pendingTitle string + for raw := range strings.SplitSeq(string(content), "\n") { + line := strings.TrimSpace(strings.TrimSuffix(raw, "\r")) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + if m := extinfRegexp.FindStringSubmatch(line); m != nil { + pendingTitle = strings.TrimSpace(m[1]) + } + continue + } + entries = append(entries, playlistEntry{Path: line, Title: pendingTitle}) + pendingTitle = "" + } + return entries +} + +// parsePLS parses a PLS playlist (an INI-like format with FileN/TitleN keys). +func parsePLS(content []byte) []playlistEntry { + files := map[int]string{} + titles := map[int]string{} + maxIndex := 0 + for raw := range strings.SplitSeq(string(content), "\n") { + line := strings.TrimSpace(raw) + before, after, ok := strings.Cut(line, "=") + if !ok { + continue + } + key := strings.ToLower(strings.TrimSpace(before)) + value := strings.TrimSpace(after) + switch { + case strings.HasPrefix(key, "file"): + if n, err := strconv.Atoi(key[4:]); err == nil { + files[n] = value + if n > maxIndex { + maxIndex = n + } + } + case strings.HasPrefix(key, "title"): + if n, err := strconv.Atoi(key[5:]); err == nil { + titles[n] = value + } + } + } + + var entries []playlistEntry + for i := 1; i <= maxIndex; i++ { + path, ok := files[i] + if !ok { + continue + } + entries = append(entries, playlistEntry{Path: path, Title: titles[i]}) + } + return entries +} + +// parseXSPF parses an XSPF (XML Shareable Playlist Format) playlist. +func parseXSPF(content []byte) []playlistEntry { + var doc struct { + Tracks []struct { + Location string `xml:"location"` + Title string `xml:"title"` + } `xml:"trackList>track"` + } + if err := xml.Unmarshal(content, &doc); err != nil { + return nil + } + var entries []playlistEntry + for _, t := range doc.Tracks { + location := strings.TrimSpace(t.Location) + if location == "" { + continue + } + entries = append(entries, playlistEntry{Path: location, Title: strings.TrimSpace(t.Title)}) + } + return entries +} + +// parseCUE parses a CUE sheet, which splits one or more audio files into tracks +// marked by INDEX timestamps. Each TRACK becomes an entry pointing at the +// preceding FILE, with its start offset taken from INDEX 01 (or INDEX 00 as a +// fallback). +func parseCUE(content []byte) []playlistEntry { + var entries []playlistEntry + var currentFile string + + var ( + inTrack bool + title string + start float64 + hasStart bool + ) + flush := func() { + if inTrack && currentFile != "" { + entries = append(entries, playlistEntry{Path: currentFile, Title: title, Start: start}) + } + inTrack, title, start, hasStart = false, "", 0, false + } + + for raw := range strings.SplitSeq(string(content), "\n") { + line := strings.TrimSpace(strings.TrimSuffix(raw, "\r")) + if line == "" { + continue + } + keyword, rest, _ := strings.Cut(line, " ") + switch strings.ToUpper(keyword) { + case "FILE": + flush() // a pending track belongs to the previous FILE + currentFile = cueValue(rest, true) + case "TRACK": + flush() + inTrack = true + case "TITLE": + if inTrack { + title = cueValue(rest, false) + } + case "INDEX": + if !inTrack { + continue + } + // INDEX 01 is the canonical track start; INDEX 00 (pregap) is only a + // fallback. Index lines are ordered 00 before 01, so 01 wins. + if num, t, ok := cueIndex(rest); ok { + if num == 1 { + start, hasStart = t, true + } else if num == 0 && !hasStart { + start = t + } + } + } + } + flush() + return entries +} + +// cueValue extracts a CUE field value: the text between double quotes when +// quoted, otherwise either the first whitespace-delimited token (firstToken) or +// the whole remainder. +func cueValue(s string, firstToken bool) string { + s = strings.TrimSpace(s) + if len(s) >= 2 && s[0] == '"' { + if end := strings.IndexByte(s[1:], '"'); end >= 0 { + return s[1 : 1+end] + } + } + if firstToken { + if i := strings.IndexByte(s, ' '); i >= 0 { + return s[:i] + } + } + return s +} + +// cueIndex parses an INDEX line body ("NN MM:SS:FF") into its index number and +// time offset in seconds. +func cueIndex(rest string) (int, float64, bool) { + fields := strings.Fields(rest) + if len(fields) < 2 { + return 0, 0, false + } + num, err := strconv.Atoi(fields[0]) + if err != nil { + return 0, 0, false + } + t, ok := parseCUETime(fields[1]) + if !ok { + return 0, 0, false + } + return num, t, true +} + +// parseCUETime parses a CUE timecode "MM:SS:FF" (FF = frames, 75 per second) +// into seconds. +func parseCUETime(s string) (float64, bool) { + parts := strings.Split(s, ":") + if len(parts) != 3 { + return 0, false + } + mm, e1 := strconv.Atoi(parts[0]) + ss, e2 := strconv.Atoi(parts[1]) + ff, e3 := strconv.Atoi(parts[2]) + if e1 != nil || e2 != nil || e3 != nil { + return 0, false + } + return float64(mm)*60 + float64(ss) + float64(ff)/75, true +} diff --git a/pkg/parser/audio/probe.go b/pkg/parser/audio/probe.go new file mode 100644 index 00000000..dd9119f8 --- /dev/null +++ b/pkg/parser/audio/probe.go @@ -0,0 +1,120 @@ +package audio + +import ( + "context" + "math" + "path/filepath" + "strings" + + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/manifest" +) + +// probeResult holds the per-resource information extracted while probing an +// audio file. +type probeResult struct { + Duration float64 // Length of the resource in seconds (0 if unknown). + Bitrate float64 // Average bitrate in kbps (0 if unknown). + Chapters []chapterEntry // Embedded chapters, if any. +} + +// probeAudioFile extracts the duration, bitrate and any embedded chapters from a +// single audio resource. The bitrate is computed as the average over the whole +// resource, which is the most portable definition across the many supported +// container formats. +func probeAudioFile(ctx context.Context, res fetcher.Resource, link manifest.Link, tags *audioTags, extractChapters bool) probeResult { + size, _ := res.Length(ctx) + + var result probeResult + switch audioFamily(link) { + case familyMP4: + duration, chapters, _ := probeMP4(ctx, res, extractChapters) + result.Duration = duration + result.Chapters = chapters + case familyOgg: + result.Duration = probeOggDuration(ctx, res, size) + if extractChapters { + result.Chapters = vorbisChapters(tags) + } + case familyFLAC: + result.Duration = probeFLACDuration(ctx, res) + if extractChapters { + result.Chapters = vorbisChapters(tags) + } + case familyWAV: + result.Duration = probeWAVDuration(ctx, res, size) + case familyAIFF: + result.Duration = probeAIFFDuration(ctx, res, size) + case familyMP3: + result.Duration = probeMP3Duration(ctx, res, size) + case familyWebM: + result.Duration = probeWebMDuration(ctx, res, size) + case familyAAC: + result.Duration = probeAACDuration(ctx, res, size) + } + + if result.Duration > 0 && size > 0 { + // kbps = bytes * 8 bits / 1000 / seconds, rounded to one decimal place. + kbps := float64(size) * 8 / 1000 / result.Duration + result.Bitrate = math.Round(kbps*10) / 10 + } + return result +} + +type audioFormatFamily int + +const ( + familyUnknown audioFormatFamily = iota + familyMP4 + familyOgg + familyFLAC + familyWAV + familyAIFF + familyMP3 + familyWebM + familyAAC +) + +// audioFamily classifies an audio resource into a parsing family based on its +// file extension. +func audioFamily(link manifest.Link) audioFormatFamily { + switch linkExtension(link) { + case "mp4", "m4a", "m4b", "m4p", "m4r", "alac": + return familyMP4 + case "ogg", "oga", "opus", "mogg": + return familyOgg + case "flac": + return familyFLAC + case "wav", "wave": + return familyWAV + case "aiff", "aif", "aifc": + return familyAIFF + case "mp3": + return familyMP3 + case "webm": + return familyWebM + case "aac": + return familyAAC + } + return familyUnknown +} + +// linkExtension returns the lower-cased file extension (without the dot) of a +// link's HREF. +func linkExtension(link manifest.Link) string { + ext := strings.ToLower(filepath.Ext(link.URL(nil, nil).Path())) + return strings.TrimPrefix(ext, ".") +} + +// readRange reads length bytes starting at offset, clamping at the resource's +// end. It returns nil on error. +func readRange(ctx context.Context, res fetcher.Resource, offset, length int64) []byte { + if length <= 0 { + return nil + } + data, err := res.Read(ctx, offset, offset+length-1) + if err != nil { + return nil + } + return data +} diff --git a/pkg/parser/audio/readcache.go b/pkg/parser/audio/readcache.go new file mode 100644 index 00000000..b0df2394 --- /dev/null +++ b/pkg/parser/audio/readcache.go @@ -0,0 +1,166 @@ +package audio + +import ( + "context" + + "github.com/readium/go-toolkit/pkg/fetcher" +) + +// defaultCacheBlockSize is the granularity at which readCache fetches data when +// no size is configured. Reads are rounded up to whole blocks so that the many +// small, overlapping reads performed while probing an audio file coalesce into a +// few range requests. +const defaultCacheBlockSize = 256 << 10 // 256 KiB + +// readCache wraps a fetcher.Resource and serves reads from fixed-size blocks +// fetched on demand. +// +// Probing an audio file performs many small reads — tag headers, container +// boxes, duration markers, chapter samples — and the tag and duration passes +// both re-read the header region. On remote sources (HTTP, S3) and ZIP archives +// every read is a byte-range request, so without caching opening a multi-file +// audiobook fans out into hundreds of requests. Coalescing reads into block +// fetches (and reusing them across passes) cuts that to a handful per file. +type readCache struct { + fetcher.Resource + size int64 + blockSize int64 + blocks map[int64][]byte +} + +func newReadCache(res fetcher.Resource, size, blockSize int64) *readCache { + if blockSize <= 0 { + blockSize = defaultCacheBlockSize + } + return &readCache{Resource: res, size: size, blockSize: blockSize, blocks: make(map[int64][]byte)} +} + +// Length returns the known size without hitting the underlying resource. +func (c *readCache) Length(ctx context.Context) (int64, *fetcher.ResourceError) { + if c.size >= 0 { + return c.size, nil + } + return c.Resource.Length(ctx) +} + +// Read serves the inclusive byte range [start, end] from cached blocks, fetching +// any missing blocks first. +func (c *readCache) Read(ctx context.Context, start, end int64) ([]byte, *fetcher.ResourceError) { + // Whole-resource reads, or an unknown size, bypass the block cache. + if (start == 0 && end == 0) || c.size <= 0 || end < start { + return c.Resource.Read(ctx, start, end) + } + if start >= c.size { + return []byte{}, nil + } + if end >= c.size { + end = c.size - 1 + } + + firstBlock := start / c.blockSize + lastBlock := end / c.blockSize + if err := c.fetch(ctx, firstBlock, lastBlock); err != nil { + return nil, err + } + + out := make([]byte, 0, end-start+1) + for b := firstBlock; b <= lastBlock; b++ { + blk := c.blocks[b] + base := b * c.blockSize + lo := int64(0) + if start > base { + lo = start - base + } + hi := min(end-base+1, int64(len(blk))) + if lo < hi { + out = append(out, blk[lo:hi]...) + } + } + return out, nil +} + +// cachedSlice returns the bytes for the inclusive range [start, end] if they are +// already fully present in the cache, without performing any read. The second +// return value reports whether it was a hit. The returned slice is a copy. +func (c *readCache) cachedSlice(start, end int64) ([]byte, bool) { + if c.size <= 0 || start < 0 || end < start { + return nil, false + } + if end >= c.size { + end = c.size - 1 + } + + firstBlock := start / c.blockSize + lastBlock := end / c.blockSize + for b := firstBlock; b <= lastBlock; b++ { + blk, ok := c.blocks[b] + if !ok { + return nil, false + } + // The needed portion of this block must actually be present (the final + // cached block may be short if it sits at the end of the resource). + base := b * c.blockSize + needEnd := end + if blockEnd := base + c.blockSize - 1; needEnd > blockEnd { + needEnd = blockEnd + } + if int64(len(blk)) <= needEnd-base { + return nil, false + } + } + + out := make([]byte, 0, end-start+1) + for b := firstBlock; b <= lastBlock; b++ { + blk := c.blocks[b] + base := b * c.blockSize + lo := int64(0) + if start > base { + lo = start - base + } + hi := min(end-base+1, int64(len(blk))) + out = append(out, blk[lo:hi]...) + } + return out, true +} + +// fetch ensures every block in [first, last] is cached, retrieving each run of +// contiguous missing blocks in a single underlying read. +func (c *readCache) fetch(ctx context.Context, first, last int64) *fetcher.ResourceError { + for b := first; b <= last; { + if _, ok := c.blocks[b]; ok { + b++ + continue + } + runStart := b + for b <= last { + if _, ok := c.blocks[b]; ok { + break + } + b++ + } + runEnd := b - 1 + + off := runStart * c.blockSize + endOff := (runEnd+1)*c.blockSize - 1 + if endOff >= c.size { + endOff = c.size - 1 + } + data, err := c.Resource.Read(ctx, off, endOff) + if err != nil { + return err + } + for bi := runStart; bi <= runEnd; bi++ { + lo := (bi - runStart) * c.blockSize + if lo >= int64(len(data)) { + c.blocks[bi] = []byte{} + continue + } + hi := lo + c.blockSize + if hi > int64(len(data)) { + hi = int64(len(data)) + } + c.blocks[bi] = data[lo:hi] + } + } + return nil +} diff --git a/pkg/parser/audio/readcache_test.go b/pkg/parser/audio/readcache_test.go new file mode 100644 index 00000000..d3a9f103 --- /dev/null +++ b/pkg/parser/audio/readcache_test.go @@ -0,0 +1,99 @@ +package audio + +import ( + "context" + "testing" + + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// countingResource records how many underlying Read calls are made, to verify +// that readCache coalesces requests. +type countingResource struct { + fetcher.Resource + data []byte + reads int +} + +func (r *countingResource) Length(_ context.Context) (int64, *fetcher.ResourceError) { + return int64(len(r.data)), nil +} + +func (r *countingResource) Read(_ context.Context, start, end int64) ([]byte, *fetcher.ResourceError) { + r.reads++ + if end >= int64(len(r.data)) { + end = int64(len(r.data)) - 1 + } + if start > end { + return []byte{}, nil + } + return r.data[start : end+1], nil +} + +func TestReadCacheCorrectnessAndCoalescing(t *testing.T) { + data := make([]byte, 700<<10) // 700 KiB -> blocks 0,1,2 (256 KiB each) + for i := range data { + data[i] = byte(i) + } + base, _ := bytesResource(data) + src := &countingResource{Resource: base, data: data} + c := newReadCache(src, int64(len(data)), 0) // 0 -> default 256 KiB blocks + require.EqualValues(t, defaultCacheBlockSize, c.blockSize) + ctx := t.Context() + + read := func(start, end int64) []byte { + b, err := c.Read(ctx, start, end) + require.Nil(t, err) + return b + } + + // First read of block 0. + assert.Equal(t, data[0:10], read(0, 9)) + assert.Equal(t, 1, src.reads) + + // Subsequent reads within block 0 hit the cache (no new request). + assert.Equal(t, data[100:201], read(100, 200)) + assert.Equal(t, 1, src.reads) + + // A read into block 1 triggers exactly one more request. + assert.Equal(t, data[300<<10:(300<<10)+50], read(300<<10, (300<<10)+49)) + assert.Equal(t, 2, src.reads) + + // A span across blocks 0-2: only block 2 is missing -> one more request. + assert.Equal(t, data[0:600<<10], read(0, (600<<10)-1)) + assert.Equal(t, 3, src.reads) + + // Reading past EOF returns the clamped tail, not an error. + got := read(int64(len(data))-5, int64(len(data))+100) + assert.Equal(t, data[len(data)-5:], got) +} + +func TestReadCacheCustomBlockSize(t *testing.T) { + data := make([]byte, 400<<10) + for i := range data { + data[i] = byte(i) + } + base, _ := bytesResource(data) + src := &countingResource{Resource: base, data: data} + c := newReadCache(src, int64(len(data)), 128<<10) // 128 KiB blocks + require.EqualValues(t, 128<<10, c.blockSize) + ctx := t.Context() + + b, err := c.Read(ctx, 0, 9) + require.Nil(t, err) + assert.Equal(t, data[0:10], b) + assert.Equal(t, 1, src.reads) // block 0 + + // Still within block 0 (< 128 KiB): cached. + _, err = c.Read(ctx, 100, 200) + require.Nil(t, err) + assert.Equal(t, 1, src.reads) + + // At 200 KiB: block 1 with 128 KiB blocks (would be block 0 at 256 KiB). + b, err = c.Read(ctx, 200<<10, (200<<10)+9) + require.Nil(t, err) + assert.Equal(t, data[200<<10:(200<<10)+10], b) + assert.Equal(t, 2, src.reads) +} diff --git a/pkg/parser/audio/rich.go b/pkg/parser/audio/rich.go new file mode 100644 index 00000000..ab094209 --- /dev/null +++ b/pkg/parser/audio/rich.go @@ -0,0 +1,244 @@ +package audio + +import ( + "context" + "net/url" + "path" + "sort" + "strings" + + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/pub" + "golang.org/x/sync/errgroup" +) + +// defaultProbeConcurrency bounds how many reading-order files are probed in +// parallel by default. Probing is I/O-bound (range requests to a remote source +// or archive), so a small amount of concurrency hides per-file latency without +// flooding the backend with requests. +const defaultProbeConcurrency = 8 + +// enrich performs the rich-parsing pass over an audiobook: it probes every +// reading-order resource for its duration and bitrate, extracts publication +// metadata (and a cover) from the first audio file, and builds a table of +// contents. It mutates m in place and returns a cover service factory (or nil +// when no cover was found). +func (p AudioParser) enrich(ctx context.Context, fetch fetcher.Fetcher, m *manifest.Manifest) pub.ServiceFactory { + var firstTags *audioTags + var totalDuration float64 + var embeddedChapters manifest.LinkList + + readingOrder := m.ReadingOrder + + // Acquire all resource handles serially: Get is cheap, but not guaranteed to + // be safe for concurrent use (e.g. FileFetcher tracks handles in a shared + // slice). The expensive part — reading each file — is then parallelized. + resources := make([]fetcher.Resource, len(readingOrder)) + for i := range readingOrder { + resources[i] = fetch.Get(ctx, readingOrder[i]) + } + + // Probe files concurrently, bounded by the configured concurrency. Reading + // distinct resources in parallel is safe for the file and archive fetchers, + // and hides per-file round-trip latency on remote sources. + concurrency := p.concurrency + if concurrency <= 0 { + concurrency = defaultProbeConcurrency + } + probed := make([]probedItem, len(readingOrder)) + var g errgroup.Group + g.SetLimit(concurrency) + extractChapters := !p.skipEmbeddedChapters + blockSize := int64(p.cacheBlockSize) + for i := range readingOrder { + i, link, res := i, readingOrder[i], resources[i] + g.Go(func() error { + probed[i] = probeReadingOrderItem(ctx, res, link, extractChapters, blockSize) + return nil + }) + } + _ = g.Wait() + + // Combine the results in reading order so the output is deterministic. + for i := range readingOrder { + link := readingOrder[i] + p := probed[i] + + if p.probe.Duration > 0 { + link.Duration = p.probe.Duration + totalDuration += p.probe.Duration + } + if p.probe.Bitrate > 0 { + link.Bitrate = p.probe.Bitrate + } + if p.tags != nil && p.tags.Title != "" { + link.Title = p.tags.Title + } + if len(p.probe.Chapters) > 0 { + embeddedChapters = append(embeddedChapters, chaptersToLinks(p.probe.Chapters, link)...) + } + + readingOrder[i] = link + if i == 0 { + firstTags = p.tags + } + } + m.ReadingOrder = readingOrder + + if totalDuration > 0 { + d := totalDuration + m.Metadata.Duration = &d + } + + applyTagsToMetadata(&m.Metadata, firstTags) + + // Table of contents priority: a playlist file wins over embedded chapters, + // which in turn win over a flat per-file listing. + if toc := playlistTOC(ctx, fetch, readingOrder); len(toc) > 0 { + m.TableOfContents = toc + } else if len(embeddedChapters) > 0 { + m.TableOfContents = embeddedChapters + } else if toc := perFileTOC(readingOrder); len(toc) > 0 { + m.TableOfContents = toc + } + + if firstTags != nil { + return coverServiceFactory(firstTags.Picture) + } + return nil +} + +// probedItem is the per-file result of the parallel probing pass. +type probedItem struct { + tags *audioTags + probe probeResult +} + +// probeReadingOrderItem reads the tags and probes the duration/bitrate/chapters +// of a single reading-order resource, always releasing the resource handle. +// +// All reads go through a per-file block cache so that the tag and duration +// passes — which perform many small, overlapping reads of the header region — +// coalesce into a handful of range requests rather than one request each. This +// matters most for remote sources (HTTP, S3) and ZIP archives. +func probeReadingOrderItem(ctx context.Context, res fetcher.Resource, link manifest.Link, extractChapters bool, blockSize int64) probedItem { + defer res.Close() + size, _ := res.Length(ctx) + cached := newReadCache(res, size, blockSize) + tags := readAudioTags(cached) + return probedItem{tags: tags, probe: probeAudioFile(ctx, cached, link, tags, extractChapters)} +} + +// playlistTOC looks for the first parseable playlist file in the publication and +// converts it into a table of contents whose entries map to the reading order. +func playlistTOC(ctx context.Context, fetch fetcher.Fetcher, readingOrder manifest.LinkList) manifest.LinkList { + links, err := fetch.Links(ctx) + if err != nil { + return nil + } + + playlists := make(manifest.LinkList, 0) + for _, l := range links { + if _, ok := allowed_extensions_audio_extra[linkExtension(l)]; ok { + playlists = append(playlists, l) + } + } + sort.Slice(playlists, func(i, j int) bool { + return playlists[i].Href.String() < playlists[j].Href.String() + }) + + for _, l := range playlists { + content := readResource(ctx, fetch, l) + if content == nil { + continue + } + entries := parsePlaylist(linkExtension(l), content) + if len(entries) == 0 { + continue + } + if toc := playlistEntriesToTOC(entries, readingOrder); len(toc) > 0 { + return toc + } + } + return nil +} + +// readResource reads the full content of a link's resource, always releasing the +// resource handle (even on a read error or panic). +func readResource(ctx context.Context, fetch fetcher.Fetcher, link manifest.Link) []byte { + res := fetch.Get(ctx, link) + defer res.Close() + content, err := res.Read(ctx, 0, 0) + if err != nil { + return nil + } + return content +} + +// playlistEntriesToTOC maps playlist entries onto reading-order resources by +// matching file names, producing one TOC link per resolvable entry. +func playlistEntriesToTOC(entries []playlistEntry, readingOrder manifest.LinkList) manifest.LinkList { + byName := make(map[string]manifest.Link, len(readingOrder)) + for _, l := range readingOrder { + byName[strings.ToLower(l.URL(nil, nil).Filename())] = l + } + + toc := make(manifest.LinkList, 0, len(entries)) + for _, e := range entries { + name := playlistEntryName(e.Path) + l, ok := byName[name] + if !ok { + continue + } + title := e.Title + if title == "" { + title = l.Title + } + toc = append(toc, chapterLink(l, title, e.Start)) + } + return toc +} + +// playlistEntryName extracts the lower-cased file name from a playlist entry +// path, ignoring any query string and decoding percent-escapes. +func playlistEntryName(p string) string { + p = strings.ReplaceAll(p, "\\", "/") + if i := strings.IndexAny(p, "?#"); i >= 0 { + p = p[:i] + } + base := path.Base(p) + if decoded, err := url.PathUnescape(base); err == nil { + base = decoded + } + return strings.ToLower(base) +} + +// perFileTOC builds a flat table of contents with one entry per reading-order +// resource. It is used as a last resort when no playlist or embedded chapters +// are available, and only when there is more than one titled resource. +func perFileTOC(readingOrder manifest.LinkList) manifest.LinkList { + if len(readingOrder) < 2 { + return nil + } + titled := false + for _, l := range readingOrder { + if l.Title != "" { + titled = true + break + } + } + if !titled { + return nil + } + + toc := make(manifest.LinkList, 0, len(readingOrder)) + for _, l := range readingOrder { + title := l.Title + if title == "" { + title = l.URL(nil, nil).Filename() + } + toc = append(toc, chapterLink(l, title, 0)) + } + return toc +} diff --git a/pkg/parser/audio/testdata/AroundTheWorldInEightyDays.m4b b/pkg/parser/audio/testdata/AroundTheWorldInEightyDays.m4b new file mode 100644 index 00000000..6f2596f7 --- /dev/null +++ b/pkg/parser/audio/testdata/AroundTheWorldInEightyDays.m4b @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f2b02e4aac7d6ea72fe647db7b9bcb1131a17cd7be80f9380df13fb12de0c7c +size 189714445 diff --git a/pkg/parser/audio/testdata/art_letters.zab b/pkg/parser/audio/testdata/art_letters.zab new file mode 100644 index 00000000..7c39b078 --- /dev/null +++ b/pkg/parser/audio/testdata/art_letters.zab @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a26415949a64097090655b04136b55bddc9a3196a2ecd4e2507a673f26869e0e +size 7328837 diff --git a/pkg/parser/audio/testdata/art_letters/artofletters_00_lynd.opus b/pkg/parser/audio/testdata/art_letters/artofletters_00_lynd.opus new file mode 100644 index 00000000..c398fe75 --- /dev/null +++ b/pkg/parser/audio/testdata/art_letters/artofletters_00_lynd.opus @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54e15d4fd793088e82928d88a3bd6cab33e8bfa1b60b9456948393470a5a50f4 +size 238835 diff --git a/pkg/parser/audio/testdata/art_letters/artofletters_01_lynd.opus b/pkg/parser/audio/testdata/art_letters/artofletters_01_lynd.opus new file mode 100644 index 00000000..b7a8c0f1 --- /dev/null +++ b/pkg/parser/audio/testdata/art_letters/artofletters_01_lynd.opus @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b00c58e2388faee2543cf034cda6212d2b2c7da9133e004f3cdd4e91d26f39a +size 3992223 diff --git a/pkg/parser/audio/testdata/art_letters/artofletters_02_lynd.opus b/pkg/parser/audio/testdata/art_letters/artofletters_02_lynd.opus new file mode 100644 index 00000000..bc516047 --- /dev/null +++ b/pkg/parser/audio/testdata/art_letters/artofletters_02_lynd.opus @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e41920fc3bea992ea044e4141f2e66ce71fb5e8bb73032391fc73d7b17ac6195 +size 3125494 diff --git a/pkg/parser/audio/testdata/luvsic.cue b/pkg/parser/audio/testdata/luvsic.cue new file mode 100644 index 00000000..d9c15800 --- /dev/null +++ b/pkg/parser/audio/testdata/luvsic.cue @@ -0,0 +1,71 @@ +REM GENRE Hip-Hop +REM DATE 2015 +REM DISCID B50F870D +REM COMMENT "ExactAudioCopy v1.1" +PERFORMER "Nujabes feat. Shing02" +TITLE "Luv(sic) Hexalogy" +FILE "01 - Luv(sic).wav" WAVE + TRACK 01 AUDIO + TITLE "Luv(sic)" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "02 - Luv(sic) Part 2.wav" WAVE + TRACK 02 AUDIO + TITLE "Luv(sic) Part 2" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "03 - Luv(sic) Part 3.wav" WAVE + TRACK 03 AUDIO + TITLE "Luv(sic) Part 3" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "04 - Luv(sic) Part 4.wav" WAVE + TRACK 04 AUDIO + TITLE "Luv(sic) Part 4" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "05 - Luv(sic) Part 5.wav" WAVE + TRACK 05 AUDIO + TITLE "Luv(sic) Part 5" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "06 - Luv(sic) Grand Finale.wav" WAVE + TRACK 06 AUDIO + TITLE "Luv(sic) Grand Finale" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "07 - Luv(sic) 12'' Remix.wav" WAVE + TRACK 07 AUDIO + TITLE "Luv(sic) 12' Remix" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "08 - Luv(sic) Part 2 Acoustica.wav" WAVE + TRACK 08 AUDIO + TITLE "Luv(sic) Part 2 Acoustica" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "09 - Luv(sic) Part 3 Ta-ku Remix.wav" WAVE + TRACK 09 AUDIO + TITLE "Luv(sic) Part 3 Ta-ku Remix" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "10 - Luv(sic) Part 4 LASTorder Remix.wav" WAVE + TRACK 10 AUDIO + TITLE "Luv(sic) Part 4 LASTorder Remix" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "11 - Luv(sic) Part 5 Jumpster Remix.wav" WAVE + TRACK 11 AUDIO + TITLE "Luv(sic) Part 5 Jumpster Remix" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "12 - Luv(sic) Part 6 Uyama Hiroto Remix.wav" WAVE + TRACK 12 AUDIO + TITLE "Luv(sic) Part 6 Uyama Hiroto Remix" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 +FILE "13 - Perfect Circle.wav" WAVE + TRACK 13 AUDIO + TITLE "Perfect Circle" + PERFORMER "Nujabes feat. Shing02" + INDEX 01 00:00:00 diff --git a/pkg/parser/audio/testdata/luvsic.m3u b/pkg/parser/audio/testdata/luvsic.m3u new file mode 100644 index 00000000..c442914d --- /dev/null +++ b/pkg/parser/audio/testdata/luvsic.m3u @@ -0,0 +1,27 @@ +#EXTM3U +#EXTINF:286,Nujabes feat. Shing02 - Luv(sic) +01 - Luv(sic).flac +#EXTINF:273,Nujabes feat. Shing02 - Luv(sic) Part 2 +02 - Luv(sic) Part 2.flac +#EXTINF:374,Nujabes feat. Shing02 - Luv(sic) Part 3 +03 - Luv(sic) Part 3.flac +#EXTINF:311,Nujabes feat. Shing02 - Luv(sic) Part 4 +04 - Luv(sic) Part 4.flac +#EXTINF:350,Nujabes feat. Shing02 - Luv(sic) Part 5 +05 - Luv(sic) Part 5.flac +#EXTINF:317,Nujabes feat. Shing02 - Luv(sic) Grand Finale +06 - Luv(sic) Grand Finale.flac +#EXTINF:298,Nujabes feat. Shing02 - Luv(sic) 12" Remix +07 - Luv(sic) 12'' Remix.flac +#EXTINF:394,Nujabes feat. Shing02 - Luv(sic) Part 2 Acoustica +08 - Luv(sic) Part 2 Acoustica.flac +#EXTINF:296,Nujabes feat. Shing02 - Luv(sic) Part 3 Ta-ku Remix +09 - Luv(sic) Part 3 Ta-ku Remix.flac +#EXTINF:279,Nujabes feat. Shing02 - Luv(sic) Part 4 LASTorder Remix +10 - Luv(sic) Part 4 LASTorder Remix.flac +#EXTINF:279,Nujabes feat. Shing02 - Luv(sic) Part 5 Jumpster Remix +11 - Luv(sic) Part 5 Jumpster Remix.flac +#EXTINF:279,Nujabes feat. Shing02 - Luv(sic) Part 6 Uyama Hiroto Remix +12 - Luv(sic) Part 6 Uyama Hiroto Remix.flac +#EXTINF:241,Nujabes feat. Shing02 - Perfect Circle +13 - Perfect Circle.flac diff --git a/pkg/parser/audio/toc.go b/pkg/parser/audio/toc.go new file mode 100644 index 00000000..a5b5c30a --- /dev/null +++ b/pkg/parser/audio/toc.go @@ -0,0 +1,108 @@ +package audio + +import ( + "math" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/readium/go-toolkit/pkg/manifest" +) + +// chapterEntry is a single chapter extracted from an audio file: a title and a +// start offset (in seconds) within that file. +type chapterEntry struct { + Title string + Start float64 +} + +// chaptersToLinks turns the chapters found within a single audio resource into +// table-of-contents links pointing into that resource using media-fragment time +// offsets (e.g. `track.m4b#t=123.4`). +func chaptersToLinks(entries []chapterEntry, base manifest.Link) manifest.LinkList { + links := make(manifest.LinkList, 0, len(entries)) + for _, e := range entries { + links = append(links, chapterLink(base, e.Title, e.Start)) + } + return links +} + +// chapterLink builds a TOC link into base at the given start offset. A negative +// or zero offset produces a link to the whole resource. +func chapterLink(base manifest.Link, title string, start float64) manifest.Link { + href := base.URL(nil, nil).String() + if start > 0 { + href += "#t=" + formatFragmentTime(start) + } + return manifest.Link{ + Href: manifest.MustNewHREFFromString(href, false), + MediaType: base.MediaType, + Title: title, + } +} + +// formatFragmentTime formats a number of seconds for a media fragment, rounded +// to the millisecond and without trailing zeros. +func formatFragmentTime(seconds float64) string { + rounded := math.Round(seconds*1000) / 1000 + return strconv.FormatFloat(rounded, 'f', -1, 64) +} + +var vorbisChapterKey = regexp.MustCompile(`^chapter(\d+)$`) + +// vorbisChapters extracts chapters declared via the Vorbis comment chapter +// extension (CHAPTERxxx / CHAPTERxxxNAME), used by Ogg and FLAC files. +func vorbisChapters(tags *audioTags) []chapterEntry { + if tags == nil || tags.Raw == nil { + return nil + } + + type idxChapter struct { + index int + entry chapterEntry + } + var found []idxChapter + for key, value := range tags.Raw { + m := vorbisChapterKey.FindStringSubmatch(strings.ToLower(key)) + if m == nil { + continue + } + timecode, ok := value.(string) + if !ok { + continue + } + start, ok := parseTimecode(timecode) + if !ok { + continue + } + index, _ := strconv.Atoi(m[1]) + name, _ := tags.Raw["CHAPTER"+m[1]+"NAME"].(string) + if name == "" { + name, _ = tags.Raw["chapter"+m[1]+"name"].(string) + } + found = append(found, idxChapter{index: index, entry: chapterEntry{Title: strings.TrimSpace(name), Start: start}}) + } + + sort.Slice(found, func(i, j int) bool { return found[i].index < found[j].index }) + entries := make([]chapterEntry, 0, len(found)) + for _, c := range found { + entries = append(entries, c.entry) + } + return entries +} + +// parseTimecode parses a "HH:MM:SS.mmm" timecode into seconds. +func parseTimecode(s string) (float64, bool) { + parts := strings.Split(strings.TrimSpace(s), ":") + if len(parts) != 3 { + return 0, false + } + h, err1 := strconv.ParseFloat(parts[0], 64) + m, err2 := strconv.ParseFloat(parts[1], 64) + sec, err3 := strconv.ParseFloat(parts[2], 64) + if err1 != nil || err2 != nil || err3 != nil { + return 0, false + } + return h*3600 + m*60 + sec, true +} diff --git a/pkg/parser/parser_image.go b/pkg/parser/image/parser.go similarity index 94% rename from pkg/parser/parser_image.go rename to pkg/parser/image/parser.go index 97894a9f..3217b4a9 100644 --- a/pkg/parser/parser_image.go +++ b/pkg/parser/image/parser.go @@ -1,4 +1,4 @@ -package parser +package image import ( "context" @@ -12,6 +12,7 @@ import ( "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/parser" "github.com/readium/go-toolkit/pkg/pub" ) @@ -19,6 +20,10 @@ import ( // It can also work for a standalone bitmap file. type ImageParser struct{} +func NewParser() ImageParser { + return ImageParser{} +} + // Parse implements PublicationParser func (p ImageParser) Parse(ctx context.Context, asset asset.PublicationAsset, fetcher fetcher.Fetcher) (*pub.Builder, error) { if ok, err := p.accepts(ctx, asset, fetcher); err != nil || !ok { @@ -50,7 +55,7 @@ func (p ImageParser) Parse(ctx context.Context, asset asset.PublicationAsset, fe }) // Try to figure out the publication's title - title := guessPublicationTitleFromFileStructure(ctx, fetcher) + title := parser.GuessPublicationTitleFromFileStructure(ctx, fetcher) if title == "" { title = asset.Name() } diff --git a/pkg/parser/parser_image_test.go b/pkg/parser/image/parser_test.go similarity index 99% rename from pkg/parser/parser_image_test.go rename to pkg/parser/image/parser_test.go index cbc9c310..419d7912 100644 --- a/pkg/parser/parser_image_test.go +++ b/pkg/parser/image/parser_test.go @@ -1,4 +1,4 @@ -package parser +package image import ( "testing" diff --git a/pkg/parser/image/testdata/image/futuristic_tales.cbz b/pkg/parser/image/testdata/image/futuristic_tales.cbz new file mode 100644 index 00000000..530f11e9 --- /dev/null +++ b/pkg/parser/image/testdata/image/futuristic_tales.cbz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1719b4cd61b366358108e086dd4e13fdf5e4f5e169e177fbc281ea54aa6c19a9 +size 719861 diff --git a/pkg/parser/testdata/image/futuristic_tales.jpg b/pkg/parser/image/testdata/image/futuristic_tales.jpg similarity index 100% rename from pkg/parser/testdata/image/futuristic_tales.jpg rename to pkg/parser/image/testdata/image/futuristic_tales.jpg diff --git a/pkg/parser/parser_audio.go b/pkg/parser/parser_audio.go deleted file mode 100644 index 0b4baf6c..00000000 --- a/pkg/parser/parser_audio.go +++ /dev/null @@ -1,113 +0,0 @@ -package parser - -import ( - "context" - "errors" - "path/filepath" - "sort" - "strings" - - "github.com/readium/go-toolkit/pkg/asset" - "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" -) - -// Handles parsing of audiobooks from an unstructured archive format containing audio files, such as ZAB (Zipped Audio Book) or a simple ZIP. -// It can also work for a standalone audio file. -type AudioParser struct{} - -// Parse implements PublicationParser -func (p AudioParser) Parse(ctx context.Context, asset asset.PublicationAsset, fetcher fetcher.Fetcher) (*pub.Builder, error) { - if !p.accepts(ctx, asset, fetcher) { - return nil, nil - } - - links, err := fetcher.Links(ctx) - if err != nil { - return nil, err - } - readingOrder := make(manifest.LinkList, 0, len(links)) - for _, link := range links { - path := link.URL(nil, nil).Path() - - // Filter out all irrelevant files - fext := filepath.Ext(strings.ToLower(path)) - if len(fext) > 1 { - fext = fext[1:] // Remove "." from extension - } - _, contains := allowed_extensions_audio[fext] - if extensions.IsHiddenOrThumbs(path) || !contains { - continue - } - readingOrder = append(readingOrder, link) - } - - if len(readingOrder) == 0 { - return nil, errors.New("no audio file found in the publication") - } - - // Sort in alphabetical order - sort.Slice(readingOrder, func(i, j int) bool { - return readingOrder[i].Href.String() < readingOrder[j].Href.String() - }) - - // Try to figure out the publication's title - title := guessPublicationTitleFromFileStructure(ctx, fetcher) - if title == "" { - title = asset.Name() - } - - manifest := manifest.Manifest{ - Context: manifest.Strings{manifest.WebpubManifestContext}, - Metadata: manifest.Metadata{ - LocalizedTitle: manifest.NewLocalizedStringFromString(title), - ConformsTo: manifest.Profiles{manifest.ProfileAudiobook}, - }, - ReadingOrder: readingOrder, - } - - return pub.NewBuilder(manifest, fetcher, nil), nil // TODO services! -} - -var allowed_extensions_audio_extra = map[string]struct{}{ - "asx": {}, "bio": {}, "m3u": {}, "m3u8": {}, "pla": {}, "pls": {}, - "smil": {}, "txt": {}, "vlc": {}, "wpl": {}, "xspf": {}, "zpl": {}, -} -var allowed_extensions_audio = map[string]struct{}{ - "aac": {}, "aiff": {}, "alac": {}, "flac": {}, "m4a": {}, "m4b": {}, "mp3": {}, - "ogg": {}, "oga": {}, "mogg": {}, "opus": {}, "wav": {}, "webm": {}, -} - -func (p AudioParser) accepts(ctx context.Context, asset asset.PublicationAsset, fetcher fetcher.Fetcher) bool { - if asset.MediaType(ctx).Equal(&mediatype.ZAB) { - return true - } - links, err := fetcher.Links(ctx) - if err != nil { - // TODO log - return false - } - for _, link := range links { - path := link.URL(nil, nil).Path() - - if extensions.IsHiddenOrThumbs(path) { - continue - } - if link.MediaType.IsBitmap() { - continue - } - fext := filepath.Ext(strings.ToLower(path)) - if len(fext) > 1 { - fext = fext[1:] // Remove "." from extension - } - _, contains1 := allowed_extensions_audio[fext] - _, contains2 := allowed_extensions_audio_extra[fext] - if !contains1 && !contains2 { - return false - } - } - return true -} diff --git a/pkg/parser/testdata/image/futuristic_tales.cbz b/pkg/parser/testdata/image/futuristic_tales.cbz deleted file mode 100644 index 48da598b30606fd00796d7952bddf7dc87b2d78f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 719861 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%0DgJximuRRQ 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≶Q|Zyse*l@J-9uFALGV5H5e@GgBH{8j zjc#JLcc0BHJ*`S(*3g^D>N1I4M4@W{xxlWbiyrw#hjWmcpWE!h(RgSiIzEaNFTh92 z;e{QM8n9>JMK=mIFSjgJH#SpUs3*`mK&_CCtTtf1{v|O;e};gk<7c)jm5ln*VM-5@V%{Ia6LB=LW@#;zu&7U{gj#czc_Qzyq-JDU~e!V z+GwrnrqChtNRR@*0*C6RKF5eXzSjKc3g5w7Ja32I##Fw@{#Kf+tY7`syb&b)@Igj` zjNzAd4cGHuU#*D73!xrAbu^pXhP+WY4HPK`m6eimcjshZCz+*1u`@} zC)J}!UegF3QTH9uEZuC`X{y!RNHxvE2P{eT1f!d(%R#d{MQ>NCmE@D7b$=4&Pzy8u8*| z<=+n*k#E!@=(K2AQi}q5{=Vha$m1&gLz?}9H#@OxPalhmarkZfd!)$kX)g`L!i!&v z2E-b*3Q;gm&QrdIKhlx#aeCKrxBSz!VX}cP8XizD&8~WGcYno}#xo{Uqp?}?H@Wls zgAP=}n{4jcFsb^7=zO5fA_a#u+k`~_yysRytt_Cs+2VstPoLqo@0@IRL`atO24@b5 zR_2IYkJ^yTGC_f`%#p{@HxF04!H0tAFohjEC|4-DB3=n_eGb_ZD5x8%u^p)G3@lG> zFK>Q_n{4-n>v+`A6QNX!arO;xI42)Z$Sr^4bemRpnx0FZznkB;2-bkRq}B zd zwng^MjsPsp&iyz!yG?F9rG@e_>m0*aX>I@~b^T~@J(~c-yxwD$DP*+U+xC!Mb=f=u zFyxeZ6U5v(jEcSug5RJU&qK!~BeB zni>rI9t3i`kgT*vEUKlJw{&Jx# zX$NfcpE9q4Mk-7zO)jaU-8ZQx=LNiF)}^T;PuAh_8s1PC{|i&@F@n3V(gvase`LvMUBB|Zv2-f)_g{j68$qbyL@WN8p_OkR9zi&DS0QOZ*nn25( zI_&K6^i;*u*`u=u?3!H&Lp1mWjZm-ZOlFm}hUCZ(i7>*re0Q7gN_Zv%6VR4&)sV6C zbU6?=qWj}Eb^aa#pXsF^n$aQ64~T0XK#;GCFC$yzarIuu#!+x^T#uGea|B5$n#YRvWjo0rvy9?;?^}qU|n%T*Q=&SM9PwdQRL&R`MH19y97;qc^ z#RU=0#J1z<>k_g0iDcc>oZ+4vb)iJ}jl0sSESG!OsR?8FDmMSudvAV9 z{9Ilxy@b1)nBg4$rS zwI^Z9V6xQqJTykFPqi=y!g<*%gYh#65UT=XRH~mJDxAFD$wgZ8642dy_oyvy*&#=n zG;_(Nw6Q-qPsPW>HLo&9q^+(ZwxkM5kGf;Yu|jGKkq9?L&X25x#}*k99v9nnHa*@h z8BE?|Sh^R?nixpFCofQ~(k_#{XpNqds(79rxBKHZ>hE<-_cIo~A!ocSEx+*w}J-j{hC;7GTsibO* z=lA&ICaZ+TjdP`UsMy!$}9V zPx>hq556#ElJBv?z02>$zJ1MhosAuyCGQuta#~AJf*GIQR^6?oXPHx5wrAi;zYIhi zxYuWa$hjTOzWkX==*wQ$q+_Zh=hiZ8I+|Zz@D~JnUGgihPh-XJdnHR${+^!lrZtFM zXOy7il7eShD=8%H!IS<@>E%KNoGE_=bKDVQ^{U;>(1YW?`hDcT8}6n8hum;7u3IF2+c-5rjr|(LoP5?e8e=$~K3lX(3 z4<5#(IhY?rB>kmnDIy!scH!p) zo9Q~gwjJiNr?|kfIt-e$^{k(h4FxNfs5WT$gemh#+Irp*m{Q+2nmlpW*CgJ1HSZ!) z4K;x$8nF0|cJOxZi7C9eH4Z%1^+_Hc`Th^Ev0wXPutb*q!p)CUrdQa_PPe_Ov~96s z0Q&YcEWNN|Cko`iA@H_)O`u>CmtQj}>I!v*mo(S%2sMY5-eJKn?Z9lgEE^Lon1|T1 zAKnQ!l4n1!GK{A@I32&N$@9x|2++wIXLC%dRbD5$vm^81jTFJAaGD5esXnJ@7Fx+R z@h6fjW|QXlws8a;b4w@9Gv1UPaXUWFtgQ}TBBZl9nvGB2c%As%SZL#Usp&7oK-Zhk z7?J8-6q-{NUnyHR1RqN)Sl~2wx-GMwMCay!p9)T~KZtR$_t0IiJGuM2g7z&I(_3B- zK|u<2cCOvwms49Dfm}$-E|jV6g^xf2MnT#->hy_c^Ks~}M?y-Rzfe#K=ZO<@0oR$g z$htdwHb>yQ$?D@`l_yR!;1kul-hpy)fyJEV$FgOvJH;z}0`BneJSl|cUPLD_H>gw~ zAyWMA9{^qnUG{A9p-h>~qpQ!l-ISN=wLvVc;fxk)D(Y$ai(WIp|_NFO&xFK5dPF7h(BgG^?XfrGb+}3?!#mEN^p zuQZMsd6zPoGq%1h%l^f>#u55wp|SDuj%6#CwSJ4G2m4|50_06y#0`aeqQkP%0d9{y zt=#REUmm&)S?IhNeA1Jgj1lS~tYfoZ4eh@uy>1VXAKqo$sc58Z!airdAcfQUlh}I1 ze5f~hDu~_7Omf|pij;u` zIc2s?^rT59dLv~(=q==lWzM1>#{RWfF*k8n%z9i!NwJD@2axi-qkk)DhVf!vJg>e; zsTRaN{YvEiIR{D(t7ROi;{NRP1SeTP`K*csxtsY?&m0;*iA^8UeP6e|B%AtZAmdC<$heFq1Bx$o#j)wp3qH}SLk5~QDKBgcdV2WqH;1Wa3(KC z3TNG;ge!9&8$!NV+2QSmkY8k5WK-@vj@Rp#e=fKR#XU(5e2(OK^NCyf6QTXaCNZkL zc>#vMQ&%{k_++|m?!qGZo1Zed4mZ6V z#Lmj_?QL^djwXy$0VDgEAE)CO!~S%jp|1PtXL#CN*a-@v5=^kcJB9oQKx+Qs|D;i8 z;VQdmmYsNehArJFO<8;AKbfmB@5Mg8EX?LdC{$)jk$k?>FLlZRnql+Hgz>g4(jah6 z@wn>~v!_zKid|sx1>MB$81%G`_~9l@HZHS3MQv99(_j)6x%F4y1;D`XbGR_xb1~-X zz55$n(-Vm@u48uC`I1~!K92)`M<3*@RG;^T-)lyG69?7rbN9%+Onb$+<5u?~KzBou zr3n&#C}x2sa_zTUI0O?d39XZlsLM6uQkY4S)m$Q3hH>1Z6W%^d6JK~3j~tFv9o{{N|kbDzcad;}V4@VC+2pCDTPr8`cQ3*dgvZHF6Xr|B~)g1vbjrjW;zI_Yc7qQAW-o1cBNh^0MpAwA$}12tuDe_zXgbpz#fUj- z`eXClKa+xk&{zoroNOiQ)U#R=m4h0#@rzd)F=B zfzRYVG?H7D>mOjG1{;vh1n}fA8eKwQM}28zHk^>wnIhqg>N4jR)q>X#@fg>&KWZK= zZMK<(4k-=G^0X75H>@cTP<(EsY&Z!Z&z)>oov!WNvO&6)LnM2I;Sc#gz+tI&gNXc7 zth2d5QYO_);4@v8$%{(N_!o7}hsMypB>^W9ex(VbVs~ezUf1e!GXWA+tSZ3yTJ3)h zGe=9xjxWT|M;k_yBznf#56uE52&G9L(Bs zQSJG>&S@<*g-sCZuQh|b>S}ef6HS})V=c-`$kC|3|EsO66yB*KoG)>CclUS(s}-Ft zfV!j)ZT^vu0LFH?-^tM9Hc-v8CPN;ge_FXcPF zesQjIkV z^~^eKnop0r*1WOjjf};nG8jzx@^_zS%mdUAO!xs(xs%7y=hDN+Yi%*T8A(O4_uv4kbTixl@Sp z_8ypLIzO<``-8$iz&o9h=?hOx9a=6jt&u{rLvQ8U;3#fvtl}2|#oBjA_XUr3)Yy`@ zz`+41*Qet)bMn~X7gXm+2%hpYx4_@@H4x9xF0v>n1EJ_w=Gq=>@Ui(J!PkP**B8~v z#wCD17D%l#cbBZmUq1Y^Y9uZjxb@Q_Hu^x3S~c3=cEHvZCfcEd4}m|+mC2nu9s|xF zyteIMyhvRN+P57@f^v0#JfIL@vVsw@xwB$A>OU&Qr(Iy3`bC|m?1jrA3$nC z0IpB%oee)rU0EucUDSn}CMKJcj=guyW_Hzneeq>bdb>p8TbnsE^BMg%F7MMDY&N;$ z{36s$>0-h6#i{C4uE?=mB$`xW$sMZ!)(sVaFJTT4q=`|U}b8&1K zuSQsD76aQ2Fy)K^HG5bV)Uk}pmarq>HyAmBte5I4>$O{AGwbG&jXKXrNZdH7n`j3Y zl!^F3_>y&4?rH`aBlBG z$MTl}OGBUYjlT*x<9KURCt)GMlAYWg`} z_MuHg06QP*@zdb(wo}$nP`LPBFf{mti%yXOiWC$bWM9&lHQuN9C2P`bexYrgg05~O z7UZP#{lnkX?z}I~e7BWY2W}=#EP-q3Nbi_&n)sh)cDHbdq^2d=C+~wtw`^b#$UlHH z#ecc_8pRkD+;(de7R+S>C=)WL`hQFKdNe8fh-G!00VzDR>wX zq4b(7@$O)UB-`QS2p*Bf?3XXNY$wN;iw}P_K5q2ZOadR2N~WMUY=XCEQq0-a9tG2e zruN%UY9YOyA8Ia1s_Ao`02Vj*JmB|*Tbky?4H#eO=A;6wn1J9=%)^{~$bJYxXVImc zvE4jJ_5#>lD~hUiPE2nOrg&buILGt(0LAWA8?Inj7uu=W&-TYrOGhmtVo>a(UE?e) zz0GL|*t#IwVh$GgRcxvfFhx5?pXe=_l!6W^Yz3e)mrZ6XT5QeiwA&TxwdEJmKcY8^ zr)4W}ulVd2JQn%~b%U8CXbzUN{s9s>>}1i!z~uPNwV~1l0$HQ6_%^l)yK?WcLvX9- zF8tH4#x(kt-}4J>4+xC4Q4=xh(K2z{))^+M1qa$if8i58^ybsXaX3>0m`4ZC!R>~z zYTA@ouH6)4a=&rQOQ`HL8$KbPziTQ}9r-!I&dK}-g7vAGjk@YbGrHKc9DXj|Z(bH4 zey-ScE~Z#uvjm{EIRkRvdP}czxU^_pmWJ3qY27GJO@3@+hHT4q2wSBcO{o-it2weI zrFfL+9Dc_`E+)Hg`zh=>-l$=PD{zBz2D0cPsFsMS%pEv~erX~4jad7jDPE;pO0Smd z84p#@i@7C?(rr{TW6V>`qFKE*I{T9$ED|8j?O*$Hv(Nm|{c+&unKpk(qs~!*{q1#_ zsH<6p(i^DjuUuPox(sS@hPvR`k}6&7GiR;virrZS?wQ?85L&F~l&A$09pA4d_mfYxD zbZ^6_vU08+^0_ETaVwhq<-pK})Ep+?czJcCy?py-b;Yo4eLXlKTrJ0?;cSkVAFK~E z*zJ`y=ipqa?+wnv{W6Y41vkC_q8p29imA8w(MeTs(8r{yZxGMR#VakXf5IVgfHd|> z?=8TW9!`Iukk5hI;P6w;Q0o^WXY{G(^<0TnMpMjqh7B!czNVh!u8CAG^Rl#bWhZFty0y@HPbW-ohPj>Lj~CTnUm!tQ8flP`CR}If>Ox<2}(;^A)b< zM-M_9pRCK}FFuH*UoyKQSJ-QMn@ppu^PoVOD*DPw%$!oo%RubfOfSMxKnoc!0MV3#D;@#J2GI-CEqGP-rfa~192hOL633S9zsWB^)Br4 zjIe!@q$1pQu#}f=zz{yOS@VqT68F|QEo^rf2mcK-mU_^G7<#Zml!*lCC|trj+7(Pc|5p@QGiX(I8_3{9UiwFK)DQ^yj@ z62b0m=Q#p#0)rY-j~l5e*V@)oMoh=LR_~{g4u*Vvwr)}Gp&-utjzxk}su(cK6uLBUnto#U4gF8NsqvJIBW zmY|~8_;((?$R-9fA^CZ}CZ+FM9lS#>)LhU`j%AWiLvC`9;L%2Y4UD1VtT@BRKDp}? z^7B&gF`wu5VD^#{ToXK^A^vln+zJ9Sm>|Wnxe_tPLw}S%A*pWXkA-I8>#R8PB zduw~$4$y!$N*c33c^!|V&5VRd0yTPs`}hC7@rG`Zufwm=cgOc&)1txuk@Ufr@iym+ zc;f%w@Eiwj?qZ6;YP$L#BED{noidEm(TRSkde#dpplC?;xMQ(Q48v*a-#6KF=4GC% zE~{)ynQj`$>|s_W#(+(j`(!xdggN+;Ja1d1SatV{#+G`idcp)|aCrheT%18Cr2)yZ zwbxVta{fNIBugqe#PnhFcK&H`1dSJlyH-wg-;&@AjREryKmeX>esEsxnvJiXB+=X5@=ozgyzHN9{g{SrgPsLv-N3p2y5acy zNlVNuD{PA%kZ&hU8Ft#J%)$p0#KVdWX zn$9bHaOq%3=RjOoE9p>2Jq{BVfABPPvy=I}>d?qPV(+a}qqg;$$?LWI6ck`DOz7%* zT|!INal$-Ou-WJRGiV^$QL+lD@0PzLjn%ud#tbLwJVj>%Nk8iFN7~+3K&;6^j{mjC z>50T#W3J=3d#1y1mfs#Ra}xfpsHOV}C|kKiIfmQ-4!?fIXL=RnhC^OWt?}jH% zvMty*+@(Q1YvLYI$?Ua?F{VYce=+4~h0;)!=aq6tRn{Xf=1&HGPt2ZM4%GE%OCS7x z_q2SW;*2yCLhP~Oq;K86vt9UM*LR*Mp`|Gv`mxpdMaTohv+S>J!7tlTx!F1XB`~Nv*b&_m&D}^Jmf$68h)SSLB$cQfQr$InrR90ArOMs0 zDnC9Mi!Ld7nLCKn6nhzQ#u0m#xbFlVuX#VV!Cw+}2pq&XCCRWPAAk%k1*;cP{4g5Q z{EN#{&zGfMrMW?nUp(0PJ{)MD7ybaSGePz)kntH6+o>_kI>5k7ZGiNX zoGSmX>+f2jP0BH6w!)&3w5bma&5%$MEq+dC9*&IBaIiaSQ9dv8vtSr3eJv+3tduSh zCYftNkcpKs<1PQbEOTUtdG<|fbo`h4cW+Cxp)#=h>xu3jFLHL3_wp#Mg^K9*Rz|z8 zJL`wQQb}qyGLOY&}=)-}_JVl_`*r{sr<*2nWTs z1i{R)th6XlETn0qmguehB4WK`@joTc0GfU}2u~74lk;iq8aCyj>%~hFEtI%?1~}vy zI`>2j4dztdzDlu(!U;M(d2)s1ZbYEJ(jRS<76n9->BIcv3gy+{0<5(YBO-kY!+7uS z7r0kwFl&q-^Ho#ng7J5D--U*$rss-o^3R(70k#p6n0lcZ;oXRlRiptS1ZGHQ_;WCt zS&r{1h?w%mP{7a(*o*^eLRZDf?^8uqOrT?Cs2QW*t&Dez&*p>aXPULLE)@pbLN2PW)H0Zf4#TB0a>< zhpc^vte2yRfbVV%ZN9u_aPtC?aJvWrIl}D!0b~qcXR)wLUHx!#iX)o+TN?OW(hKPO zAeFJ|2F9%`L!myd8pc;DkX);R>QXA@_|)+I(+8`}rP~x@2i*9_Ow4nZ{fC-VY6MU= zIHjOd<<8rSn9(ESctIQUxgLRSgvQ@ht=`*2WsXPNxh`r|d`Wb#iOW~Bijni5vc>eJBH}@e59)ZI(q7ZzacbXi47E4+ahOtq*2tqL_&`80TqnY5} zy*E`{8h^%AMCN?g3;ZWT9=SR*mVAWGetK4zjfGX=;lBdt}Iz-Dp zHhhE7OV%}}y}f^qzgKWyi@!00?h)?*OfaIEX^vESRNj%1&HCG_4e#;C7~Q=xDO>cM zys^VH!qVQMp@8>n=0BA^-s0e3RXafpU)3G{MYM04}0unHJA1t~!lnB@!``l7yU)H{AEEp9mvDw6C<@GM-5 z!>8Qh9>!X?GJUd1{d!2JUUwd%K5jo=Yyy7AgNlX0OJ=_23l;3^XkMA`UcrFoO z*z}&6)pvIzu#j5I&|%x)OUI5nFgL^urc9cTMYI7uj9%KYgrhb;0Wm4;f65fryV&k% zcQp3OMJim0!!|Cfz&|+o9nvMma0f$*mRQ3T61`f2)4zLzQF^1yMGv;<<(jb%a}1?-`<#9P9JTC( z@1^Robs(0CTh@nCz{p-yFK-bb`zz_1K(s2CKijmJCdV&`w75ve6~BiA+(ZR)lwCw% znTnsR1uVFGq-cv~EY)C7w>*Y5g?h={MCet6+wr$tmuG)(3pPY#vTR}|Po9|;GAm%* zI!p8%Nm-eI!7+PtNfUiZ?+A0=8!8awlR3(tO~u+8+i~>;bm#J0HP3KxC$9X*n9AI0 z^oGIz%b{-eK%TJ=-uKsjZ5$>f{6-sp)`gyd#<`^r-<-KYx&f5ye`2HyadHO#cWd(V zQ5S__9B8+w(?8(;Q&Nm&J~oR8du#4pz)EGLnH-p zn%2{X_;9kMMSdppisYtP$ZJcQA#Ba?c-w~lJ$Q0i>`gXTVDwodk6qMKbV(@oDaeQU z&M*T{GQqC*%>$(gTVL^j9+4B}yl3bN7fkSB>T@mO?OF6!J79cPuXIIRS#&xUy3a za~^dAJsD&@fnrCajRAuWk$T)@rq6Y~vd%Czk;g}};!FKue*`Oc4>rXi^AE6Tk<95> z)5wP6L)I8#IfA)tb6YSf@$>{6DMt24{|dDj&#C4Pt3c-Tn!pXNGul(*ggg$fvbmIU zef7tCS}FtV-<*M#$F(3Z)X*0PM;?^#g(j>rZ0SZ~yn0)Uf$*-;Z|vldf&DOEjmzAY za?v#zqTIAQ56cu#%byZUivKzcJm>;EpU@g}5i=)hrazS8?kDIvVb|zBaoU=nl`l5O zC%u$()S@^w_};XvgKvXQj7rh`$b)U7-#Q%KU-#`$J89qu(-nE45(V^on0d@M(UK`+ zIG0gtWt3Pv)#FET&RO7e`rVtGRziyYeKpnz_O86_*g4k`{d$2%ntk;bLZRlIwC9~b zd}bzyZ^Su&dQO{fl*df~KDI5d$@RAYY%X80>SGaR{_}XcTWvFs&lSw>oxtWtF+1Hq zaVXe;7NVO@4j2to%9l5oB#8eiUDoVssn)ci^%Y{r2lqLE{a9VMSUB39uHW6(Qn#L2 zRuuyIPgel=M`J&(EU|eF1PXE2)>iA@?oMN&U!NB*#!P+wdcq5Y+Fg=9*u4`?DAmQv zegE-v?!vs*gxgq2wZ|s$=WVBgJ!!6z6JUs3yI0MRWQuBl;LCbE+4)vl)adsrcj%=; zi~H7){;GHrW%+ern5*$H7fPqv+``Eqd%~uLOcOc@88qt9yFNV`R>$OIlQ9U6KT_Xl znyT^$=7-Wx+Lf~!Vm>6BUI!_k6hcFOdP(8G?~i*AC{?P9CC+7=Sx`vn!4^npf`_4p z@?9+2O26OAvopQa>G<%Y@}d>ss}MJRS=<5h}KF+2tOn zt@3id$wDlqHzqie`qL!z&)E+O{ki(*zsOmWYh*lT zh&f_77^ED3A@(v=hjD%V3+M_4&>HFE0`_8c!Uu^zD8K)qaY8_e*0IKy4m7 zI^u|G4-F@&G`qgI&y%>w9B1-3&Q4sh)!FgD*brZ7z5ddM{N{GQnZ`iPH3Nc=`EJb& z=yfLm6uxxusyuG#*usBOa!PX5g{KAGU}h!Thsa*=JCJu$b!u%GLTcG952!)O7iP9! zW7cI6%Z8jBdR(lNTKQCt6>Psy%?35i{7}(%}0h|;kAt69Kj}>)uhLzn|=3B zXI}&b6?2$%z%`gD`sOgOP{-!IdrRK_=tc@Mo7M%IOx<`9nTdPFUdnKD2zM}7%{Zi= z+=b{WIX?wf($O+R^*QOqt7{6+30MJpr_Xa%ieFELN+CNIASaC_4;|i;tTa8bnxuFbG$M< z-PKW&x0z7|sD)$#r?jnV^dNP^MW9}h=;ArY)RDC!02!FGaQ}RJI!`YCO_x?QG>Slj zS)Q~~mYc8}_1Xl(Vl(`E2U$;-ljW+Y5R$QT?OE2ew}BDl{s;IYHhE9kZJ5|ze%UiI z%b$inW7iV4cv`R2BtFNX$79W;6?8)pyxSl+(z4&^r`9mf43$j!w!MB*S#3;4-S09d zv^22Dx%w~>`=NaGbl;?G-k|iNPwIVPp|sFu3&T`un&6|NGX8!?r(>JyUXZ+kdWmTL zU1h6wcb9Whdh`o*FyK_l$^rvJzE|%2Pk(+ zP>k&`9xD5NuIFkS;wP!~Kk>t-ijim$1=kZ+``TprX0BR{8ScH7V|{#9J2HiXXfAlH zxjP#PnKCgm4O~>&Td5YlZW`F#Gsf&OorVF`<_M_&z@rxD{sWK)4w+Y- zzT}275BiQ$;GsGjKB3DnvWxxA%!8vV+LWW4wUnPLvJo(bU<=}3k}I23d3JJqrycC? zZ>KuwlslA&2}MVou|0`~?{t}qSX@Y-g(z*%5c2N^hZG&LGTzf*^BvQ2v>LCRZ_|Y` z)R0ix5CLxz2+Z=N3Sho^yRq-~+?CT99}q^#`sYG*mgVodsII3&iD zI`te#UTx<~FcXjnnSZnms#wYTFzxJnROC*PoPw8|rq3z`Lp0|+juBN{$Iz}mQVg_% zU9fG9j-G7AFJ|XOR|TPxT5EYnoXQyKMNtJg|Q5Gfrc6Fs7 zA++m+T3Q!t%=I}*_HgwhZ>G@*JlWj3EuYKAzlAs<`ahDDu-NKvS^(;%wjcMSU{cj$ zPIhy5hYz3f*Y%pUrVmmCEc5dFFcEPuvAuI5w<;_#=XaaFv6pE(7bFE?0W3e=r1q6T z4C8N50fsY&jf}+_JpSZ^n*DG9K|sF0O|GKtQm#x$_`PMVXykpUQFc7lfZmzjX$7wi zR+zUhW`M{kOu z{-T=x;zgC@rd&Zv7o#s_WM5CDFbIA6?m1{SDJc_S)CR~Q3}V|}q2jjF)Nn%AZIoBc zfW4gyAMwG6A@P+1KwG~D?=1{N?&S7CB}*?~_zKUjU@hkz6mz+Kfw@G-ZZts`*n>H> zPu+ZchN+1C;IEgC{mAYg{OoRi`=bo)6EF6(Y)wTLGiaN@#=g74E)cv*)idp6os9|B z`Z&6P2Svb2YjQ3IHbQ@+!UC6ueL3-^s_38HF-IA^tZ2ykhYpWn)r;P$pXJ@;%>(fD zBUZ!UdaQsPVPOdA&^F?I-W$U$Q{g_9weFj!+6##C-Hweu6hf)-t;!v*e#x#PSE80>9Byy6ElcZ^nOe zGM!_6k7t=Gt#A_j%}hmyP9#JfB`chsUmLube`o2i(`3H*hHA=j=yTR8b*UFv@ARBB zm($D=v?V&;*Q0V#T4iDB&hIT*?<#bDluJ9Bi$QJ262Y$SQ|DQCMI&xpU1XmVA8+{D z$p;G1dJf-e#b`<5l(6ipOUYS4wuhq;zLoqahr*8*$(Eq9hg{F^?eo>%KKzWkzbOpr z6i^`cWyw(2qr?htw&!yR*%DXFx%q#%p3-?ThR!eQM1vF;AH762pk?z!z+q?yQF=>qBk@%h(AY#uF~&^3WKrnxB`t_M#<7q~4-P;V3Ed{2SCDQCs7 ziM#wybfLX98FYzeFe4^Kc$7rQ!9xo+Q`i`4DX;B*ahwn6V}HU!y-cEItM zwsEc#-xnXco7wg!Hq1wV$&UVQyFG&+$*#v>7UkXD4o&RrWykaW4`b&U&W7W?{UC~{ zSrj#k5-BZ86RS3%g4nYZwY9N#ZK_7>Qd>e&v-WI_5~HlczqjYS zKlkT8*SW8A@q4`YhY|yHxWR+SXnV;Q=jh1H%Y8!&4p}mi?osRJvtTps3ua zMxdlXl}A-0d1et>`3#@`)>QKd?eq{g`lo#Lx;P9=@#P9FDvqnOz5J>eIg!Urd|c>r zmZrQNP*pr%?Nv2#Xz*NdU&|JJnU)yaz4H&CC@;#zl5|p29{H^hRrqR5+{H36u5G%Auby0eT>Ay4)@n<@2^JjnKMhH*Zw&lpJl51b#gq_;Z@Co;9dV0*gv=Y4@n zc*Z-1`~1W z-Zl?ZFJ&G7j;=?EZ`ZE>KzFXm8<87jVz$~%)-)r8%ULz7XReKd`3b9wzY&s1A`M$*u6SS3rl)1ghqjWg6g}b z(}h4I+~44N&n%~JX@fb-M94&JAFMHGY!(wMSkKW|kzZ5G6!nLnboL>gq^amM6bBDV zzmwJa##L|sbWoboFQ-})xSnu78HWz-B=cOLxRCl+ zF)M&qL0pyB!t7nabs-7CTBAU1b0M?8Z#eK@5tF4{G$sV5O-pZq!zX9e(|$?zVyswZ zm45)$_B)^cW}1S3vF-0DOvO^0(B|!o^G0|E#;5iKH;$P48C9<1%4^VF$1K;cQr*V0 zejIPF6FUxen>?5|=ssgAe?*{VE!-_DiIkmjc;?Ig(QX^2%0J zri}sP_b4S4-Z=a4U~SkhMD_7y1%=<|p39b)Y$N*6-kHKtzv|5%;3{{OHOe$kE6J5X zf?Ekg`NspoyuK*OT@z-^Rk9oRiUG_-*X!7Yo888edQ>3sYF5QGA}U!k=CP*lo%HVR zZEuowQ6j4+F+?W5(kc)xim8LXrLTx%i? zc|A@6NPk}*qut@1Bk%O6Non?kt@dzDEL2LEOd)`S-^MqmZ@tc;3Ta8hb(Zj-FK$Xt zNV-O$>=VgIxxJ^SuQF4QalPakZveO}Ta&?8jFLgQ&()@6MnFFW`rCkCBIjsFIj*L? zx{G(}GYge1XIVdKKYJBj@Mf@ja;9$f2 zMv}oUDrGV$QBha31y7ez9mEWB=K73t#fIDoax#uf4XP@r;=(f*u1cpBH^iz?T_n<* zWSGVYHA;&n5nuzwqS{lb^^|tUvM)<^t(zQJc0E`dZUVCA0(>4hrv6u2k z+?YrS!QO#Gd0vEPHdP89bdgZkr(ll?ixx2T7Vs)?My|M1t+j<>b|SnxYX+6MB79Pn z=ab=1wItdN+j>+mg~JGXj0EyXqf;AVDpGEy@FuC+*rnr}C5}8400LOluG{x@u}8m~ z*vN_Q{CRoTe{P+eRZUgiB$w3iqBL2;FR|@!in;iEnQ@Z}C!ZLF#O;+67k*6NF4nG} zWMZ|QceZ=kx)L4qa|*c4vb0p6KU|%c(-g8+p*nwUF1>>l;!Y)07~aXazd6y@qYCEGLEvdd zOXC0TB8g&dsOOvO{oWV1xoJ!k`Ovz4Zt(@Z6^9?=f7NAac5C|o1NcfsL>*lh`QEF} zGSN}ra0u2-=4xhKs=lVXs40CZ^c2TSHVXU=X(JT*Qub`#t>%v((%5=t!V+Hilt&K> zE^Y9>Yz9_DCh7!i2?igg2&Rzjx-K>jHdvUsvbac;%FNNIs1^${Jk7gBN;h|%oov$3 z$is4#>Pj(n3cIVZ-Q|~vSR}ngAa_4&;k*hjA}B^MOiUo(@zw{jWm9E88YbiJq~x;E zGYo94TBx^lR8bQ%p0jbbF&gL}8Kc0zX|IVm z%(7&%?^l*qvlr=)@*H&}=ii;f?o9YcW1aZ6(!;gRmfAO-{%RZbLXWGR(@3xp(R&|r zAtPG-n`iAQkJ3}f^iI~b8{6XRRLmCrnilp%lebDeQ!_;VMiP1&!id`$`)`4L3+18y zMZ-h${!`^J-U-dop$6!NL+@>o&MT2?Y?(Xscf;blWbVYF#NhsnAYerw)!1L@2NSo% zB_5r-#mqMryUXGvo6A8KH)yp4p1bj2Rn?D zDy+ZgBuQf%;_~HqRE|TZ16TzMI+$sASVkQ?t?m=6nLU|#EI7>*w}Pq`$Oj0D%1XR3 z>gV4M{NkqKI3M%`*!@uMSK7uDQZi5&#yzU%OcH6m`+GmW;wAO43+z9gC zBuyHWQxO3nuXR%0IvjLl7khm;vFBjnONKM z%#lzb>V;osP+`S@%?9P4dB4zOe@h=(KNh?8p{Z)?pPyjq;T#kvmxy8dCHS}0u!T+G z@%&kQ>+%+D(h}f<{EHjpdOP;jp~k1x!Trrm^9N5Ye0m*!N_4xAqUVGJzLd(R7Zi{e zOK``C4CDv3DV7h#ZALlHS}VGa0s_6iJ$?X_YJ_=r`#zgDCg~})*p${T#saeToHeKQ zM0H;CC`nuF-7V()&LN_cj%mX`fUpa>n#hFQkU+{&p04^%NR~FUz7&_dvF;QSzz~Gj2{;=d?qihgA=2_#r{k}#s4{9yT zD!iu%X;~kv?G=iU(q+0g^z!31#@Q1uVzp@*?9x4_G6+A1^7;zMZb)u!5D!px?&9*V zU${^~m2K|O08@ZRPm;VXZZ*S~SF6-Ss_#^#7=x8UuajgY7co1HK;KT=PBY>D!hMO; zhKKL8^^QF|b~To1))Uu%GNt#aG4UA1i1iGQu-=bpLF#pQPyM|3W|jNLn={=h9?2rc z_~(-n)su$o|%; zWqSazRE>K3Hf`Z!ItR_jRK*=@^PSoPz^)5HV|O_Cn7wfbh1$SUD(oIJ*RPJobxp=O z-v-J(PcJLFk2jgyVQ52Ahl=4?1$ppfn#rA_0w7|({NCMv03NxI;;75I26R__7|d?$pSv^b=(K zXg93 z1N&)9eKN>P{r!7~hD6mjKM_d1<5Nja__=1vmx)R@onBXdt>`_cNFphz<)^p77;#|8 z(wrhOIz3@{zoR#Q;Xn*ak~4XT+Dh>1mD)p{@)YP69D_>uWd^q@gr2?g`NdmTqe|$> z!8}}d%IY0BTvYB!bCHNFVi-*@{OsR;SSMp&%t7e7V0GkTNgrABO*N~G$i36QjRUZt z@rKx{v(UkAY-wqDoR?ol_ zR304_a$aPB49>xIj`41m-e$KD))<+jjqqz;^x#aUF=P%+YGyORoN`fbP=eS^^<~ct zWNB_a9TiUiOkEU-6E40`-5nh#w zh7gmFRO()&0!{)@aubQCLAO?|o#ixVdgA1-rnzVaF27?zpshmD@#b7O__Ag5{g6STngDlPGTRU5fKm zi-Ee8HQhtqogH^~TwiXl48quDtmki#R+cVdnV_@IWDq~|b3SH_gG&C8ob)zPE zA+^p3(upEYiWEgBkJfRR$3vX}o*QUC+3>|%8{asisK``I26-;Cq^wqpqLXQazxb3R z4&WPPauK7CQsD%?LV;hqyUG!ry38d;j|J&V8u?nHSGfXGZXtqIB50uSbIjC$vVn@S zXtyvHQO9{F(X^HvDHWB+A6ep-zeC%DWoo^5*&rW^CW1h3yj%a3A3IR%zzx=d9QvW_Dv^XS?4 z`%$xOmZ~nI2BX=+$KMQ013$DZu8-*ReOfi+QSAzytFUZziKWjmqb?G>|De;5vTWRMQr0?_KTH=L|Lr!nPK<74#U2Fcz$>oq_4Kns(#aWgw~`%;+D0 z$U5;sz;k-z^^)ow6 z9lEt;mA$}96mSV!5*m(I{yiyPP*l3WpQIs-l4(~beXaknVT@p`LB<-C`uUT$Qu zh@Q|G6h+mHV4Nb$PGV9dMfH=nEiDV315MxFdn{CzFB&GJI0+dEI8kXmJ;kxmxnO;R zwITwNZQ=U8j?)12>vvo8ig8ujfxvJuBj2-XA2$vn*EeMGy>u=Y4 zn#B=W^|l}P@?7{f$MHRjTR)9&TJNXaCxb!tP$M)xuk@9#RS7tj-XPn)21*3vvx|TF z;h=>!Mnf<6w@K>Cr&6xXJTa-cDKUlMNg*cj!8@KZgS<%1ST4N?0$KO!MN5^~?IsU9 zH6BhA;-(>Kdhfu}vc2S$Pa0RAkA zuSOCo%iGsqHFB})07#WCE;tfxp>pA*c5!)E{HBGN& zELO3J`G0_x9_`+iB;AMu?(5mHBVoq!=YvX^m^A(Eb$cI#F$(qKF9re zG+|A@T!_D^M^976(TwMPj7lYO6iJjR5&PE14_QVY(>sl&k0^jiCZA>Z=fc0hJaLM@ zr^TLTWFDDJ+4^>Aaa`breE#gUi~i053qXk$e7P2hsMdht_RoR@^Y8064Pw=*C&UE& zEpAa2IKLfDm;On=Q8m5uxXFfC#}bcsMZO_g z>IIe1oB@D;r7gSyIbLN^C-#%-IxA(d1uFHCfaCRm!Uv$ussfLGtGQhrSFQ}4ZNyoX zoB21;Q7v)Zrn=Jdt{CRiuSkPsh_fIX@Y^l2F1=AHg(CNjV^#RMo;`Q2Yo~&5j-rYf zdTRMx=65ajf!(Dl-knj24??LH>Y3@`GLR))UZgH$RNe-SeIpwh4)4@VEILa zXf^Ct}eLgYC_X18tPxo zcMQEnrHakoiSUYM8Qz!A8dOHYl$VAORQZ8C#VQpw%ehl8%Y`uB2MAVD{enl6-Bi8! zFAu`|I8niM!Hx;)%iu;q@*;A*Ht#VfX`4MduvhAQf6|DFQ{KL!Z-fFOH>UYpt+mSWFqKvBg;-O%e$25_nlCO@{_I zd+NL;%;+InHgg>!vloFZsVhMVsCrM2aJD?G>nM3Ax4TP#lKeR1U5F4$l_9F)>i`vO~% z7u9f+$IIn2QDxzhM(%TdBCmj1=x=%b4uK#^8g4p02M(UJUlx?+qwfTn!G7z-=5T&< z68SrOXdENlA`rSt&hp3lj7$H;4<%Oh`MlAlsMx~aYy1pPVr+=%4e9utl& z7lN*ErZ25hTCxYb#(!^B5~kvTVvJo~LpvYHtQy9{*6kwDU8o^M0D-6E#74R9GJXVBLgXnW{>QtN)2$`b1i2elJ~lbODVb%L-a)5V+No zCeqV{wK?hu=P>1ceySzfwPK=>k;h7rer4D;uVUPSaT=+O#^B+j;N6EBqU<+cNRFcm zLK$M*r;Hg>SCsb*h6XV4$O14V#7Bt|!f%#%4Zqn+cAW^s* zRnMem-UCmO{<1u;0@Eb3;wn`!Pz2*)$vywu(bN%R*$9Mu4U2i77g|B_3%++{ zD0E}_3uLa7G-x!mf3j)&$@oE=1&t+K;K_SEE>f{YN}K#ApERH8x|!bl*0w3Ge4a;( zBHGjIam;?dGek!@lj5^)bIi#2AJF=K;cxzN`|f>#GB#QA%8Ei+?+#+sSqTrPpB#Qb zzh~^@)<3|i*&!F{7S<6(GknX;@!88s3ynZ8*}IBgFbpFydmm_(&M2S)Q%I9ea{=7} zllN0MKR9Uond*@06p=4qlbGzU51O1PaRm~^lEGUn-y4)(wZ^FR)M6?NKPH}RomE{M ze{88z0PUZ+7}^6H!5)#!wIQ0-ugxouE!H!Z35bSL&d|PCIH`;nqtV`%M{?Hx5?rAT zI;t`b*9q_^^Fz5hCz5>UVi>y^L(&k>Z>2KHma`3Wyh~!F8j7cLAN3mrOkKa zHomsp+krCfPi4n(xesb9o%gs{ZU@~NXlgn^^6GeI3fb&+gLjAf9@NUh0C?ydYivmbASGKF-vf?a5Y z)XkS#(llW|eX60qHqYoD3qC4Zm}D^_6{}@Urbid?+?uVi$#lw*Oq=Gv)?p((394Ik ztcqv5T*?#^tDuLk1BGEDlHL(Bm>F)(@_QDCV&?oX)`3K33?7Y!%NZUyq`{?)&CoGA z)ClWBp)vHxcV9EudiqGv^U8z!%lSw%<2K&fZ>jv`_8@Z;<{6V$4D$oLq2j@8`*nlB zw^3{};>beFWGPS8r*==wJ%Q^OCp?U07At-5Y5T0ws;hCNQ(NB#V4CTZw*}DLp-(L z9DHPLrlred0pbd8t>zi2uyrK)#~yq*c$wH^az;tx$U_Q;CSp~~YkD75jSp5FL`vS~ zo)LhO+?$Njm`NVj1fCp@>kr1d59olE_RxJo72J7TroHsfw>lE&scBr0-4LxdixS_e z+1VKu>*BU}yj;?(>-ppBKs(ZE*i#J1lqPQkJKK3WXGE;0;X;syjN?c6zIZPqwSav2`p&9mf6xz&w6p_Q( z`bP{yY=Sw5zQ-7R6hRXs+0yE~s*c)^nxG zww>|Y9v8&+NiX9$?9?FKdLmo1ELEg>>4j?Q&K)Xrqwx2K`Fz-^Lg0fbA8C!+x>!Se zsY@lxcjxUVtJt~XMt_mub~p``$PCyzuC5+EDj4))TyF`+tMNg)A=kp2P|O9_UP3`@ zPy1aX(*ktNJ69@KEn&gXUiQ5-mu5rr7iKT&d0h3=Fxi|U0)mwGy~Y0knWmYsJ}yQR zlU8gc-_>ptU8aGO*D-X=HMsimClsp|-`~Phvbh3jh;Sn54HZu?z3wA(hw}fFO(m-; zJy%<0P*21}GVVqQ1J~rfu$AA#?=4l0Y>QMNWIU+`;qt&Ks_qu2;)_x#Jc4*Gm0}lreAtdEfich$~tR5s|&$_|H-1|w7 zi84jwet2C=v3<=}Ya=0X`&MKmwZih$D?qp4ttn2sq)KsGPlLQ@9X}hL2^(ZWzJbYp zWhD7=DViR&`qc2ytGa%%#ai%#6&6DYJ&LEG;ks!HfU=iua9wl_Zi9^uT*~u2b>2T8 zT~kA2(yEWoPcF*m?(=(1^n^|TxRSNXg54Y*d>#srLo&R<3*Iowp~-59js)D z;5C{twq&@;`FQ+mv`wCmBP=nDTm7l41V#Y=2Ep9;fQ}?wW0C$FlJ9dSU;Od^(p+D) z4Afcq{+APG=`GKB)8w&3zGHIE(LY4D@p~N?3wIo?ix*)l#Whzhn2We8$f3<_zY1}E z3pPs9jtOp8fmSWNbP=HVYg{*>)UO0IeK$BJ4eA1}|1v=;D+GC4MCXOOIWfxL5EjDLVo4W)TEvSLz!#Hxj0etQtvrW@66`O+v9^G+O}wHQdF z9eq)EbVIQ>0tt_VG$S>Ef2Oj(Po^px z7-(s24JF1>Dr*gC$I7nwvZgkWW=GJDG>8o>4gA%i`H4ZlWN1LuOs(SBXkn&CNH6)j zQN8-BDV@fJ^7VW&lm%R;4v;WU)#n5xtrmX5+L`Iu(5zL^&o1eHH#YY<9Xmtks%+^T zj-#bAiYf+S>`ypn?RAZ-(^)NC8695NwR`m26sB|TPm0Z9`7Nzb6LI9qgGkg5N!bE< z(V3Kcv>q~x{)`sdDvWpTgad2=nmnvbE#@`ZVvqKnyJKf;oLZ9`lis>CQhH&cE>hyM zDB=6eo^{W0)*dQzYsTRj@@z2-k5-Nq?{-=y6l|YeKLGr?k_I{zbwovQGV3KDcME+h`Zs zZy+{kN9%1D+K|j5wT?O|b1TTct!dF_otq<8PRhQ`2?+uODUQIH?Sq-Px zENOp~hIU#sEahBFFS%PTJ76wd=9<|;55Am5M0H(V;5J*{yqv6Pv?{e+Y8782gyC;3 z8VvGrw;*5yMW4+Dzj%6~fnT@ut)IC?S9`%1)@4osJjO|;-qS%@#0tyY2lI30l-N&o zF%6n3QsJV8i*<y@l)lN(|wr2>m#&9`(r^@iTG7_ls5b&?H611uoubT}bc)dH2Wz&NV5-50hIaA9n zSY@l-hZ6|37I(7{CmGq+9Nq#1YBDGR=~{=IB&0GoMo&%-+Jp70*-lArFP@sFrUh8K z!LQbNV^piM?g5_-1!-uP^r4NO3cuG+uA`@+Ry=bO_z*_YZ*YC<;Bu%izg8_8K)!d( z+->yu)>@@VNmG$s@^s5PvlEy4zhpj zY550;nXQPK&gctuQDw3}1abEu{IRMrbW14GEGx%sqPDACtnT$f>EUG#y^yH#HB4() zbbFf3?6IX=`~>))`zarSyc7djw`fkV&fI<&=!7GGrqfwc%BbY$hasrc z6P9ex#$8R7q{VVNN%jpjlo#34?Dyib48g)|B0(m;;EMG3lHLQ4`Gb zI5$V-=Tfjgr)z;Uf)^BoURIO39}wWws}Wr>%3HpnpX4!_jpUqc$MU4-+Tn`tGj4zj z4Q(8)$`$9mnWCF*RH%4PGlx=gx+qq_M;fv%7XJFXlp?pM3Y|R6w}h7sE;vM3>0N3` z@y?-*z|eJ26Roy?VG{8{)R%ojHOqsBFxXOvs%)1r)p1H)z}dDu$WiEe0twW){ehU8idOP2@Jj~mBG ztZWFC{PPd%Utpi_w!P&?VWh<6@+VJb*oYZ0?es|j(o7}r``umWaQowB%j$U`wexj zri?UEI9q1DAF=*Ww5CH=+E3_lMa;-I?3}XqT-H}xOj{NRy~(IBS@^*#%%`uarulIL zP{WbHgk79nij7saw5Rfumm=_JwEOl~tC|KJ62(#F$~K%5X+@R-#q<;_nyQwBi5c_e z_*pH}U~fA*=x2MiB8<$AUK^Qkk>D0jM)EL&LD_9^B`b@HTEEsPeL3#|sKxVH;Q_d@ zjm@my(mHLcBqN>hmC5*NyU%;w6DDz`hZy<%p#h|}KSKc1lyOZN+m{93YyBYu9e)t?7N1R~33oNWkqlisA%e!F`pHgKwz!U6RV8kV zSSVR}(sc}@U-ZHa;~*A{+HgYnoqqt(kp-Q!_edPGeej-M^(cW)Vxc3M<-d!P;0TOC@&*f58(>0 z@XRgN@*I?u8{_mSnscdrEeoK?WT>R!met=#w2v~v_rURY58EuyQ(S(9GBmAOndxn= zOCNT0p9=J@1e|_wYrJ!#tnq6K=W(IC61hA_VmZw#)==X>Na{?BJ8mi$zI_RO5PTmi znECJ%c^ns5Q6b29xJ>$6y4gc_)9u0L#S!vS#fO6eJq;VSCI3S!MnQ9kLsou`=RQ|_$y8$Qf4~V-uHurgB~*a z8m3Bn*u5c25vCCMVb)NiAg%zl&<+Bo-FYsX_W2?KNwthJ#A$~;im_yg7Nfb$5nRa{ zHP}T4T;mH3EbX+AQ+wNB74jww*J-E`rTXL6y1MC)W|b=iTc#BWDu9W5|@*p?Js>e5CD^BNV2anNZ0#yclIaZ zS46#7ZQ2eMfs5>NVnZ6ZAV!&EyW6zDl6=s(A*wVK(e>!`w?jRGd`qT!G_%&c#GKxxFDmPhogkv`Hp*COacpi^n(;YbAeoSJ2ggvXwd; zaT=9a$QM|4k6%=JR0O20UwtEx7$Vx|2Q@NmAvYe8Fqj`VK6oGmAJUfe`w8k4Jz}T2 zm272mDmm&09hnX^bdSxr=oChg32ss3P40ahY{WNY%ajlNZ9|M@Y9+KJOSgKI(~MH_ zeDV#La)m)6(xh8ylWFbL@BkrUEOh?)fJ0HZ37SS2)-Mmiw}`%hxH6|@CI<|TX3D9h zEEdzM$dp5kSw%M-+9GE9XU&0p9t10I%i^qg>Ro>b*Q}af4`Qll-S=J_a}t5}PQ=-9 zwNIH9GcoOmnxCC@B6gw_P2K`XFmAXGmobbC$~C^9feWV^N(JAbb9b@u6hBs)Crwr< z7>2e&)7CytE4XxBe0x!Oc6W!8#lQ&gzrKx*3A}6)W?uf(!i-78rlzlEO;D}5jTvwL zUC?^w?9Y~!f#<)Equ5$1FH91s{LOmF;o83Lf=EPH)5s@ud-E#_a@k7{qOlI^X&$|5#Its@`1_cRWP^!6+s$ZguQwG&HJ$DN{?bI*Sa{83IQwSk5KZ5ba|Uu_=`m4 zkrgJn7idVF@cCB#{n&l{Uz4&y+b@~&UegGG=UzKQhrFA*Fr|XXfbHgeh>HO3QCb)6 z70SArX<)^^Kb0ED%H^%0N=_zBsfs^2>2obxT26b&jP}3j%gYPer2!mroqTgBeFJRlZqun+&SoK+a@+ToD7cfXiC>l<*HKM>x81&Y2KRqc^nZ zUIT3e3x_+7cRIJ}TB-GJUum_ea`H)&Ok;YtMd#FC;U{FqRCZZ*62q1A;P32pcMk+& z@T!F^VQq_S<^IM>3jHa;_%1I6i{c_x3i#EOVRWy*4+UI(Vs@H84yV%~YB?zlyW zy181Q7RpQuoO_9Ur8rvv&Q@}j*{>UUJEe0d*!QZLUS))NFYr=Xktc4bET{yMmh+bE z4gJINXCPmXJg^v_r#Ww|qVaNq{DGzjYk$dP)`Crm^F8C$cN?1|o?VqGEO(4L&2Hc& z;B>PzXlBXPE=O-Z-S@35?HlI#(6y4R=`I^(`7J==)s=C&=Y;s88k>;K3)!)MfC+&- zXeO(j@M|lUW0$63+sB=zRh;P9OzuSua}W%e7pX|e_xr=)OpRWF;CDrp>lJPy?y<0ACR|9ynWSZs7~ zaN_24XclgxZMc(vsW86aTAAw^&~6S1;9?v@QBvQtWZm4x*zPW!6NYc zc>0XnjPPMs{cZYyCyv$ug2sW49gnf%6NhJ2_*Az~GBPk$a|cWs7LXZ~2GUaqn*5aZ zdEvbOgU355U=ldQfGG9F6wbvM2y}1=d&6V2aqoBHt=Q}j0vjzZmQS2&9ulX1znD3^ zI-AIpjhim~Rs~&Er6>nFTuoUbx+0TcxAdz=S$jbilVUUuJg2f9=Za|^Lq+&tivv%C!j9I<=S8dgs4|nGb86?>d2pSOg>)+sj zz&lhQIC$Gtre(BnOkyVQjcPHGwS05=OnRy!9*T)Z z-@s=zRw=wv$mHQi-o#-eDJoGxLd0t;R}Pis(V+?jpW^hK{>>3yXRx%}Wxvb5i z@c!FGRq(AiwnYy;Syb{m9^%pTykQ<#u{89FqD=SkVve8ck+i4|b~b~y_CO2Z#O0ry zn*IyFfKc~7t=!Aa%H&^(MTbquF#Jp}G)izy_qWMzewqX$BXFu@)4Av|p9WCzI^P<_ zT87=EeGtJeb4Qh#Xm=pzsg|vLn5u{}d`nS%YoHI-?h!pm)u7hrb%)C_Bn<2$vmC>1)FSIE%8KaNSbm_tWd4c{4uuN{-zmN z-zO4vKUQw+(}tPIAXl>MeuK6BqKAYK7C!tFK@}P6&(Pw7F~W=NTd;jh-$)g1kO^;9 zGW5INe}~l8a(Jq^%eq3Z1ij+)ikmMhkFx&(5wEgCWi3xPG%(K#S1(g@ofR}vwC}w= zS1`VJANyEFjscv`;S5swZ_Fpt$<6tS_2{m~9XR{-I{0a+ltGO!MQ9p?RC6K2{fjDz zfwxwr6d<#`%n(@Z|kdP6}gl4|JwSyV#YaF~6rs z%lJa1CI?s439HlNuqk~L(EB&R@j=ju_Gap{Q|ULS4T{w~y`kZghV)9F&*jR~|FEX~ zoEAc4>elJXCCr>?qvp<@?r7)|9nP;4P#3b{QHjje5{7S|xtzXhEY6vkc>#a9D{tJu zies`ts-kT4~qEuxOyF*i2kYZ@kE zr&;ayBb7K#2XVsg`3n{sUILjyZjOzW$?dJ(*WuN=MtDe_w0Fzk(P1{#pzrj9?}y=X z%G~7eZ7No_z*%k~L<_b8;HWKl(q|#@6g)Y(9Fmopu<_G%Xt9ZKwCzf%MU{yfxB&4c zDb&q*Nq+ZKt?*bsQWEwfJ6BZ9`7o>ZSF5dIXu!T|?*f`5Cb{4{ZKGSu2PNXzqwbiU+h;-P%0?dyPk{3t{Ci9q3}lJC`( z2L{gQ-$F1l#%EfC`hFsU6@zzTrDSW^e9smB~YzlIN6Vpcn9+>XUd>B6uEfv zmnEg%-}|vhc;UxF+0*~1JztuMjJ<;TGNFTk_ z;qXunb}Vc?k6LCTZJY*1bsqCb{BCp%tLNqPh-_|}`@k-hJg4fs$}A~;5LUwakX-;5 z+W_87qj5g__>4Z|yG!iay$({ZTza^~h!pZdO=0&q?>_+a5?@v*l3iX$jVkCWN;f$t z-SzAdvQu5`S|PJ?@Ad99<5w3Q`F`Ihh7NKiSzX|pd>~JmCX*KlR;W;0A*pJsq;)Pv zUoRWakb?#})&r4CL@!o!&4c?UgG#~z!$sKE8^9d6C?TAS%-R{G-vB>Az`qTZF83{b zop*i(+VKDpr4kd$yx{K!z$hF1W*b;0mPHH3-Uo&k3PmP}d|><^Z$=;^){Vfw+n2W* z(i;n}Txv9C3*;OS!D_9(8+5OGb)EK}k%jhKTn%U|4|yq_-;I>I&;Z-co4^4p0l7nI zet13}xpV)-J zs(lUxxczw=1fv;cNqJYf(qvQR#>)WNTM6^ii5JyInI3{@$pEY$4zgRI;jK|5SrS0v zyQmt|7$5T{S@^s4BaSj%nJuYkz}#MXNr)mAC=I`YlqD`IS$4uQ8Ogwxu_3@4iUSj+tBh?x_w>C!xP zsylxjJK-<*T5amJ{+=(9tlKw{^a5F2+Eu3RR1!Sibvrlh_q~InnKgMD^%}4NJ<-x{025FP85E~*PvbBKXM=17aV?YCBd9~6BXsM2Sx=1 zVklC`l}{^1nfrkYLq0-ngU?(Ps9xE;L(yjZhNsJPL{3bljT6Xh!YEj8rIf$Cz4GTH zLT009nWb!Er5kmp7UOw!<#SRoemC)|?BhRxYsBjB_1|4Myz3}~ zdG*DO6Yfh)6Xx9k_sSvQ|F!pBQB8gCwxJn7ng|LA0i=VVG^JM|B$PntU8Pq^=uIgC ziWnet484V}2%#$=5Qt7hc}&Cn&3*{q6xcbA-4YY{Dc9mXQV7^)H&e`X!B>A8**E>_h1C zU432CR|pGPADc+i2ICri91L>c!*hDkM5X;OR_%|*k%>czt1j8!gX&#dZJC>Au;HYS zKH8j2R~LGX1x0H=wfO{H^n`Rz-pNpzqCdA&_nu~k z&tPuP9W%t)lJ$E^oYvn(VoR4ZQtsK3;)#kp;kOKm-*`4`Cg$*A|Unzo_Hox)KR5( zJV0_uayBMeZURcAb`j>cx`Q!S5&`6a{}|+C>DB>V#KqY7HOlx(y^574pFIw{2~k~2 z&Iwz1k;hgY{IH|G;c*!g|Fjt}p$0m3T9(g>}lgzh_ygaKvWdb6dHPglDfS@YUzg8RJc%-Hh?Nf_%BDm5h3Iky;MhKzHsn z@w~5_`kFz4yzH-^hISVO8RK8`&TqTfoY){}qsmK7TYi6Q-Yu3nF@N-Oy;0M~icT=0 z>OEwk@ERZ$7YUmVk@A3Uvx!P=lv`L1Y~_ZDuLFRr)!IFAESbL*@ppb0L+vVd}>E~j1Vc`(}voO4t&S~m%ANT3(y!LnAC{KQdtC%T5E zaksR4d=Z&P0NG_ISG@;>onAD8>*YZ{+B1HqUkB@GwfO$v$%M!@XfV4ua$kP4F=Y+)1hB1t>?FxJ2r?;>4%~Uoi6e zD+rk!wD8Gp%$47yI%6|gdyV_J1lZVkPFplq-TK@`$OOQC>yl_*CaS8397{DRjkb5Q z>^xf3`EFF|HmF`NbBQpq*wXtx?3E6O6G?9i28u;~S|p!YV^ujIA~B7foXBRvJ_iLK0+;p{brI3Q~0t9hDA{$5xvD; zD7d{IYm+4({=I9Cbn34Vkw%h-yd-G9U9TwbbL#O5o6*U>Qs7=U;b zZe`OK`5l)s6S4P&8v3?bK72ufn=n`+$gI6IWe?_BZr3Df;IHe-e|zgrM#0?E6gtcy zHP^Fvg+S9AaXGh*UCvCjiPWz45~+ox`F9hvZUWnu7(%^G-F%hrCt~+vdM!7QeUqP? zX38waJfS933myh!obhu86-F_&T%$o5s}J}VelYP%IxH<}Q?UvwGeS-crK|3*%-VIh zJK$$&Oa262L|OaW6N7bT+Dcq)jOeN~aVu!~jX21;mqvYB-qyF&;FX@$)?SXi_v>kr61p zdVepD8G8igbK;}&IC_ja0PKBZy%dsLZ;~S3o_84W$5j@J$g=FNWd5NNU|A0!`!Sxp z*EkT|!f|}_X*1!l^KIg&H%eq3m%}W0YPcke`ygG;rRa_>eH)ki4u%bW4~+7_>jT#d zs|SUpLjY7JeW-5KH-Y(DRQFKW+vmH_N>sJ~Y- z4+g4uQlWPP}-|sl+X+Plv{Bp41lN~@)`mB5G*tFI@&`bQ zJ`TXtQH{IU9;y3zwTM9l1XKX+MX&HDS@gr35?PRx9T!ak%t}9}V5JgR>A66q zQYnNCPNf#iFH`x??$anEt6Ora6io@tFkQLQC)dFJ@ff!Nvh->#lh-U#mo>Sa`!*91Xz|G3!j;yAX$_+>Jb2+QI#wK zm`l(TkG10UAP>}MR9Q<}%zQ)2*HY3CVv3z1&Xck3R}G_#kQJ|(i${!@U@&UHZ*BW+ zeUJhiOChX97AEB2%U{b%jsK;o($hR6k^{ov73QTSZ#GzK2hp$S`;ZrZ&8OVJ3n zY!Vxrn)_{mWH(go6JIEyRJjT)p3xwbvLq*s-svm&G=5j@>! za-iOk8~RpbTZ#wx0&N?Jm;9}%2Hwof)(njCV)Ly->pd>#iHzL)(@HR<+?((od)$TI zeO}kJD9?b_Qxq;uvh$dOmnJc7^dZ%`Q(kgKVNb;hZR=pt>4;byhmpNX6?^9B6DsgEAeA;S3ao7OwW!{1qZNn2V-k|N5}Sq|W2KZje)QK#7w{Gy>h;_^n-utOwsj1hb~Ju$~yc(5VllJcT#JhD>sB>2$Gl>ZhjGW;^dFYX&+ z`*ozgsrmplGjTale}1U*;~kuDbFIEdy%F8X-!8ZFa-JS9I=RAqMVowLTc$Bcs^`NT zN6V_N8k^%GpVli0tK?*~sth-``38y_mMYA{qR5X_hk^s@Zl_?m7z3BE;cyNF>xRDT zG>ck6gVBaJy+{IDmBi^VX>>6*u1KJ1Rt`&K4%l@r{`5}rJT+j!Df7tR zo=vt=plBq`k~5i~yjchgWDmxk^1hP(vF;x`{vQ8s^FwNF76iSziTh6bnWGRl>P)2u ze?+R%MmEZV{pLRUO~B91$17)%AvH8~*{D^CEVry=ONYigaH9{}hHmfk50oSt$+q|Y4D+_fbPvpKkT z-?LA?(%_Atgu|wIBDJilqTavYws?3_VgYANsrl_!6OMwT>9qD25PHa5+i8!V(o}2J z^4!@+$408;^PJ+o*1iTvV% z39}!HRP~BFOSLTEISh2ngNv~9)>Z$v&YsL2d!C{7iula9oiRnY$_5_FJ`e57vpBX! z;LN3YlB#MzD8`XYD13@Xpz0dHrO>h-SbB6@yvoz+x$aHvRF6a}_mu;bmKJ%?!YigR zWv$gfTE@A7WjfUXYhMvrfl_Yu;*bxLv~0J;_^ zaF(LG50*Zc@c&{vEBUQp_nA>1rH9aCNO)5_MY(hhP<&Cat;llx^<5BBn_fMQq3q=s ztpBqx)d2;_r1!e3!RD>1rW%Vvu%o@%CpL$BVHU{WggU3C90JCanB%!@9m@^VgaAZC(K6>qDhs zLbv?{F$^IvuD#u=)!jmyis{q^Y@bfuUkA!Py&-hoHNeK$T3MR9PWq)4;@6{n!c4oZ zgS9?5zIDL$&>uz1Y#Pkd;v^P?J12)^x(%EIN`a=oJo&M7K{2C;bsn%RrI&8(JqZGH2973+T3rZA-c73yQ6m zci7(v#orl{pZKNKm++Me8ZvgI0?WXrj2!vsj_g#t_n|Vu$G{JbE8-6(KZn{6$#L^d>whU7+1WnWq8}ZES_s@ zj2lF+)1va_LsGYZ0-(;K`A++M~24a4OF6m6QS}At0cY3UYA1K zJI}Jj7I6<;9H<54KT=X1@rHM~kcNozmRLm-O>Z`hSk5P<+C>t`j#ExeJ+wuj63$(^ zI`U?5Z>PJq%KNe%>P^J^s94DU0F>o+hB&fx38K=-?6pXDb9ts4D>h_@K%Lhzmu2Eq zjCFU4<9?K+Zj2TdiFo7Zxi}M;*I5wv9OZSS=EpPD^rrztXBN(|DqF+WQa4@An-UJI zqsETE5~d0d=L+;R4Da-0P1?whRa&Mla!6F488)$qv%ik+fa; zE|+2;R33g zPX&PPU*=cW@GX~si4!Jbdj;oT z3%7?>r#H7HCc43lG8>ELWQsBe(z&x$nA;;MV4Do$UNt`|KCeav#^t@B>;5J#1!Lle zuBLfW69&LnOLI8^y+Dd06!PAKe|^6R$X)~XDxNJoTky%Hp}QesR4Bwnp->J|VDHb+69RK`Frgu<>_mYP2WbgVBKJ`pS1 zz6)XkWF`Ql;a37B7mrhMGn(K!Z85>f?(q>BWoi1R%7rHm<^zw!ZII`hR@VTbja8ie zLs7Nov<&n!KFj7u?Ychaiee+np-&{OwRInxwdQmli{5iVsX&vMsZ9^x_Fnn4W1&mF2d+NY7f@NyoW6lhT(kWo-|Y4t`JXT*+5vw)xXWcX`Tw zcxGDt9_EcV_JjdJxpdHE0}hA~{JW|!y9a3htrCa0zI@%4|Gl}sDW(&bfN1aIWJWc= z^Q)>9nI_rI(hRPZlVunQ@Y;XQWWx0U z-J&u{oH*d`mKbe3FX|F`7UwE z+rZ_0jxhUAK7Jm`kN4+xehN$#7vVY^i+i#1gg1W_JWoA6TJhN- zy$eC`BjIxTM8m>0ZR>~I;KHdYlbdI#Xd9mgp3l2gPh!D*Jnc4oO2+oeB$e9vM*aQq zT)pwF^@d0OB+1x7@AlJ7OXH^>NR~Pp<#2g&{vGMY{ZLN(^0s)g-*Nf>ZoJ6XlXe|7qvZiu) zMb5_IVHlHYJMXho!R(k$Q)}Gz)z>T6@mtm$8w385NsIGgqz9|NLw8W?b4h(V6)cFr(yqxvD`K!5yY(kcp z8E18&)AEIUz)nls^I;SDJKoKeJYE{_4ehwh$-Y&nMqIN-D_A7Uo0snEa9rqEn&>g1 z{QX#mERbAS4c0%;Y_lrrp=>f|8*!;6&hCIZ5~hS7>;N>Wh}cr&A1n;KFw7xizo3X$ z`m<}miEq9!TR@wM$($^jQ9_39@FOi%1+$IxmZH z-fWe|jXew72~txdd??%p2vkQwla!{*n<}pX=nf9GYzXo}O&EH9q>74c%T6h}vV82; zrZ3M|z14G1cA?v*_vd_T?-r)u4h0};`iu#Zai`k~@oejtS;6`{9v0EebA=dxiH{_< zw7pBAghCE=T49}l%y;89FH!5y=_t-UIcMD@`SHHN+m8v%_KAt z_ZrM3qcuB2sTVZ;eMf{ulwTsYcfX?rylAnSrCiLK>|rYJo^E;!RyTVx&g>R@I3smKtCe@Ql8x zbf=)JFj%AG^jyY;RFXITcHi4GfbbuSM+v#A`$yH1}jYZ=W% zT=cApCA}RB3*Z*#qnjR9%VP5A<7hL5OV)PZavl}_frLsS} zO^eFirSmc#Z8J4#U$Ylx?&yDW7v0M#R$*yo$pSqMEWA(;H^u9GQUDFYF(z${ zEp&Bnp7bd3*AGaj$TGW!zlpe`x-P;+gVdB8T7atxcJC`qkiH?l)cY2try{p->Qhn9 zx_dSrEAWymN0^c~?#uwqBBE?crW{tOKqtFbSwkX#$s7xnP*}f5Zja2uM*x3W2xhhT zRwO00wN;v^C=l$E+!2K2T}9nMVWWc5U^Ape&2NV}2zzK)1^o#;^Kko9(_hVz3GL6< zfJ3u#i`PYQ0(U~j}{f5J~OWh#7aOPrP)}QN=onE%L=Fz3worCUMjj}YSn%>NJm0Q`z|)CPok)| zxxewTqXq?o-Pd8RA}a?C9!%W6A)Z5|5jdx?^t^OwZD&l_o@HAzu&}br3N&zslKrvF z01|Ox`YJ!_?8T2u=a;oJ{X*Xh_feWK_CFtM6+Ng9f%rCQ7uH)`KU3Tmm_ zcRv>*D*Q#L6R;#3xW*$I?c{fU_-V6HEKFvPo5c$~jaD3PZ|A~zACxr1{On2^ZT@7c zXboDOpPv#SxW~zhccfe!5>htb6jFey{Nx*rLU8$7BgX)rl{L4n&6gD=%Y3FS^tg)DoJOi-<>vQrz& zP4jwe9^p+Bwx*7TTb~!|#_qLfX`6eDg*x!E^F2F@pj3V+_kxgWQgtlbHA%~V=HSgZ zGP(Vu7%#x5h{Ryd;@L+(2BC)87FiJwPAC+nuf~dnKk8AdX|46Nu?JBH5VrZ}un(rQ zDvI8{*P+6*^qF3=p+Zu^Emj9@HWw!%^Gt6oyzLmWcxlwB{^^KsuFKyAK^6MEW10yV zPFA8Gi4xbf$oXQrDFn^fhMy>!PSeI}KM?nvIRTe#5;v>$>Pn=3Tb$gvC^DK+g%Xd;~!J(+7?l~m{jtT)P&)Enz|FrJj5?fdx zLE2jOk^O`UTx?8O{Ds)5+j7iBdtSjH8g&KNOe>qgl6gEv)@kOVG$M70uxhwOMZkpPx2uv4xXziac z(9gP|wh$Vz_h*y%X`b@Qe>Wg5ZOt-ju^#B}maRXp#AFJO+Kqaz^hRwB)`?PW58bb* z%p(*Eh9@7Ea9BjYJpK%9Y^fO`FfHo%TuiP$GLjjTs;pZP`*h1ID#&+o@Wap22md6| z!0(D0Ual#9ICF;q&+ThKz>I?ybO@dDEFNaTp|RYn%ZO=gx3-b`F#NHGGn_Gi7EnC4 z8EY=W9v+^9k4>NawSo`W}$C*py-SkT9>wk7i~aMys^pO!Cq1xw_UG5gdpJVdzd z?jX#@N+a?zPE-z+?h;(k zWnG=tO4qItm7pm%{yABlIA#?UqP29nR^_|sclS+mhoN(7k%z0#>%fwp71t5DE5e%+ z->Yc3LS<@DQDp5_3e94`0hjO5!kBVM^Q_oN@I_pGPO#sjuVi+ID!WCaaY?hIZwZsF zp7bhPqS}O;0!`hJDF?N_{sZqVLcAlzV(HVu*laX^b{4@e?+)p-)P8A z2TeFwXZJ*ORM6`jo*8|tBX9Uq>bC%*?@qrNCzDeKPpT~$ipY$g+}fUd`DiOH!c^!` z3a~Gb^IbY%QKNoIPUtaCk!;Gie%~=1mm4Q@u2}jkusUa~e!JAtwRbh3h$Sxdqet;B z!?YRzAI8F2ma5GyL~a^hZ9IluULRO8VN?Tl7G89qR9NqQ1u-{oq9YjmlGZGulL0G< zgb}0}A+A&*j(YVT6i1S@q80rKDR(wf+NYug0A*m^X*(E{a0(%=#an*|{TuITr*8O^ zBBng%_jGWkbsFkqWZ98@m7QE7rGOXV^*n$$yOIyiUz+^JjGxxPi&*uo z(xO_itG@DIxR-JZgHXBW#^qBnj+<+c*U5kSkDK4Q=P6E3kE+@hWE+LLwnHJHZ6uwy z<(nl`P_~-`-mKb2?4f;E$MhpdOTg(fP!6>*RqsrAlu36dGJAF?~5Nd1FMPJ3k5RyHi2 z`H;oNg<}vH>Ta2%pL84zWZkHMK_+_ukr|Z>3L0k~k4r>(YpUS+tbEoiz0DLz!ej9{ z780a)Z=EGyxa$vWZ|9z8c-A!Cl>gmyFqUITpjjdCS)P4%BmL3st(sR>xS%r*f9pEU?(rKB&4 ztNrHIZ__WwwmxK+-+b^F+MN4k1)nHqswQO1^!%h!M_v~qU+b4EuaSPZN{3M?8=P(o zw!c0j-23D=G>3kHzv(+$P7ok%_18d{#BU>ll4ffhzVdVz`gj(@^pd+ZzL zh7opjA&PpoTk`L8d6k_AnqlD4_+ja*JOOTHWj2c!`t0hHmw!egs?}8S^CE@IZ@d=f zEAmi-u13zgsURzV{h{Mzz@t4L;aP=!n4-%h7q1=Z7tA>AL;iP=`i2L4naiz@esgi( zV&8UDs?0NASn|$MQ3#>+#gS&I$whd%m{&&Z24Ju}t^o`5j7l<`{*~;W7WaDZE1mdC zCwIZK3ikNQo9~Fc{4J? z=@FHRLJSxl6%wLQo;p*y6>mP~jaaH><;->g-&Bj4!gqga^(cz+9r zlYX3G^0ED*4dGk$>Hq_$T%Vq(A*mhH$>=2jxg>T3(*~v-o zh4uCM8_lJqz!#?JJbvDx{#<_#1-liDa&va71$=(DG9N}}%{uci38})1aB-3CRDExi zy{hGQi#Q(~aVT5^zP47YloZdykDSDgPWmm?+P}4rh}*t%d5`$O4GnIzLPs6pdBPWe z>)PqNm0wcr5{tW!YNejEvUV_ms251W+ZG5bPgAn^T)NYQC+R? zC55(#l}kx!uY6c{dwgP5Cc3}38+j!6%q@lwuM@xPxG|TlmXJlIQOEkl@2E){UL*IC8B$yHvOz z&BX7Scf>E`bT=Le*j#woBe5ANIl&&G#5}=KD0NY;!qrq$hs}np zosyWZPki6b#LgVor0RuQ7vF2>exhwD`UIgRKO&>Owhqr5RjQ4SZc!(gEL&{XeS5r*7l=HfMck!joI$DuIUcjBpW zu04|)V*I0+Olop1PH7mBpya!9vrwe7{2CDWSfNN+{I3b|sRLAIV?p*(M5E=P*GYRo zA@wOO=xbY_p|9He--+^4jn3;bT`PW)u@YS5sq(j%-BcwCa{?TFbuWl zk+sAs?-lrJCR7(wDd%Z%mo-G$B|Erh`)tc#891IdS3Yf4V?KiSzd+j?Ywr@AR?|o8!PYDIC`1zqa-f6yfHhQ#P&6&tNVS(ofT_k1Ek~`(s#0U0sVBy~9#k$;#)hX`tiE|6ceoEq`7JchSx@knKxfI*7%DG#zrWpv#c z#W63!WW^|fI4=#ljk!22N^iA>SYnt&`dg{`W##aN$(1llrWBZ)GPTLpQp<%%nTgEX}a<-G19uw%z)V`6S zSuM!i=x+)&dHMR;ihfU?<25bFO}*d4Z4_Ykt1(wPIxr`l;3-+q%Sb0uO=0c0!t0z9UJu-OwRJVt-zOg0I)!GwFE9om>SH)(BWiF?0E2wJt z_$(`vSl~fX1+1s0Du_*|Dc;wg;SX}iG+`OoliF**w7Ac-%f1HYBX>-^KdB{(PvF_M zkzvvw4gV-vVF{~kYqeyI5MK?Y)Zr@ukP)Mv_kic|;IQ6%6Z3bu#^>(HE=oCkHxr=pBM#+^M3eUVDZ0*Z|N)PBE)3dCRJ8;fzd zE6=QCugT$ZCc3OESDZ{Dn#vp>4y;tIejMEO-MZ5vSHWX1!b0tsAsZqh&;v?8IxG)C zzg|%h_%XZ_Xvac1i6wfwsZ-&m(_@SH^zwKcz-o2u<$E$`rr?m|xE1OC@MR4|(VeAv zy=b^u`%sBm!ys7^wSNgucHLmCu+-wVrsl=pjCxn2nmJ;#-+eY8z8bH>hFpM|(laMe z9iZh{`}sS62mS19Y+klLSrGg*K+-t3EN}r;J3ZqlG$O{Nh|C)gHtEchWlNo42LF*KfBZH?jAPn zl$rIk=7sfrr8TQwMg~I+V1-I~gp8$Jgg*?HQF_ujaf@H^@y&O)M06WxLdzZi(yVSx zsjE8VXSH+D5Wg4s$a3tQ=P&YiK+dz!$` zCqCExTC*ra$;bXIv$`ecLQ`$73s=<3S^xOJjnBOyaXr$)il^lu7Z(LmfP+trlK!zd zX|McZ-0kW<#~Z6UR~8=JIwOh8Yty8E`dVL#{As`5?rW9Iz&~Nm|FiemP7?nvW^gPh zw>RVA`XTEL;}$OtDvrME$XLgCJkKA6RTeQY*P^{t7O*4}x#eL8s=a%q)-UoG?bgAc z@$f+5EQ85Su;`EB-tY4cpH}4a*J~F}U2FczU z9CzZser_bcLRc9YtrO z>BWXd(~#+e3g<|1PQnEiJ3>|+G%}hZgQ8zEQJdN|v|G`YGKc^piS~>pzkaFsRy7a* z9h15mi!!2ABHNb1!ewr^*=wp&8gD#KPg zh?$^oSAV~!%nmC5TSLLgqp@p1@BM3li*C@3SVg?Y`ZYj#L9xmy=bJaJ?Irr*aq{p* zy6ZJyDe-k}aVwbyh=@CSowEy?kR1Hg7+w7ra`yu2eDy>6^}g1(X+xXv{P=(I44%@v zW&EF{fE%`2ZC5UVbw3VHwx9nK?x8jQj$|tTZ@!6K=Ym>5C_<*jh_?+WApjZ%7*P4I-HzWnkU5O&;{{p#N|g8!ZKkd`SJH8b}2 zbz892f4lPEuKc$v|KGd9_P?~ATmb8IB96f&VoiSJe>^K6PtOO6XyQrtNfC>NOZ^2$FIu^N2bE0J=&708mQ<1QY-W00;p3w8=OC00000 z0000_zybh103ZNEZ*qAcL~mnsZ*p&UCvzZ1b#!%dX>)XGV<1#vY-MvGZ)PBLXk{Qs zWpZU8VQyp~PH%TFcr7q6GA?RxXH`@U00$VAO-LJ+O-Q-}>t1zu3jhHG=mP)%1n2_* z0POl_SW`jwHwuR)y@e{hgp$w%kuK6nLJz$Q0@6YU1q6}aO9(Y|2}qS*1?e56caS0o z2nd28O^)|D&;NPv=fnHqJ)hs%*R}WhU9)HAx7N&_S!-tBFWzqesNiZaH2@9{4gmV^ z0^F|vlmP^I`1tsE1pjUX1OyKV$%qL5B}!6KVlrw6n`QqrEg&L!pAo>6+N@b zBcW^@_P%H9gqW94Mb*&G-Y@NxUoE^hqIkQ(;a_9S|7iT9^510s!{Z;Hf0<9I0l2t0 zc)0ikI1e5G2nha@iA#-#59A~er4duqdqB%&Y<1aP{gOg!KYHdr&0t^{r}=I1Q6E8h%1d*i5vl~i=CM)=L#RFjJB*1?=XgIB5E_gk)~4E9xFlHNbceSeZIgJV!6_UA6Q+o25QeV3NK>w-z<#njMqx^pZJe`>tyU(B7o}I_uF8;6L z|1>q;9r#&XGW=|O;Njnbq-@Ink^wX4=Sw0#kCX1WK1)!RmJGjr>+^P>?y9zZA6%z% z<{A0+1NUy&f5{=@cTXiWZf@THGs4I5u>Jiy>#u9<*Q&v;Zr5drq0|oyj7j8mH-~NB;4WfM5QTkiVt?V6)U7Y>D zWH~U7+D|h+EWU77$4);3IeR0`1(du6n5W+q$<_J(R#tHpuxq{t0KuKfFSu<2 zc^=#Y6wd2!XZ|Vos?_yHc%7#{!HE+-YiSn@J>OkwvIq~Ybb)OnS@?b_Kihv;XQ|M2 z-Vsb3AH>z9bu)y$8;*+LkX>0(8#SsDtrD1Sq#S0;XhNoA37iLJF%%M}&rlD)LaS1P ze}Uh6J-=3U#ayOn+@$_%&A-%D_K}%4AM;dbFwMMt5w}n0+=Zw3?oPZ7%>U&ocJBpi z#6MdlY22i#yqy-X3zY3`XX$&g%*&8gYEX70aaeH(hMLh@D*4vdMZ|0q#>~~2#W+Rs z+E1RhSACkErj01Hf$9Dv%z5H`qqB6!>yI!LG}TPYgpbfhw141EX?VS4+Bx{>Z{X@w zHs2@IdhM@K2F=fsG37d)zj95pJf)K!(JK52NyPchaunvkVZFDWa3>Xl40FJ@-dj%C zf1^IaKh(-$1-NdJ=&i>^o+H)%<|v+TEG7Niio26aN=vPq@xnHYi*2h8pw9gJ>m>?( zKD?%`^y*?a4gfbm$iL2E7QMUGds|8sBzf`T=1{OZxV|60lBG zvg0mbf*;VVEYTC0c$7!+uSDx95nep=dBk*j)-Ubt@R|omDf4KB)U`q3HOJzfYw@0-JFr<1 zU(hw^I|~9GaFY-}Xbwubvdvj!6F#Itnp1CHE8kg_nw7$2pI0W0@sKnJ#e~0aupkM+ z3eZl6kZ`L{2Sc$zLd#mp#fDaJ_t%FNK%`9i85J%X9qRKax4t9Cro8<7T0XX=iFvIM zquGLE?f5bGkA2KwNuKKS_Ua?`mzA6(ujr>I)AmrMum8MeCpvz~zL7zlB~_A4@X=li z#I7f!s`k?E4>brqrBqL@w98^b*z+9<9bgvj_F{Ah@H0YfyNf3)#m|I$0?tn3@?xEE zceRda@HC1UJhg;JWZZUfKR+P6hypaROPyKiJD=YNIsY= zz&X zF^E1zy5&Oyu}T26_C!}cbE|TrDg)t@RWL;Odt@4j6}$?G8ve3MAGqILFY>j6;rg{a z=0@!9X6kKethIg@?MoL_s{5v?vpvM+z>i63)2#h~` z_87who?nF*1;ed2TK@MpH9)b@WQV*5JpE95Cl#CM7ZlbKhzI`qL6VJ%@8uskR({~C zZ-IL3+>-sFN1Jc76#K?x?jDqh=sFeQ&W}_lLtCym9l)2$yeaS%wnv4q?{12b(`I&3 zaPt=YIJQ@D!FkU>FP%y?*$P65LjK1q#0kX%7$-))GT5E5Fs6y6&qr|e_WSR>{C$Ld zdrgF{ z_@_tjv`Y&3Dn~_E8xNQf=XxU~ABzPI{G3EZbS&n9)jFc`D5EUr%Hhn6H!GplIEr%U zJcdms(d~gxT|3OW%d|P`0qKOr2pO;1COdqH2a9YfadFe{B zaHnG>${-cKS%lMTT5%-?l{=)y?MS1^rY+T~uOAYUG>FwUh|8AKrFpU*-@&c(V+UeHiLKe%xGP|C7WVQIoX3#Q;PtmjP3jBQ18E zc8Lj$@5QMQ_gX2AXEu#@zA5{FTVS9=VWIcNAvwo?-Z|5&Ld&xTb*#%SMCoIc46LBA zBOprlnBXefl6jq9WU%vn=L=n3#Y$ILc@P&AOyHINveQM3@)1}jg7Za# z&|ArO(Ju750+~!5B8HZF=Mq24lnwU{j&tB2?HZ*P4?hSDYoyQaAt@hFj;_+@@}77{ z_c~yp7l`8cC($`+&JD$MBl5%bbodlJc&k-%w23kn7AiUGWh{5pdAc4x-Fs?e4iuh5 zbUmS=ow+Sp3duAo>3r9qzOd7UwTW|S>OV)>s`pijY;$lUO) zpsl)}JftuBGQZz;Z7fP)7`nIb0pORvdY2!{KEQWU8-<+fuJs5AOl2uJfOHXR1mn50_DN%et@XSk%SSXBQVXiM}wdaFm{T zebJEHrYtCQVyHcq36<@pm*-fQ*6*|8y*00Q#)>>l$hH+t+w@eZLN7Hl>q%1`w78+) zr{9|E9ZN({+CWA`16z!|UudNgzYfV~l{w02LZkecSAIm`a~$l)+^MWCb^oyB1pL2)yml z!OX-%{iq5P=Qpr|rmo?LCeqyVh|7v))4=)o) z#2p=DPI1JW@hoDs2c!O;=`h)qm;@tDqg%=zQunl$MV6%FH^V&Fm5SK6QAhvDQD;MR z9cG8k(QF{op(5gdY{Vd2DMf6=frr=3$f3kkx6NodGTiGYK9z5Xobf&in5r@@KxW9p z3K9AsZsrMQnP2{tI?zQ!^hgm9=oD3(I1_o2V`@sT@44+*Pdm#x$=f<|B$iIYtzi&K8)3O$RA8>&1y`60 z#`~;K`3bcBr3ALi%aY-Q{mBY}dpp$pdUr-H?GdeEzX-v`l$z#vy47mgbdiPtv20EX zS33L+jKM}(+DM_NAS@?FY#R9K9Y6h1YS|o|L2i4t8|A&L5vs>wb8BImZ4p00Rj^Eq zz!+&x*n{?~KiN$79RaJqHI$mCw;722nb@ns z3~!74G0Z=D!(}uh0G;2)!pOzZxZ#Duk%te83+0G^{g(6nndsFcbOLSG@2xW=o;MNZ zDXu#{<5I9{?il~`)<;s5moW>2(9_CRc7~0a8qSolVlB?j)K!)IFCd6qGg~*sl6l?t zvzKvP%O6;M8WX;dtN7L%W;?3{3XZjxzZ7L|jQ?h}X?ArF5cFtPui<{9uGFVfZBMM{ znkY-Yi9G<+~c_({p|VTuy`PMx#xCHE#eu}mrRq_(5Bv}Z3DCfnPhve zM5$jveda2O!{x`Dg0a zD$95fE3$G<@-lTF;baldEZ%znLq7#_v9%34FI`?Y8k{C8JjvrW+7g}wOm&YRELbbj zGcAP&jG&V-0+v8|$LZvC52FzE%r{e=NGxYxMCocc<}!g&{*(!>C?~Y{_sELjgLvRy3zh8m}F$aMiLb7}~sJanEj}HGGc{j90E4?hZIy0tGIQSJo zH4TqV57yCqT6;Y5h=Rl9>&#N|TuClGI}d$PS}&m#je7-a7V!SWV;ht|F`Zr>AC@!^ z!oZAnwiv_~<)C-X`D_b5rb%+vWYnJNeRSD5Tg8?ayeVSly8gwbZ?^sjhNyNKzpy59 z-SpNj*)of7l(#&^NY|=x&WZ!P-^1JzlI0gNpp*2SBG(0u|JgO$PBvwbrulkl(GXW%K4-*{4pC z*#ImFKg{&O@&_OyxbHG^UlBeTVPDI&34sz$3l%2HJT_0a=4>{zaR(0okK^hp4WOSL zeoQEA;ZUVU9~^VliojEUK!~;d(W7G6mWnHW*oN?%_d{kfZALyKTF*ov%Q@MeSw0|( zsvh*aAJkh)eu(#iymsr#N01F6Zlxe_N-;0VEFf{0H~VzlZA+@b~G5~oR2x-fmM5QWWxZTih;D2TcW{muW6P4)FZwDuqL_8*D{L>|jl%<;bt8cEB+7p^~)n5@Qd5r`30U0PlRq=9ebo z1|!Jq7?~x)ZWmY{Vb9-c_H9KMry!-gIgz$xSh9x0GXf0W6xCK4NgkG?O`l1>^3l#{ zreuGztw~po+v;aorYg55J13x#j}n(UZ`yQ|+J`6SbP<>@kdsu|G<`MOWk@5Mb*b9y zFzly!OzZ(~Q9{}~OrMsrrPZn31E9Q!hiL;q=3*^a8E|HJ0v2GYUek`#PjWMD%m2d{ zj0S7?&w?TP&v{26I%#9(IzwE_eUA}yYA+qVDGQYxEMbAdO=GED@74kHoU!^dkAc9E zOgJWEG!51<*HMd)_uQXxDUppGKb`dXpDZ<_QXRPNt8!Jv@D?c4tX-t&don`z&2eTb zb7d3MgO-1}Yqw*o>UqEdq8|l3ppeM0G^mJCMT}IXQ#)_NHG)B}6Nm-?xdX+0h z?IE!NPB0|IquA1ziTbpsU*11z!C@I;kSOqANhez%o%~ZMutDd* z4Aw^IK5$^6pdue}eN5rBW$Uze)8M~+i!l&X7tMR$Zfua{$wQL0wkQ=5=I#nbXbr@| ztECtdo6eL85G^rH)9L8&A?+4)>63YQrpH$e0!yaAJ^SZbQ45CFwf)1+71+mKMqGP& zP9*wj-<*vSen1{E+L=osYxJwaXbG{BfxfuY8`zA7tlK*RB&y`a{VsQ!G?cnmsJS?OwqLMBE?yx+5m*Qxf7YoI=OB@ z@U-KE#OHCNuEl%KT`XfB<5MRF-~=1Xz2Sm7_&oTo;r(}SN>ZAKH}v1s;s;n*VSyv} zcu!`ljhqd$dlv&q$0F%Ou7itcft{X^22 zvG=G`waTNwf0zE*+W(va@cd_%=*|56Mm0!~6`7Z`SSHM$Ce^D|w6?SRDjStHwSiMr>cxDWpRKfmHsrC^ddm6IJAp1!GDg8(% z$XbtWmWO8&E1Y+Q$rz!_Y(g~sJT{P%?H`QSe){M_`xASdML*iqTo-bjCMXfdW%T@@ z*r^+`l@Hyag7dMvdFV(KH|!a9!xaFDOdd_yi4XXl-wlY6CfY($Gfiwt*J)^Xzy1Io zN#Th7*eF`sN-hj6eK)y$x`6nIIPk&Y{YzguGK(?sd;;bXYHSmRhgG3;-GfMQ#V)Cf zC4>XFaH9XkJ=~r7q9Tyv-%DyGu}}nXUz+$7Ive(#x|>3iHS&Nl)>jKUW1Xc$*u*d3 zxAJPkpaZ$P<*%+Ahc3ezjyf<`8JHQ<`_MTeKcoC84cow^*(Nd+XRi|@>gpjuh~2PY zm31JhrLnaFJsKysN3cMtV}r!fgqY&SsW1`GR3y zRX+F_Oo}6{;zdA0a8T&&dEdb#X;p@y-7fL$LEeZjo(3?@Bte83&B88{teq-#4gVYv3T0Q6O;`~5X=yRQcsG8!LYu-u z7NGGb7hI)M8DiT9W0JuP4bhri5PCH@!`pS31HGcLC88ER%xCOK z#CT;$m1I_pfO&x99d?})5!y{yP%3(PzgBm8!!x${ZVf0~t0iw~KoSQ})vR&W`p)8c zfiZixmTOL|^-C>69W zWR^Pk3zu*BaVX9+s1pL6;@|u+IAFk1%Tv3T!wR*0URWI>to1ad>yi?W*j(u1lZ!TF zjKsk}SS`CyIg|CIbXqsUr|9F7dpi&wy$Uut2{Fp@Lld3$+6VhRm6|9pV%k< z^O1Zw12x1$j)i+&CE2Z>U5YlDIw3&gs)2Jq*-6%=HcgeN+}vOw-eu}FN^M9=ia&ug2)~(b{1DF zgpCEsAp26vdk-Emz*00t$I07+X35I&B^YkBHufsI&JRbZ$G;d(bti2lgDtrYJW3}f z(~0pmtm3fvGIl^!LHnU0p9c>iozG}6(5~ObsMu_1-Y3L^q>W1zx z)i+mCzngl((RW7IcJl&aXP=gZGXapFW=fO&8l7$k<1)rS8^~yQC5lr)_2i`4iQUgg!{omHYsyhEwutk z8x3usLCzEOJnz6HigV!4Y)ZF75^`L8D@ZTxm!u;=DwZ5fdYV45uP|5`VlSyUWPpit zR(!Ov*GC&qh(PI?EvHw1X;_KC2fcrztxMUl>G_q#E>!<6oixtKap@(OH2!cT??*~i z!_|9YxKwC_#a9g~ISyq88P<2_zn1BzndMSm!RO?xuQg3}b zt0U{ir|$Zn-KvmooDhL8%FjL`1?~Zc(tm2{iQywpbOEn2KjxAJw>IS8A--?*$p@$o zZ4s~K%AU(-3lY^ClVD)5N9JRK}FDg0RPkjMR#ei!|ZuoW@e--I)+;u|S0of|$C{ z36TGe=u`2L@Ui-kPa~6^y)hhs2gR&U+I%GAYPQR~pzCF`>DR3EBac(isipaYJyg5S z$gGI9t@sa|cHVL=*5AV0ejAx-X|+mRr6K1>(A%+K&@24Zw+4?LPQ&YYtorngAJuJD zHY;JA03J0RU;3G=V^|xrQ(dt%Bz1AbY|B&tNsb+vxO>1LW1`a;GOxkX_M48}r}j~^ z1Nvt7zUyt5Kd6pJ%cBRW=^3VN*_t)RWg4Js8?b74h)iz`f$E@z=S!J+-(7`o4??BA zaC7=b%d#>JYJe30O^HvJbt_=q1&NGiVY|LFZe3dk!!~q%)o0-|PGn2!b4AJ8&#WSJ z%lW!)W>~?GGt7-&xiT`*2gt&o>@3g~i9VQr7K9wCNxW~s|L8T(MgJkfpPyW)IwUik z*f#G*_GH_xDPeOx zsf>8iGK;r}ml&vMzvU{<)1Puthp&c3}(3oCxU7-=~4h%;hTLUvJjmmkyp>%|N=o22B#Jm@8b=Is;Q^y#o8;o=z(mLA7LJ*CwNq1<$*g=(w)& ze~qbrp{4-VP>k71T1>3Ww%VN`U4?cizrR%Y9lcz>I$M_vF_l4olZ1XzMxLa)6V2%M&<>)b>=>TFgJiIX#4@7K{*D)PyWDdu z#|k?^<-)w!DpIktUvUx>5dO!J zG=%n`pR@akUK--kF5eE!31fLsT?hLtd!;6pmQVaYRRVNSawYtc2LCjn1|KMO{&6TrD2KSoT@ zN`XJ#J;YtQnT`st&}K=WS-*tMloqzWD7 zcS=puThA#a=YrXOWwP#>X{M{ zA9gNnfL#rdi+meSo>;VT`T*=y{f#&gpP+&hqIJ#(Jwv`$`jP3@E~2T56pv|~>7wv$ zT3)Y784_}8L%jAgP+ZXa5MdT9n}^PEvo|2)ev~Yzd-TAh(2B6;$tg8JryGcN`lJW7 zEfbM_0y(?lwcjyskJS+@xke#_sbAKTyHGw~<;Bi83Sc7v&e}PrP|t;QA5wm!=3Pa4 zmOeF~9IFedHMy`q*5}$qXpQ zWvI5Z)PJG_q);qlXdY!g;bHOXn(sTC#@UaUGd|4O{`fMe)t-FfCK?Wv0BdytEJDf8 zaBb)e9~Sf1-SE4DrQ6VLF~Yb+7jp!GW^XFuLLZyNDc6-}$!S{U6luy#Qw=t2a%SK_ zYsRSD0tpS0QTr;#eZ9a6M(ouhcI^x-W*r#^HRe!rM^Wy}shE*=D1@rhT@-)vsL z`DmDt)Bo7@2`BUM4Xbe2_ zCQeg@2Pr;`jV8fS*8eWbcTTtP{x;?wF!1bG^p~>G@D~anEqPfrKf?}HHs$P0zq@hy4k9!DI6HZS z0x=3dzYPA|?lA(^6wBl+g|vE@-2+}RQ&m{-(~&xPea@3x^V4NuU~+D^&XD>Mo-S7PF7LerQwlPf;fXkvP@5Ad=Kzt=qd%I^(r4X#)bhe?`8z+lSziSkWzaXm zL!Z03@Ixa|zJhIEXpw>dKEH+@gNd4AnN-kY{cIR`3UN+#TjdW_igw}y1W*k8YP674 zPX;9(9SB?iONBiw>;+5e^wR!z+W|4^3O7xJ0Nic2Cd#eaa@`#VnEjL>k229`zu#AzWO>@g~6;Q$ZVvf=Q8rb&K){z)`X7{!fYtK5L(NsU< zp52{YW^@rl}nn-ZTF$A*_uQzXX6JUjgA2q z698b+@d;nNin&*A_3)ADA#tR0OJ1(d2kkZFGciO~#=0fCsaij`?!DVoR1y01pJJ9Iw5Ef zQybfkB(-xO(w$mvC7bV4+s?waxpl%J*e1~R?2a02GIx=@+CnlF3;|szw(`Ae{Kzu8 zZ$QeNXd2)`sj0sM%ETBd5UVmwvN$!(x|Y)C3=0r5NGrBBh8r$BntOGaF5u2Ve$=%d zs|C%(x=U2>Sv1e0q!>;1!ErB7jKE9hVIt!t2qWLCvLy$jiciIwJdQ0gBBS_L+wI{` zzS0Rkm3*p}=cg5u<&>OZkr_0|M(W!j*IpL_uPlYOa5xQ!%xJjdkaCoM3R<|C!u;Ql z$$9@(s0;AUtY8}$2cqZ@#K@>WLPPd-{OnUn+&avalq>_%gg?G1I(T0GviTKHTS3=jzVQRy zJnDTkFKFyUUlx|JYX=pAtcM-{c%6#6&UE<~m7Gfwm^&{sUEzyM|52@@-qM*EdW=t{ z&hbBgE{KjiX8cb;$X<=)z6Thz{tF11dbPMdoj;QFcH+yJ(#Vgaw%H)6ca~-7m73Zy z$m-y&_BH4g!a@VP=fBun1yRNucD#2z{v2}f>+kT4xqTEbiG2yrLbmBQG~`0>A*zGW z-g4VYG$>Yahn%%^4P*B{jwHI{0RkPjIAY7MWHHm;m#isW&q5S!7KGNRLNRj#0a zWBirXA1)U?wRO!vbCYwl_)L5~z2PYYI4`aim zc2$QZ%PHEhVC#)N{fZ8m3Uk!X^uh%*3L#s4^RaoN_i0&2VzHz1gOK`t8amjqPO~H@CnYq$z zde#E+cH<2yttyH)c(J;l*tNB6od!z`22R~k(y1=__{&mxnh72ueK||o4|*CFO3TmB zTgF<(w$G~Rnx5jXOp%{)3n5%8H$I6m@c(nJCxO!kgxMt1I(>xka;J1wNs=6*^E}YZ z_YGJQx+gEiEDVQ!Q1FGuMjwV1De&bezs{$dbD1eTh~NHUUe09&w@4}VLP(YN^|wgE z%t42h`U7{G6D*QaXaSjJjtV_bmn+bLZ)6Z6yQXJ%AEDix;vYuV`Wdcz+mMWlQrt); zzw!`Wzs1_;BnCd@@1ZMgl7Rs(-*v5@cE#rOby(CKJ4t5aOx{p9DCugU3SKA9MZJ?3 zE$msqr>LZkHZLCu2S$}JdN8DB2>(_e_HgPp0$mDd>v1-mJvR8=v^BZ=BE`|`fE3}X zAn!s6jULV_vG!+7DM74`_ob-{H342pRLMyb;L=hG6Xu9)%7m_&#WMO?(vX#n-*%0x zO(H)qtuKi7FW|>!p3#ijl!4?|v_9(w)z2=z8p3zBH1vqQ}?ebfrMwT+d&RcwaIgBa3Vcj6h((W_}^7|Ff4A(Xs z^tbmgHad(X5+P0K;J&>1lXw zs=Q(cH4@EX>t7<>a?BN;&St&sZzjEydiG-6dhX-NK+$slJuuc?XOP8Ey70U-DhSBrXC0C8RDsr|5o(TzhKcNz1;MYnO(l! zRq|Tl=^RhE1QcMszywn^chs+coCMosa*24H_LMiBaNa(oJxW(~XwrX7;M2}CN?jcA z8f;h$hz?I(+5q4oU>tbsHZ(7P>2$_a>LHL$`4xWC5v26k?smtHKWCpGsknU9<}Ebg z>TYKWB_`@e$5QN)0Gw9K z{w=Cre%;t$(-7~|B>1oQ)Bu`3oWMkYcjS@x1NemC7Xr1JyqE9DV zazd=&jnqvVJ8DKP4a(V65%I2~wqL`F>P^EF`BH52Rf=*HZB{Hm!+E0{u{8RQB#h4m zGhzf|oi%E1B_VO%yX9;d)9O2_DZWxAl~bYAKdl}wcCaWk>}Pz)9b=Ii)~uNcDm0e) zW;%yExD^wh;h^}%vga4gnOabUtX8?zer`HI&^*%a#Nh0uQ=R z-cDKdJ0GMdE7j}t(-AuT@U-XG6KbL#O}_{5Zn;Igav(&T-sQ))3%v(nQef`Z=bl98 z!VF8f-h;r2uuCCTsxut7)Ob&A{$6iQn_)h%$WRknxXeJoT=fgj@S8lus#2sOzf%_P zGBuIFQm|D2ljg|B%b%@FG=auyB|=L+iQCUBl0>6+rLk>eeZ`Het{NnBhK(}wN*eX# zH^DYY9I#ykn;*;Of>}>y(0*2V{BL54PfbcBSd8rN{pDI1VUKWXfy$p$@~w`H&EH?1 ze`hTp7T*XcnBU~b)X-A^#C>p%<%&eAW9p8Fn}#Di&%+m;oys6NVS!6ak__Jpx%&HP zZ1um6c|1KGe<~7ra+?iC2SiSu{D`sg!5?kAhRH=+9?OZ4Yidl*P;3ioS1L{Lm+A32 zm{C-b>tiw~Pzs2-7k%XmJDV)W_&@n?Xb@GJH5;C#y7UoYg(S1=Im+ISjD+MboIp>^ zlab!!g)mzr80X?0YeAYfY@@hBiIdc3w&di3Ex)uemcbnD=HI7Lb@5AXU3;`3$#-Sp z(M(8GVWqyMpRHcZMc;~GhK07FB@xGx+SdAxMPSjOCbb&ll3g`%<=FTnM0<)wNa}Wl z2hJ3CN-Rx?v+tu(!U2Gk{3w0)yMuaGFM*N*mSpuP$TYWj~ zjcgURJt-`F>nqh0Sly|Iw;iyKsa3==C0RMjlm);7@>#F&5MhK=h4e8FB=DtA0Ppym za<}glU#e-ITl#NBv74+x#k%z5h6_t|M+z&$B%-p*0&Z8P6!E{TRmf*Rd9%E-s)flP zP$k+e1%xXagTH3XHGdNQkx6qzHriu)XL5PJYJwx}6NcbJC0NzDv2okR)6LUPB17#Z zVDe<*VJX2Z&v)xp;0at3YTu6KMt#goQja%o3&(;F-NH*>|4GNZ^aOpkShMOY&*e0 z3U3nQbwCI#zX|k!*iRh3tn|*7G5MO75!$aSfl&$Nj!%-gy3B|1MCVT*A}@)DkJxhg zq8$$E9gNgGmtC{c5rl-V2v<>rTq`Uliq$oW zs+pEn-O;g1h%z`dzY;%*awe69pkp#}$2QKi$mU#9{*2hk>zK1-XBO8m?fp_?B|*5< zHA!#{@Yx{mGa}XNfn0Hto#{BM)Yp;McWQ$hr9BTxCulw#rv6ml?fr@cuorn#yW9g1 zFHiKz(trpcUBTI#Q-}N^xE$;2mDy(!0F*CY&z0grdt}NF5l542@vH4&dPrNuAQvH} zg~h{u(~lFe<}GeH+aorY`l8|^`W`=`VMO$J4QNsnW!wBiKN4c;dZ&R^owr1&9f$Sw zs>g<@CKM|yn$X0*88>x@tk`V%wd&pCUtTqk?ORXK55c;URj0$Np6Lb@A&$NM zsLzi+m&!M1^jz1N%UA2dA}Zjhk@5Hpl36iImI<*5ngeJeieNpM2P!P1$ zQDn1980$jwr+Vd}koKw<8~PslWL-buN)qIsHQks!LKMpLFkAGvp;nBy(J10= zmOvaArpc2n`x<2614Eu1#OTTnrlUMgT9xXdy;Jve?E zG2;x9*&A6RVew@;i6CYXg~T*yf06%oI3x*A`&OzAFq5fily}BxsucGm7jxvU-Bx}g z_}!f9ZW`ew_w{r?A(n1mzb^cTYEE#NX2Z)s0+vMbnM?RMz>Qbf zli&2UQ6;*4q2MRYSS(eXyah0c}Kb0qPl{d`}C zC2?9l5{-+^88Vv}21XGr=&E%F>+SD56+yA%*!j|Zfl?i@tgFyurW_o_-=!J}s?~(g z45WXU6lVL4ttqW>_CrfdwN4Nkkt5BrA-3Foh{}=KIZ2QLaxQ{LT7kUH-DgDv&ztGx z_tkuC*7Jk3G)d2gpCS?g7S6)>&fyiI#2O4boty9Vyg!x?4TAufHI$8w$1Tf>I}=u_}?9 z42YdGPA5dfp@9MIgLHasOWU<3=@J%KLkyDT9hVX1=XeX-B2^uLiXj2zLVy@jQrk zyx<8pNAy@|NblJU;*Wd{x-xV}NWd&%uH4eyoWeNK__X1`o{gxChs&lY0$)FxgWu(a z_n_Xdfm_2Thru-ESzcX+s`ER#aR7r6?N{Vg1*~Dz*^*AA@DU>J`T~a2&4+bu&(kjO zSCr{uHkOmK{M9XNK+Y+y9m|XX)t?O*A$zW z{p6LS(8H?aoG!?wWd-Rgr3B8aG~JyYUV5y3rl*EdQgXr8uTZ%_nI7;XP!qPp!0iM1 zY;QHO-PcD%?zNaGv_Zw$FBQ!@7A!BzGLqV{12r}_Gs35DlbjFpBHwb#$2{iO<8H>M;(4@omGCF&UM0o5(4qf} zskaPjgNYip@#1d9p#hTME~UM3NCE*ua4T8@gyMx#THG}xxRU_EiUe(`pv78>dy5t? zRw%XmyPxNs_nq(8&d#2HJG*;bXXngeaecWM7bNG-z)-KC_qMIW=yx9I$kTX==zx#M zhv&KqsMF^i-^c9eR%sJwv0gO`gp*LHX%Yszf0$v64c$zQ!|qeTj8)@5P72OeKSUB7 z=e31&Es6K$*|x|&-F7(sKvpezr!+hD|>Tvr%XNr_B9VC9`8MJu=le$C80G{oNMYWh*>-70->aTq>9BSJS3gSlaQ0ODSioNwofW$L6L=#?uR?OxfwQyATm}57|c4ANldqbvcrY7 z{F}e@Mj>27=?t22NQ+}QL^Zosc}lZ5Y@~c%v>0jfo!k2bt32wY8&BX$4WC%4`Yj-S zFZW9YLW7N)NMz5vmS6oNx}_FhJ6H+ssh#LHDeBnFCI`MrdaC~i)T;Yc)%cHCtx0h$ zQvb3y6}g-yTYI0t@WrUywqWNl(W#|A+3?GvSeOkdf#McQ+b8QjQQ#5V3lqVa6|TVD zbMf@mwN0Z56?U4>bCY>0$JoPM9U)OSA(EV?P4Ef=AU-kE+*l;eS13%=fcMoPM{!1F z7}yvL5tl47P43ppthHcD?Vx#GQeU|VN^x4bnUNK?H1*U9E@=i0T#NB|fn}7mJlBBQ zFozNP7%DCx6+vv?x@N>7{ry(>2_KokF&w-}L~?9xQS?1PHT66m z1WvJn7nK;T3O#!>Kt0jZ*oxgVw47px#l$iuRP!vciRa;^4}3;)*Msmm&+`BlWeP4W zu+^O=BngPZL~gvNK4$?jk6iutjEVo;m2q%2{lr(8XKC=dbNNpXRW=e+@=BTN&^P9p zg)+^s&^r}~y>|_K0^xx3UTOu80)t^r!}Dco^Y64B%Pv@7&omTFTcUqFB!55a9Xw&I z_%N!zaBe`OIj9lPV2NuFoYa-}k{&`jN}1|^4+;#LXqsF`=FM)tz}8V`&RK07!1h}AfuAI&c{Y-vr7F4~)6 zuc`3kgAL%f)@R*RY~3P{X#`T-lCH$A->a(OitqO>-z;!?oXks z@g@BQuAS30R(HfOTG%?WF4}RAl1_-w4~`FFV6nW^Sxhmtul~5Fs1{N;p-Iq3qB~XO zXH-W<1)?*;1Y1%!^Hq{+b&Rm+V*+prO#CyU%T0&_&xVW-6-}jhn_?1$()g6wutAI=^RS>kfWd+3%=m|Ky zDsTI+ofaBH*;f1zatq86kVdcz9{@c-!oT*4Spufi??_bIy7?H4-Yg+j3w7r^3DZiP z)ch{v8n_Crgtu(0$EsG4K`t$=!m!Lg`1_qR(zq5}Z(8p$7Qg*CB8WTNv~54ISv4jV z2y6QAPpk`a9bDwyB`ygowDhunaIn347@@0UFnOI~VLnDS8N{)a-2gM2G@0Ff2+qBr zhwe%WBlKQuYa6#f5fljO#ORfD)XanRp8E5dEq{4qkqZFNasx7RR1NbwqUg1q$8M__IC0@Jm0;PYWwJM+C43NkD6RicsnwE z>*{;5Z&g)QeyenI6p;L$qyks-vnuyf%MHdfpdfqewTtq-pXLsia5t%y%Ul8UH_LTK zg)z1$_Tq5zo7VN>_pAaNHssHvE)3@?dX^^)OYYj-+_BcbW-SZ@;@V^h>yfG1W);1w=&s_W`LV`7H^z> z<%%Q;Gm$=_QI;uWmm2jp|Qw#N*pYqXBTw zr4Soz&-OE?D<^2#)UgtuHGOgCF1X_;n~a_>=K(EcuaG}F%`a3lWJeLlDwjoVtRu)vSeBIoQs36AD_Kt7l zx?PMZlXxb zGRdMd?yE1dc4J3+oU1Q%rBtAGB?Y*G0gmDu>w#SIq{UDf{Q5dX+;G5LWmYI~0bW1z za1|C5oUV1AQ=&{eaVs@wm2HjN-(>Hb$hFkgO|?Uo^81$`L8&^o$*~4I z8G2rZ62@m2VUvyB*@h;C4DJP>z884gqLN${zK}W1K)NtzPlEEHhc_O`s&f&?*^W}y zENSt{a7NAjqYcG7b-nb|CZxop9+bq01gb$KyN^DNt&tZepH7T9xwM`i6uE+4*11j| z&zf;MCfJ6jEjkvsnvgDw=j~IWoPO~5aJC|0_{2wq_c4+~m6$QpXLrTA8F$2Tu}x=D*(F#A7p6JUVqKUmV16R zEDn5t1X)g2`97BIqlbGC^eig*t}nXfV|yt-dRb}sOS7|^Zy*{Yd+gOM3>(_zTV|xt zPjH;-mCrN9m@S^hoQcYrAoxK^_ksz zq51Ua6qoON2HzJ9gO5OFFIMsMk_9x&pdF0LR%Mb3O}2fJ5?s~`y%H_(T-8n8(J|MR z`$wjJ8V54sDNJ#teP~Jbwz0`eoxv;qYXfUd9Dmt;wy9y}0kCx2oXP_9BWk1ut3>`y z7pZ>sbpQ)Oh?hk%6Te1vt?ocO%<%n<4W`PZx+bv5E?r;D^nB~|8M7)art;1|F$zKa z#k$zU58nyEEPtGfGA88SHJ`m;dvc!QZsS1X_lN6xOG{y+;BJ8PTgPZ+E!ul$ARBpP zvr+|g=Ih`rel?c?S$&+A_pLr5iL)%K@-trih~HyYGgAvXbFoBaLd zu!7`W#j5>n1^>`OKk?cN2hv-%%f)-Hzv|$UvEG*TqvqoHX%mD>#ePwi3I7v*1nO_gjv+#38FxQ;jZ4Y`s7(iz!xUJXajQ zWAN_!4uYkO`|Q<~wS^Jte5{7O)~ke}bJOm={kt<&?R&c_4>X^?=6S2=QygkwoU1=O zQ%?;ABgiGb_P?dw=|5NE`g!#;keeB#q?F8a)tTG)lJDXUskMLMG{ZT*aw(b^1a zj+Egus`0O^mvalHxNTczAM>9HvGwtZ*elB+AhF_%sb!1>n84ryIJ!p#6tR{Q4hlNq zU)`QRW{(mOW7xl|IIZfg1peJP$z8FdYv*sQ*_!i(+n+=RHGZSKSQhB>=*&lqW4~s` zXEFB!hy?N`LOm_`YDwWj7Vb}JOhUvAKYGl{1;QZQUB}Nf4LtT*E%UHTe_V|;r_%M* zROI7djE?7qVKYoed#LXqvU4Mo+mSBwCpM06zTgid9hNrF3%U*-AzLxDu zW2WzHwGEM7>s5ZV+_n}d-5V3jTi;n>i@IKJgk(#_8jStZl%ejmHh#t?CfR(s(8d7k zeQAz5`D;vLRp`xV9+of)v#VL96KR?brBiE#6Di?pC&^b8+w%hNEV+nP3+=cBio60u z5rTWOF>zM=6A-R13KPV!b5)UaeHyUF~ti_vu1WJ}6>54`tdTnIl67YC-MSwOK4$ftwL(4eDV8IHpi>2Hr*?AnKIh z2okrRrOX@2;{Ra~(S(*L)EO`_sj5{t3cm2NtuOld!El9tgnQX4=#!1xptoLpxKRnL zYox#@qBrW^ZY`UoneBrQN!S^OlD8F1oYBba2uB~$ZVvXaQZr0c&|+tj!$2@Z6zn)y zw1%_K?9v~ASbE+YvNL1A>(#(m%^#vzthO64O2}Py*o?`9C>jrNvN2bk8T?t*^hxza zx^4Y@14nxnwv*^3oL`{r;HMsq>&rz**O+&NyBIqU@|!+sX;cE;O(c#rOsdD5Cft$2 zq}r#fg|+4NFOTWPldp?*v!okjIS(v8APps4J3w`mOo5-j$Kf*DXdfzaWuZR^aBr>;zBK^Rh9UHRfha_>e(ji#s&< zY0|``l_xp!M8wT&I0^+0@XUC1u&pS>>p|B0B*Wt$#YP71k2!W4dC6k(R?*x3o^ZRz>_v*7ouq zzDIF$VB-E>fa*Tns!M(c(-73kb4xGE^^=lx%95Jr?h4v5+q9X+cG#)Bb&aH3Wx-pkpO zTG6zk0lY$P6F4N%KUiEBOGYQM@H+RqCi3qhcsc~C+$2#BwXs0u-DXPQ1bz+FMitaG zI6t7x1Swr0$4Z(E4W47F2Ke^CoQ!NC`B(~pxM zc0S+VOy-MECu)CwJyK}-Kc}e%FNnWqF5^Q1YNM1HMOHeY@7g#Kohm=Ryp3O zX}TowqCbfXH;%ti?;Gq8r?>Qd=7U0uTk9Y&iV6HXRHrF42_0Y#4g}Wbw)Pr9x^ugF z>?vb3;ByNzaaY!~$y-v`nszx(ZuSf?ryB7|p2*i=s=kQ}A|+(YEKhmSX+9pTJNEBN z@MT;8wK+X8ub|tv^6u!Q6X|MBn{y73VRN2&$YZh`Kk>`Bz%`}0`G;N;ksc8Vp3J-E z5!?KE*r*=Xldd4}GH80g+^fJs7JX|ip z1rDjtj2?MGGhGAVsk5SU7!uKCrIc1Gd5Yj_4d2wm85=T!X)+g0Hi4<+)LsoitCH%cp2Fxo~H)& zzBI4awB007&CAn{eI^>bU`!8i%%G#xb`_?vIh|HW-*F=#-JD$qzk}qfy{6;A2X4@^H5 zmpEXSkXD72@(C`M!69vBBK(4GM?GPhfU-dRAb}fm>;p&b*6ecQ+VaXFCgXTJ#wt2h^-A{Pc$JkWg%Kb3jK-p1POs;9TcV8h2+>E0agyLQrXG2j&zp`G;NNTXdD9@wW>Ay) zQ|Nm|Q;eh4htS_tWXPv)gHtEFAgNL8eKJNhuXl{tJNfMo~VAn}dsUfaq0F?EB9fkTnkB!^UU*7%72s!F%s!NC*R!5Pa0f0Lg3 z_|lz(HoWAfF$q2GUc+edaK}YS##Fx6Lz2(ZlH60IlGH0*1#NkFfjuVi; z@1g5(fAOvjz3R-H60}K&u5%zB2|(DW5^9jQOV>RTsCL`l9j#_O8g)$AnxVtbqEvc% zMmbxp@}d^3aVGv;vdh{qbZ45AZ+FkNQ}IOt_+@)r$WNp8%YC0X@rfZq=lZ#;tYSs% zOQEq?%kBF9;op_rw9|+Aa-d*E%Zd~1x%slCf(JFH*KM-iUP^ zc0i-?4bUDsO*h#;$e*c3@_#WoGv}OLR!jBX1O_{q-(nKG>#XZb3^f3X|#~L%FWIi4LgSKNoff7*D(^#KLBI z$*=a{LO|AGT(_333 z96#?RbwV}$7qfu$2QT>L{}jH2w$3c>yS&v8x!iv<9f6IBoD=`{jI!f{x+*~6G2z*q zv&DotUM+Z#N%}tUcz>QC=|1T}4ZAWZN%^LcQ1LRxy3gEXTv2&=#d2b7eEc89#f7IG z0DrrVlw(0Nd74(y4NU}*6Wek=kP8R)DefjIrOqxd+Xe_x4&UaNt_Hg*eh1}i_KO=# zZJ6z+42g=4=$sh0-I7(12-=j1MDdLNWWP1q72>L}?|6rf&xke`yu@)*@Bc=*0nE%?tdaIc(GgN4A z{Un(bT(*aW(k47XJ!_p29z&ETy7?skQ!|hTAHu}=YS`uKl6DGR=3u_zgR?Lq1~kU-CA&+EZ~J0RFB-){20)rzshRp>?WfkF5-9P$gqf* zR(JVb^7N|zm)3|Rt?4oB;mk~;7qmffaDr4Qx-H5rI!L}#Go~ic6ke}o96N><{rFI! zn0?2e53NCv#<7+pYug5T$W-}jhe`}D=+e&LX(C#=RQPRHB8j8~~V#3OZy=Yf|R)T{!()-e3 znLR${hC5f1>rj!QiFUa3rQ1-~iIuc)1!ayL29k6L?l3276?ZX)bF5ktaWdH`3hriGl z<>TyTYEBCP_hobsJ=Vqq98)+4#ZGLgR9X8{I6^l zhKMhCn#=9giPf20OF5Z^5L5r4tBwF!8q+6t-Z9q+8(4W*fuw)Adx>->@)up8 z&fWhu+eO7lpH?-zaf2_tDX zi(=N6?R~0zq}6HZEJBzz+3#ZTEWPrxE7ptq#j2Hk=2x=*jfeh;cezm&`#f|~)~Qy_ z4FEy%9-2ExlGDE)UL27pgF2ol?HRwejvu4%*#WikT@MP*2>T#2DmFC*I(f^KLQr&y zU(6xemM8TG)B?$Le82B-{GgIdm9i{|V@EdRF6mGFp2=m7&!E6o`N_R^o+?{wmZhWY zKFgw7&Hc>cl)RC!`G@HGlU0`iJ}EwO5r-vx?v&<#OaMf@Pj+l^nxGWdLf&88Elr4S&x!ZmPG2y>(|H}&JQ%xY7UoE8)b&rgx_Hi$b5m`8m{)W_ol$u@-Y zb~;mZt)3ElW$dJPnQrs+w@X=iNZHOzg*a5N0pf-|5C2SM{)Uf32IdTQK1P6|%jO+x z!pLItL5=U1z_N;yi;mj$<7R@shnIeF*$FB#d> zvuv_2x9Xmrg)2OwJouL^jH@Mq{5K@|c8xOfHun>B1g3OG=-YV|yxBj?q`OSv#aGQ{dGcDAg=r6T}$y7?pQ|$m;EMD+p0y0>wB^0<6 zT~nV{d!fE2xY=Qw3>}-Gp`ELIT6T>Rq zOwJak`acRZeT2;jEloX%9rLfF4*_1*b;&oW%AM4g)SF+ zC9C1o{}MQy7u!q4rx2H@#MpvUuhii^2I z-uPejjbPcsGxWt4Kp5V9*6|IWP>H-rIMh)|#lrKuP+N_q8&+Z-G0U#J6=X131!>$! zE&1cDi?eyrqi)`+)Ppgzaf3Rwv6p`C{qUygaO^8Kz-`G-IEDzT9i6gJQA1cQ=Rx0U zOq=Fen@*G8`#FS(Mfp>TwI_kj3nD`6I|TPt;gJ8NI*3^FVyA9LV-*m*BTzPGa!*En zFC57abvT$*ExvW)M?aL2tjR`b+7fEjngq@`RH=!*Ynmj(iNQVc3e~2FKGv@)D-#@p zoLsNw^J2Xg(LOZew^OK?_4$KSuYse$*w7qwu*~S>gi0@vtg=O!&-T#wEW_cOTf>ow zX55_Qbm8MXv+VC533FN!h5ok$}+_y_B~4&*Fi zko4I`Tw3{Y0+|ozqqnO8JA7Ry`nkfo|B@}Zc&}kf?WWE*xO>4yI)(U;Xa*s;c;w^- zVhk)G=ka+a@@Lh`NY+0}|5MGh{eOE-7_!h#JUES(dZXJOf)*$XC;ZQUg#z-YwA+%j zX5eKZuBh(ggj+u6Dyd$_+^=e72$&r&wz*)zqe)u`pOG}@Bs_SsQl}$okdk6!+{NM1 z`|LUGf%=X(ioz&SbxKO<(JT2T?m$MkR>pV30Msnt+p?_|W&IrR){rB+yZ-ChJtd5# z%ottbA<(D;QEQ8QHPCRU-J^LacfG=xdyDPlj{fXejXWNEUh`0*J@$Qu?d@OBTmLwL18k|Anh&E)$^2u%gh0{cT@S2={bp zxTJ#5*KD@Y&LuOq&Q{oq4%sEIs{S3!hMy-ae`0y`TPyHQ$P;@0AVBm-Z2#-)J~9sS3G>=$$xud(r3%YQgl>j0{>%6uC88{w61jLxi@@R7(bX(xSbZC33#fw|A z)qiEUb!FCKHkTfGEANLWA4;;xr##EX0etEzppJ4o{etM=qtn~is zVqR%&l~XXi8FuC8ng@V0_O3|XXT5NAssn}j9O#jPlp2^sfGW{?QVHEcaC5C1@#e_S zPwiWQBT4rN<};3gGTaW1E0DwVRs0UUReQ~XD&%8b_mq)b=G!?Z?+>$e9einzUYZp} z!Dk)A3ThmTd#ySAy53h0y}b)UG={JEOI4eYTJJ(F5sbZKz5SpIgKZe}8^0dB6heQs zK%&_ONa-;Dl)2>hl)D~>1R?%G;NPUF{GibMRW-{3-Jl59Ze|7SvomG^jS_CGPqHBQx=+96 zQpV!5u>>huhXZ>`3$S*Js$P_354u(VuR+- zO4+x?D4w4#Fqf>>lsA`wkg$KIDdO50Su895F)k-sWw-@-zDk4nMK@Mz;6_qSk9pi2 zd*s?LdHgunq`hW-sNX0!sWp9ETn$Z9`5TeSD)`cQnIbGM*~;FK7jM1_z|jF%JBrCe z8?>7R2U7Yr?ou9e+jh-j6VC<*Uq`;5EvvIbuz1-~0(YXE)l5gXBqogUQwd?}WW+ zqQNkAM^j{#8s5gtu)s{ztW6A!`1Sr@6D(YSVPE-fyZjY$0*l7op!A$J9~F^fCvdG#W7r60x-i*KwH(1J{P54os22Y8TA+9Nq+9T!dCZy_}|?#mfbkL+!FD2{&+qs!r(pd zbzzLw33b&%EXr1lwxg6>{+*TKn{**+{=^(n@CI3};(Ldp$I<*eKMcV{x;H{>QN0?| z`~xe(m6q4CbUoPH3+EKso244jlCMN6gq00}{;nL1Q)nTSRpIH&?jm8R|CuU=NoK;;+x~j zThDW@uKy*YHAKUDXsp++_gEL(_zB4riJbkx z>&{wyYE<{k&-*`IR$4jvpAhEHL9e%ee@c_=d1L;2QMzpRkKTpbmyeK-fmNhC|E&v0 zG6$gx>oJ9;*?LN{)L^UO$GK7nghe3_H#E{Q^xjlIof*!9Kag9vm_OJmJTW*i<&Xkw<%X8`lP;xD~ncMZPlH$pA3BEQi zSm+e8q~?7f_Z7jbV>U!UhTn&GHzftwc0OGwSP(T>x$k?{*AtrTbQ-cFW6Po+uYA=UdqS@I*Eq+JN~_! zIu@|h)$xke_|vwg;M#OqbTCJ#A4EeE8Y&Ba9X-3Xhplc$3qH_9YQ zK1SkT5(U?Trf_}-Gv&ZM^eGdB8W8gckR4R|^rW1<`bPVJ;F(wmrY>v{;aoJAd< z>`cXk5Iq=(BEb^7fFlLtVH{?hH_ zC=4=sT%6oK4Ab3x@c+kPqa<#Azb2!$oFMeryfni1})#+iNl$9$!n2EZfG6AX22TN+oLiJ(B5<_1A5tpzygcKw;N z$MZ`{-XU^L8%@kyYuBX;|Gb7N8muB-Ja*uoy@60%i|~fbgH6Dp74b^S{nJnSW2g=T zvdcBNk9O6LvgN{kM<**&TETg#DVX z*Bh-(mjPr_*N1LGe997b$rz=@Z0^^=Ruj!|6@9r2AfU5Yto91A6Ngd=f}_}y8E3ba zI?y5cmHgrCSZ=Ojx8s}y|3DT9-m(mV>oU#HySk4gN#ip`SaHs!K{5Y;tns+e8!pCC zc+7AJ|I5b*v?o2^GPg3WDW630&H{Ypz_rq>7a)_yad0b-VQSl>8u#UIJX`2HgX2FW z>ZbD~BtZUN)ZThAgE{Lt1rmQHZ6l5;lI~=H+6od=zgjokUq^FQXPi?}4qvx^^$%qF z*kxYxs{ZL^6+xmu<#sWGQ~PUWTT*fgI{WU@^IMj)ojX1%5hPJ^S7zlEpoNe@zo15A z)M*w+qlxNcl^eIq?dhN6{hq5E$Gcrrb)PemxjW5em^7{TEfGUsB?Lr$EHg4_%w@F* zwg;l0Ee1KP>e=&EiJ>MWQV)0wja4r*xC3t65!;;K)X8*vTML5LOjgq87M0}!0c*sD zf60`c^lcxLX$%ZdZn#0rUiDtQ1NBA46R+3>n4wh6sO7gndij zrAct!@=u++ek&|=w|T(d!S0CfJv&6R?iQR@-nT^*lt^2y(eS+Lidm098MrISfPi71iXT9Uc~xZ45;D9` z_-DEF2s;Ijm)X2we)2RAc12>z)<5R#XFbk9->@ATC$c;- z%Jp3*_dbivWo49Isvh%me8n7?1-$q2hkk{pvxQ_yglal)T?@EhDO7Mw2}pmtKT=|| zUW~I-J>_E0=H^m84zfCruk+Gnw^0Um2+Lg|{FGlTk$u~CHx1>4AH+$!mWOUE-=AkF(;eVa}P zvuAN;{g5ED~o!~^#cnZ z(|)XV>6AIQxvu$EQJT1gMQ3r^`e27$t;IizKP3d1O=QQQE(ik*ftsCQf6!_%1s;Xc zWTP!nwhbuTgykC1I;|~-Az5d0;E89NMAwr5>b7(7T02yuSL=+0Cr{xc5rq!6NARG`}}^Z1G+bZRVl)B2buWh}?z+)Q+D zgR86J!mmb;eF7kO78uX*jAAe=y`U|^xbGu*koFYw#WS`y6i`(t>zSz?yW(CRYVgq- z&(sa*+wbg;fx}(}Pn<)=+?=+FvIGNhFob(dNAst<=@UG0nKbbhG0(L`C-GLqo9s9s zp3%(Sl%ILF-V8K8HV%piW?OEh<9WlstR{ikR_ri!wX@4;jG{+)_3xAtQ>;Ia8S6q{ z+I)LYNA;WC?eA2Ma{tsa)FB_;g6+iKXp+gSRis9I63Px_UKA`X31}MCP}|Mx@PT4= z=`OA|51^woj_XvFK#Ifm$30r^G-VjSI|OVKhXHCWL1dqByb;Vy{c>J><$X>SIw%_& zC7&Vx!vUpd|KFL5xcF^xxM-PY1IV$9(^vjPQLi z-ldse4}MkNoc`R`dHzWwx#{_|Nx+*vvq08S@dqo4f5cr%dG=q$^IgD@Abrea1DUYxSl3`9oQ`xvaW3#-r1k53GMuv9ED}; zld06sE}O$XV|sV*YrLXtQ>fF4O#K^8Koiy~2NdTM`IeNTu_JIIP$&L?Z5h1dUGSJv zvuMY_a>j~LP#%Sg2lQAFde`-VkBRiE^s~M-waFPuoJ$9X<8@+*ANXIuVk=dE=ad{Y zS0WWs?NO#95q%pY$dOCJY_^rPr|ZiqPj7l-Js+MHk*gRx)8l6WgTr^VOP+bR>eam# zfB*B`9AQOhX1Wl1vcHBFdxh<3Q9bL^t^CKvsGTXX)^mwTown72s$_oh!)d;WXG$Sy zyiY!lwav0%rF%8P@sj84G!tj`!Rjlo4*^kNk8CMDB)||6pY-nA~Wsi z$>Nt8!j9S>qd81N&BvMKXG~Nb1MQft$XFqrzU9mwdS0EwQUl8SJ!78Ghy}@F^TuYY zj;2n3e@;gs!^BnHM%UUz9~|RdR2*1FJ;0k}kC^ zrk)g4zTmK;#&+VZ!xUz!n_qwL&})FOm+f;t@Q)ufH`%TG1@uf~{UY^aAshV+-3rOJ zJ{hU@rTNAV%uV&_6^Mj!U+XiG;xhrJs%OQ^dg^ayOhuUpDGzsc$0K5Qg$i0l%4mUgktPc13e%1k`z|MYss9Th@38$Dv`eI|7`D zkTj;K3<)Vd_-u4shj-!Vg}iy2bPTWAnoe75e%HbHRc7q|)NG zqc@9t;v3wkJKWb8aWj3wnU}u9j58CCKdw9)~*xRd&r+4o;DZ7vSyFXD=Q6xhBB}i%g5KY9XIC-N!%FV@*{wXO zRl503PA$z&*CXW>V+&wjk-cK&6dcbPw-3o&@n)RxwU0`7^ZtQyv$ijmE;LY&*{P(X z>#Qz{nOBYCXtmX2=S|3SMwg92X9`^2B)m)PQ;V1rDi1phLv8A`^k}Qznhwpt7)Y^r zLW9M@7OE6q9{(7j>kLv~A5bR7%Y;N+^V+|Aznq6lV7Sf+P#>Y>WUiH8B@LlT35*SE z;~e=qZ*y)$+wyG1Oew?1O4LLGtzxtyk`K!9TKg~=nFRss9z-9zMs9Nva{!8bcy&i8 zM)6`oxfeTNLo0d@UmV=hna9VV`@C9-e%2x-0i!`+dVQ(ewlY*`0sgts3 z&tl}gSa6MZ&4zgirAE94&Fg9PtO#eDro#*5zY+wXj3dj?1@NFZ^bA+uy zyAu*@85~mjwisK6UOa&B0cy5wueF<>dAl7w<}{t(lbex(nYGV8)GyT!wr`6~LXT_u zJ-w;CAkv&=G&L7Uz3xvD1gVk~F=~^CLcNdGUl0hO>}b6X>lhcn5@c(GchTmkYWed@ zaa^q%?(%hI3k1e8&xhV|(8m)s3$|uCOy!`CMUS4g9cR5_n-RGDol-k4+L6SXXO697 zqNO7<{57;)=A@Z?Rm#Cva_^<#r|o1`IyI+iM8>RHx?e}eR(avY2!!&1Lh*3xZJJNx zw-79DNPBTYSr(g{1H&S5W0|enf+h@_1!s}If&jA8J0I|AkFTBOA6*Qm z+;jo}Wo1PnO0xY7%`%4XfC6S^n&M1PqckPT^9G0k;g1wLdbtK-UI)>31z04j z;Y-@qmjHw(t`$i>TsMMa&`?zTH%inO+V0-0)GAYdyPrD-ik8N-PwMd>kndVoq4=dv z^w<~sEhio(cp<&^l9TEfrOFdyvWS(4fe9J97x+Z8x$^m|M>jGqsag5Rbaw^)sC}xb zmWe|r*T+GXhF+c@fxkK!*V9I3?H3Ls)qdx_7&02=%Mq(pG!i`jkB)`|%z|DbmYif_ zdnU_2DR6F@Hzq$~8Gk>Hg82lFF8}6>DPOV=H3+;&rD~6v~BdjV`*qfX; zm@2$;UeuO|<}*8Xa%ZWXMk`RUYO}1x0Ob6BSkcXbs}^~_wI<%(v-Br+vE!Qn1nn~_ z?`P_WckM4`T0JG6*v%u{9s+^ABc4CX9DB7{un9YrKZaQ`9H)in0; zp7!S8Uoh_D(kiT*J)G;I`9G{FM*I(kta%)oHurn-%~uM6LDTKcs}I=H2fAAM zVsqYR{qrpGo`CsJgh$oAY#$%{vO-AD&<7&94vTuRI_Zqsa5X~Tp{qB+AT<(^iJZ1A z4FD;6d%dOkO#taTHYk`j5niz4XU65?yDhg5-J}aVV6HTQCo{XNW%e@$6{Kll@ABr} z<|(7aGRe2R-z)}EG@_Tzbx3*VdjuOW=k-$M!WfaR)LyZxgb1b*`cjc?#^BN`$A6;Q zsmp5&$o$mPlPu|50I9zkm)sAz@mdKgTCmU2Gu@jmqSm2>hI290{8bP+vf(UoX|Fek z@`bgVK@)dhxE}9m@KkKn^}S3^KaEQwXKu~uG-lv=L-ELaqX_*~0*jZSV&GiSqi29J zr;KH*^uTL0x;)|3wJEbXA7u4YLlHKy2QU%vIZL!m-Q5m7ql&Lbk@%JWg>t=`Kp!}F zqdda0$j}w_H^Y2k@9zK2Si8;MH}*vD>&pGdp?i7&}29)lSQc@ZTy}s+d-{*PP z_x^MCA7`y|*4q6$XP_tW{QngkDh!iEA-b|vW7a5n7I%pzud%AYccev`Z0Y}Bq(Z_(@ADHDeySl%$gz+my zp<$}aqMD1kTqkXhe`tn8$Le40-7rDKWhQ<2UL+Yla8a-jpfOH7OM3dU!4LzPL|0ha z+YOImn#bg#@S-s04Q7SJf0lwZJ`pPm#Q?jm^D-HvxfimHDd;OfioLC)uS-12!>THf z-hGn(M7p54a$7Arqymyx*ws7Ki)=E)NuZVP2TaI?ve!osT zcrsH;iwM}3dd&E0cAFoL@^7k?xAq%~cDI5Z8#9Z(8K)P~5fSx8PQMOaf7~q=5oANV>2sA^y8`7MJ%1P@Lu-3p0^-X+w zm)Kil$US71d0Qm}9Tm=y3tqG#YvX*K2%JYPA{_!C>kHtju=uJVBAK#MzVtE1Ji8T5 zkNj8t@04#T4I50Ch~W5SK$H8hRAU4KSq_*hlIK z?ww|0z9k!4DQHm|xwD;>fAenI|J!e;KSs#uv_8eJX4VoVxPt!<$ULBc6s6Ra`h?@$rPXKOE3V#$!{0)KG{*YpPguU*p}Z52zOE(%Fp z>W9SdnWWU(*_heo+wBaJ@V#>Qj@+}aEl&^gB?f38B%7{fluhh`JlSy$YEe605g5@L z?Rn6=Wc&<=)ReOAvxjAx1(Gz=l>s9du)N>!sxEcs(X6;asrQGAjl7i))5Sm7oBY32 zbh~(nii=BS_-Gbdw+PFXe{@{7xGq!k3XJCBh3Bj|IAcb$K{SRu+6(QiM=4>e`B$+g zPZz^)fR@_WOY>QYfj(qT2-lvvDM38>sySVyyh&IAq<_`^iV2<;&ll!c>dYXc(x}BcG^#f7-qk=_U=IK7(aVgSKJ*x4q zEI{O~mb#Zeh+Eq@qkV~l&Y!s8Z}v-j?}Qp>ULIa5;S`!^o4R z^%McC-YG?GUMZxktae&Xptqrje&%&j#}aIWnQ!i2U*Dmb*6P0iML@d0s_4)-6UR5T zENPE{$-JXV-M_5g^~@VI$%APcTVmV&OIvx}C8=^>CwN#>8@5Lx=?P5fmkd3`)rg0q z2Ry3H_ZlVQ(e@**;FlyEg}E#tPQLXUX+OFH9iKZ{`z&{XWXYe#~zG2GE(CmeQfeme2u3|I2*n)?svydIXA!+6!ars9xuNDJ+6-ye44T{j~ zK)}0V{8_82EP(_2C!wMC>rQRB5Un4C^ghA_!B?u&;~cJJ=KQQz;PPbzbYRhNeCKO% zz#}7*FppwpR^KVWt@?B%5EdU!T@{V3!J%?urZRvXnEFOyqkSLWgrzxxe%rv zvx{y0!#T_jB>VTB_)k-J|HLLSK^qPK2#_1Gjhm}owD#8MmRsiXl4(-{m>-@#bKR%h z;8{FgpT5}~6jQ<{D;ev{$!icgjnvX_REKW<0!kLoL<{hmvA6#o)_bSbJaQ|AKC0Mj zVft`b+Z+vSgSPlJaOxi8-E4yT9+r0Bekt7?FboT2nXQ7*Z{o39f*PAbDLpC<&q-l= zXTNe%mJ4iyS3zxGyMkTU=?r!;K6 z-?Wtd1!_pKRb$1*;*AGn>)1jW* zoM$fu6VmBEgx27YZp53XErWPWUYb)m>dxuE;vC4?s(9<$aS_}v^w=~v z)K6;OR5LN2=u%7l3dq_zmur)1U@}1QG6v#aJSdnY`Ru8#*N4%h=#gW*mw-LGRTZt< z0?Q8hnUZ1TrhA}&Dk-$?NZ+Q+G{N^+Cov$E-9iR(<-}e&!v=NzG4nV?5a2z1Ojq|2 zQn#tE`bTIrr`CRGztU<@G|vv1o+-nnu=lOotdp)7FG(uy8^>%RdTJ>>m1|azI?hs6 zkxN8-FqE+P<6XNZYzmzwqQ2loo+ZeTmU|cvj#T|x*3GNk9|u_f#=d>{$li}0XDwT3 zVc?JptycU~s{)2SGe!5TeCiXw%z7xm>K%DDw{!O-%+(K zIug02u1AA)n~-dIoq}*NL+B5-F;qEI`=lqTg~=HZwamqiry{<>BKaUGtKjglR zoAwckd^om5x7XTG#`eOUn6>;#mw`Tx2GoNq5%SXmzq9i8)Dgqc7pt;b${geVZfk_O z2R_sE%3|{q!Rbl98IL?}EHzogJ8h#@m-_84^e!|FJLW*+bgCgLh`ewABnrRFJH&Uf-FS%rf#j+>U$cSyAs<1w>f;cyb zjJzw}WxJvZ4a?ju-!|$8`LQ(e*(jjg6_D0Y`|-(H1|MUO43@_l=jkoZt+!KT>NxnQ zuwtxntY2UpD8?4PSpFrxMPvx8`vq(juJB|_j6+35TyxX8a#`l(dx@aEem|fCj%!HE?*4>Jv{5OeLvXmLe8F zE%{)zxee#(jjB#Q(~MT5EiT4rMijtqo8iPka#_;ndwdVWQeq^-r_vevu^V_7##3}7 zQkVo`TBMlfVkjN?g;#BEsv(f+WeRj$?l~SmG=%gZoh;W2g+mhOgH1CCebrc^kO7JB`O@ab4bP3TS&pw-H28~u9lkCP|M(AHAMKxkpM;dKBa8&Y;{?;E zx72@!N1rVh-E%*Z=%TFs$jiJO&M(e$(CSi-!o5IWn>!Ky{P>5q*D4=6)3PfnunGps z0#18UulAJkSNZ*DoCGdqJVj1SHXKxPvPp~d^FVsvLJUsuo3AUkx4yHU_z_Uo&Zks< zUOIl}?>L<((p+9s#q01>hBc)lg<1FGS;WxQ)cQ*nEI})6F=Vsa#GoIMVB%xg$wuvD zOjQ;?=P8QEGC!b|F_axc<7r5)5dX?HNV9txV)8x$?CccO7Lt_iD+yG>myP{ZTFdBC zXIo{?FZMs2f|pm&Mk#ucJ=u#uphhh{HOOae9T9iK$-45^*^gHmVJ(K8=K4Z!ZsR8dO5 z?MvI()G4BNwiz;B$xiFeN1jYowI^rjXNS}KpC?;~R@nks(>?qYqTUbm55*XxMyA|@ zVT)PLK+iD|Fnii0y*dHI!_hDMcP{d2cB z{mVF&!d?PIK zWSdYOwx!dV()l;Nb_OGn&Z|a)$d+$A)Ovc@pI_?&nDU~8JvS?#XN788ey|!4re`OY zH@&TMsNTwm!Cx&`-vgVLMp}}!qBQPGyzP>V5!Jflomr;+SJtFk-SzE;>8P#BajgnQ zvIJIpEX3X2E{j)OI3D?Vb!MP_trZ9C${VcxlclyH3L<(>H^?`@T#LW~$zSAlU~dWsK?{ZjEF z1YZkj;a=)x67bk3^aU12Be+6>q?bC0zc#WS->TN#5`=PS+3xV_@&ee0+hcQNURsxp z*<(6F-ydYU!|Sl@f}N}(Buk^wNB#kVEwKvOmlrMHg$OdVKk@Qv-=eexx!v@fc1_~B z65jWwzj@=f^H~li4b*lIUNn)ou(D~gUnG|DOBKcUBl^K@TL)lUPyWE>Jpx;*ZLrKn z8$KQAAGs3uYA1H4(l)J(tntC%!AqVf9JD0<8wv=+!z*Kr&q`GzR!y01-G^ z-KE`n7w2NxA|TSiTgg_{Vc<-(ZtLakqIkkg=}0Cs>(G4Ibcu+z4T*5k;@ccg_D~|b zpyH`;Lm=8ClX*Gv_rHJg)|@*B!UqZ%gDEUdjz1L&#a{t7{oC|hT#_Uq?v>-3#c^A5 zx0x~HDMY(m9*({uqxX(=<>_&9>d{Zmab6YZhk7V-G8h6;L&NiOly$)+{mH~lfQ z3&pr*zp)^2NxVg(3W@)GGDz#aQcg!$YO|M)Ok3lXD-ecF6&?;Ogw|30fTQi~(VY6D z$nU&lS>Pr)s-rCNj~Cw^hZDn6716|dqIx6@&Kx5q9Ur1>!FU#Xeye@ltNDfd)0bdu zmZWKciAX1Lo#dPhel%0WBR$Y+C6KHeq=z>0Bm5>FeKHG1FAI)xz}-nlU$VJxf|F)Zn@~^6M}%@q zsoUve5Qgm%X2G`X)H*{sldOS=3`SFD`m@wP&nRJ_&2a{Y^3$unJH--*Tx)M*p_dY~ z(sJsHM!f%Gq}3N!2r}k+^W4oxv+l~F3D4xYFl~=S@;W-aK(8y-qc9pxd)&2?>eI9AFmX9@v`gY8I_SDvQg47pd57$B;AZ}iA5(F0fectv zQ+3jo!|Hh@ghLmoCnqzp>HE+wxGy)PuM4{b6ykLm*emJjE}4#YZ)*ISv#7(Ia}%%~ zf?AO|!?;LA0H1Bo<%ozE|ub-b2WT`s}Wkyej!O_law4~+_>!S(+ zIJ4g=NE$T6`YD5(BX2ufq|p)zB{?XCO=P(Mm8dMdVVB6T5*iPwRjlK*!#@A`;MD>d z{A;?kwLc6`FRnM5R%-42^2M6W#dmb#xe?DEpbDIe?)UHg6e(23)e zv;|AE2H@HtTM`-@4*i7_SYa;06SmZ}iCFhYA>cuuSikFe|L!<)O}wA0FFEafuWApi zz(PnASd;zWc0u+BCqCEv6`#I;@L1%o$^v*5{5JLZ=CJ}OC}$B=BO1@ilOm(~Co*Cz zeN;iSq$*i({LxelORjcunxsr>u|{2iwI^GWT0_IhuV^KHgP_{6rLMyd->qlbUmznbOk6 zZe+TzK;K^u=FtYQ%vdJ|JsN(`SZx@;dkRh3o6>?J`%+2yBUg#&rFX);D8J?`9d;rO z8Dh6#sU|*~Sc<1tKu~Im)Lr0bl=kV4mM5>ud+f6*-W7dYIq! z6w97-1bXiJX=!VFQ2!siD87KUHX%b$N_Frd@(^8Lo=2zSKm3RBbyl{qH4fSW$Scx! zC2s$pFLYyuhQ*)MUkQQDnoFUj5kT=bs+B5e+)yc%3r@Dwe}vyP-e3*!Rd?4SlLGDEaLFbA8#abur`|6o`G2<%|Z6J2q%DVm_k z&!#RCG9N_l+Z=8|{nN!zVx603Q*-gQJ!ATlvSq0uM24I-L>M?jC0!byY zk3ZeHD_C{0xX8zrn}ZDRc*Wc-v!Bad!T8>ps4_v|{Qx!HQ2ucxmy1H;MQinvpYaPeNKFrqmxgzi zd+4(u>lMAvSSnsDD;Tthxd1=lZNXTA{5PJg_^i4W?hr22*$1%8?5WyBG9Jlq*QkLk z{G4oyD45H0HD*w6KU$@XNb^qxSK?*h&m?e*_GBr`=ph`yTBPn zStgLgMJW5nrMdg8F8*=1WErtIz5`QP*8Fg{Y7noI26^s{*2VQgqm~PI)NP8$qb5Ihq%_ zH?(_yw@LOH7RQlu)b`jubY|I~alh+m9^+Zadl(j<8{Ug zS|be00gdt{>bDH#AwQMxI4kcz{s-644F${j_6xCq;hXCq@J*Jg*fU|AekFDh5w`3n{{yWicq2-{PAgmhsu^#M}{j|<9QbQXZDpahVwF9Lp@ zb?DTi=zPn*BeCnz)Es&C!+t?>s0rOyzITgg1MKLOw59UL2hE@QzOz0j|Loq%6Z1D( z#)J#krg1KU4Rxv-6|@+WK4uN2G_6fHp`R701(X~se z?bOV}atwAs6jT*9Lqok0sP;X^9!Fi84?%t(K9HdvRqzpCBA<0Syx?BMM5BM|j#U>g zYT|pWXKxto*M2`_y+?^gXleXL4azVs@tK}EuK=Rn`{P>xta>fhKcu2BF5hItY-7n% zbFg!TX)abK{!@u9$}KVy+n}>ds3Q2ZWth|;-Ce_#5tw^v5M+f)bg5mYBAMDmfg^fe z7{oWQDE+1elTd$|P;I10_uK|t(6-&EQ>2^J9X|7^9gOA_QyYvSh%8NBFl5p}a) zs*xYsi^$0dYWyZ)RveeGuCcs}P=3t=d(1q$?M`5SGt!Yc)zXe88l75N0U%5XskJuN zml)L?hjbVAKLuWcbe>z$Cb*9 z9Ys6tDYQP$&w9CNyIjkK-4Lj1jm8V;_>?!qOZmV^bN8{QvxWh<{oJR#Mpli2|AR+F zZA|u@&>!hR2{s9wmAjY#Zwt4Rx3?$he4Gq>4SgoCrbA3wqf}b=%E}>f(!VT+JdnZM zNI-8uz-44;FHuE4V_?aYKsnj`?W7_6wTqr0E~~c^U&y#u3>H zSI(*)Hv1@7tLhrB3Vc6*Pa^P)r-Bq&lWjJyHda0NTq+S3a3-TVIDL$=Vvf6_VG$S?@D0Y8 z!Mw)(uBG-zTxZ>PUy}U(p&IqF*_}yAV>b+hJ4BBLDL_~Y)v4G@pl{Jp@3$CvnA?qM zLoFiT7$;3l3+E?Q0xVo6Y@Vx1^H^L@F(c4HuhjfzGEGF}Mqx;bieS<<#fMv;si4l{ zfF!yPMj7S5Y*b-29_%=Nn802I165CIa4obembLF}I%L}^U(^7YB^C4{c@+Q5J8jJ& zKOV!A$2#lRWZ9{eTebNp6d}^nsh3mZMQZlm&d~y1>p>+^Ftu1v_@$J-V$BG|qO!wf z!71}eZY$S5#(65o(k0V*qIe5q(Nuc<=r2>P)Rcm?dm#=|tzVsyJoPAxYWW7YwNJ-P zy{M!)gY4VI1X%Jh=5e>6ZOMU`<$1$_l5TQC>gAmoLW<-XAa%qCda;tQR1C~Z?mc6hp~m0g*?G~@f$jrRN`3E|@TJ}d zL?O{*THi#(gu+anLB4eo{YQzl@R|ZcJQ|)M<~_+kN~xI^Z>@PYGCxt7MKUN&NWFs9 z=6sy$d@OL_kLf^z7dbu{9-hCHR`emtV(H#7u8n!wd3@ifwL4}mJ)=)f=4Z?F0IlKU zWPm##g9okWF2=M%;k0I=f_dwRmc%N<|0^IoRX$cF*1?DWjpo&OoR;`jn1BqLS{{8Hn zOmBlazB81&1l+b?jDKw%nn9TTn9XKMAc(`kA6trS>_b~Xa;+Xy?lA8LMD=%JECjZzF(nw&Ow|K2P4V8^g3774?ee@a2 z(=5*Tv0D+8_%FcO{+TUSl7er0x_?b9^TS-y^9hfuHt2B*)ml zK@@_sXfn%ozwZ*zp2*of88FHIxS`qP3`NdxlBp`U;c?NLz7ZO(DIM>*qhGMgFTcF) z>jyV<<~fCUH0*eBFGJEY(bEg5NL3B`J*wukUObVv^0F$~!~wqbczAL&`s%ilZ>{#t z|CXiQaKyE8i{^Z)2^9JV&-lxI@-V}pn)KHCTxrvN@~_RZ?WRvQ=J~^O`61|0p=V!? z{|c(;DPInTj2Y4VZ=$~lME$BK6bA9k4N9cX#uHGJx1|Rc zmYpwSK(6##uZXW~M>f-X4<0M|Qdvsb*cw)LDr2afU+G<$k{c8{GFQB~d+~W@%%H)t zASkhaTtxB5@)e?3jZi{uzHbsO;m!CirqwI2##*CpLsMxcvBmfwyi#(>=)@7oh<-ni ztRAdh^FpzXX_?;GIJ&{70bL=c#GAR4AufbfYv)_z=@gX(n+e=O7G)>jkYSsQbNheQ zfZssCW3*7&Bwr%9v+s-@ut&HIXXLWOmW63EF+u#yv3gIg7ha>vayHnp$JFW3#x*wx zV@x!|BIDDQM(v>QvOUXHGVJ!CKE6~(L;O_f2D~ee0OzDs+|(jKbw+Ki2TWG?0YsXz zrp1Qb@dNkW@#%(e5ssgRFI*ZLL0jLV^sS$TW(%C1A*l6Stf{&;Sy^Kk^Ms6q<)~k8 z8AL*y>OJbl#_WZi`IAxcIO_JC&^}KxYj>lg!b{&v{vqLHreL2PJ4ho7?WPUhLPgT& z*0Le4+wKIIg43XiJ)liu(#w3Sj_{4nhx*H(^RV2?7nUHh>0JM`r=1?1RQ|M3{5yiQ z;K@P?_SGP#gpXoBMfHTJj);w`1PjCu!?i&C7fr+Upq5Bz7b0xtiLz6fjK>eW6?n;M zf^o{peXUUM?CWPLNKD*~v;>F+FKyAhL{0EQOQ|mf67Nths2ZOnzcXS$I3UvGIY75~Q znN=*iVcH_;aiyVw9u|a8mKoUl17=^(X8q1ma8xYNqR7U1Xeot=dS#macm!w4`!FM9 zi1D1eBDHv5)Z0MsDmppF_KEo-m2o~@q`2KAhjYblwmL9iZm>T-`(+D~@txLZPUY-) zUcfTKja#C21n4D&$z*=_zFYluDMFM~(EQcIeDpSp7;m(%#DXgz-QklpWUAT6jK