diff --git a/src/mediatypes.js b/src/mediatypes.js index 5ab6b99..afd0eea 100644 --- a/src/mediatypes.js +++ b/src/mediatypes.js @@ -18,7 +18,9 @@ export const stacMediaTypes = ['application/json', geojsonMediaType, 'text/json' export const schemaMediaType = 'application/schema+json'; /** - * All image media types that Web Browsers can show (GIF, JPEG, PNG, WebP). + * All image media types that Web Browsers can show (GIF, JPEG, APNG, PNG, WebP, AVIF, SVG). + * + * SVG is included because browsers render it in `` elements with scripts and external resources disabled. * * @type {Array.} */ @@ -29,10 +31,10 @@ export const browserImageTypes = [ 'image/png', 'image/webp', 'image/avif', + 'image/svg+xml', // To be considered in the future if needed. // see also https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types //'image/bmp', - //'image/svg+xml', ]; /** diff --git a/src/reference.js b/src/reference.js index 8bc7229..703c5d4 100644 --- a/src/reference.js +++ b/src/reference.js @@ -77,7 +77,7 @@ class STACReference extends STACObject { } else if ( typeof this.type === 'undefined' && hasText(extension) && - (extension === 'jpg' || browserImageTypes.includes('image/' + extension)) + (extension === 'jpg' || extension === 'svg' || browserImageTypes.includes('image/' + extension)) ) { return true; } else { diff --git a/src/stac.js b/src/stac.js index 1a09a4f..81cd92a 100644 --- a/src/stac.js +++ b/src/stac.js @@ -4,6 +4,11 @@ import Asset from './asset.js'; import STACHypermedia from './hypermedia.js'; import { getBest } from './locales.js'; +const isSvg = (img) => + hasText(img.type) + ? isMediaType(img.type, 'image/svg+xml') + : typeof img.href === 'string' && /\.svg$/i.test(img.href.split(/[?#]/)[0]); + /** * Class for STAC spec entities (Item, Catalog and Collection). * @@ -88,6 +93,9 @@ class STAC extends STACHypermedia { * the first alternate asset that a browser can show is returned instead. * Such an asset is merged with the metadata of its parent asset (see * `Asset.fillAlternate`) and `getContext()` returns the parent asset. + * SVG assets are also replaced with a raster alternate asset, if available. + * + * SVG images are sorted after raster images. * * @param {boolean} browserOnly - Return only images that can be shown in a browser natively (PNG/JPG/GIF/WEBP + HTTP/S). * @param {string|null} prefer - If not `null` (default), prefers a role over the other. Either `thumbnail` or `overview`. @@ -115,15 +123,21 @@ class STAC extends STACHypermedia { // See https://github.com/radiantearth/stac-browser/issues/910 thumbnails = thumbnails .map((img) => { - if (img.canBrowserDisplayImage()) { + if (img.canBrowserDisplayImage() && !isSvg(img)) { return img; - } else if (img.isAsset) { - return img.getAlternates(true).find((alt) => alt.canBrowserDisplayImage()) || null; } - return null; + // Prefer a raster alternate over an SVG or non-displayable asset + const alternates = img.isAsset ? img.getAlternates(true).filter((alt) => alt.canBrowserDisplayImage()) : []; + const raster = alternates.find((alt) => !isSvg(alt)); + if (raster) { + return raster; + } + return img.canBrowserDisplayImage() ? img : alternates[0] || null; }) .filter((img) => img !== null); } + // Sort SVG images after raster images (two filters for a stable order) + thumbnails = thumbnails.filter((img) => !isSvg(img)).concat(thumbnails.filter(isSvg)); if (prefer && thumbnails.length > 1) { // Prefer one role over the other. // The two step approach with two filters ensures the same sort bevahiour across all browsers: diff --git a/tests/item.test.js b/tests/item.test.js index 89fc773..6c85995 100644 --- a/tests/item.test.js +++ b/tests/item.test.js @@ -200,6 +200,55 @@ describe('getThumbnails with alternate assets', () => { }); }); +describe('getThumbnails with SVG', () => { + const makeItem = (assets) => + new Item({ + stac_version: '1.1.0', + type: 'Feature', + id: 'svg-thumbnail', + geometry: null, + properties: { datetime: '2024-01-01T00:00:00Z' }, + links: [{ rel: 'self', href: 'https://example.com/item.json', type: 'application/geo+json' }], + assets, + }); + + test('sorts SVG after raster images', () => { + const item = makeItem({ + svg: { href: 'https://example.com/a.svg', type: 'image/svg+xml', roles: ['thumbnail'] }, + untyped: { href: 'https://example.com/b.svg?x=1', roles: ['thumbnail'] }, + png: { href: 'https://example.com/c.png', type: 'image/png', roles: ['thumbnail'] }, + jpg: { href: 'https://example.com/d.jpg', type: 'image/jpeg', roles: ['thumbnail'] }, + }); + expect(item.getThumbnails(false).map((img) => img.getKey())).toEqual(['png', 'jpg', 'svg', 'untyped']); + }); + + test('prefers a raster alternate over an SVG asset', () => { + const item = makeItem({ + thumbnail: { + href: 'https://example.com/thumbnail.svg', + type: 'image/svg+xml', + roles: ['thumbnail'], + alternate: { + png: { href: 'https://example.com/thumbnail.png', type: 'image/png' }, + }, + }, + }); + const thumbnails = item.getThumbnails(true); + expect(thumbnails.length).toBe(1); + expect(thumbnails[0].getAbsoluteUrl()).toBe('https://example.com/thumbnail.png'); + expect(thumbnails[0].getContext()).toBe(item.getAsset('thumbnail')); + // Without browserOnly the SVG asset is returned as is + expect(item.getThumbnails(false)[0].getAbsoluteUrl()).toBe('https://example.com/thumbnail.svg'); + }); + + test('keeps an SVG asset without a raster alternate', () => { + const item = makeItem({ + thumbnail: { href: 'https://example.com/thumbnail.svg', type: 'image/svg+xml', roles: ['thumbnail'] }, + }); + expect(item.getThumbnails(true)[0].getAbsoluteUrl()).toBe('https://example.com/thumbnail.svg'); + }); +}); + test('getAssets', () => { expect(item.getAssets()).toEqual(Object.values(item.assets)); }); diff --git a/tests/link.test.js b/tests/link.test.js index 8d334b2..c403009 100644 --- a/tests/link.test.js +++ b/tests/link.test.js @@ -77,12 +77,15 @@ test('canBrowserDisplayImage', () => { expect(link('image/png').canBrowserDisplayImage()).toBeTruthy(); expect(link('image/webp').canBrowserDisplayImage()).toBeTruthy(); expect(link('IMAGE/WEBP').canBrowserDisplayImage()).toBeTruthy(); + expect(link('image/svg+xml').canBrowserDisplayImage()).toBeTruthy(); expect(new Link({ href: 'https://example.com/image.jpg' }).canBrowserDisplayImage(true)).toBeTruthy(); expect(new Link({ href: 'http://example.com/image.jpg' }).canBrowserDisplayImage(true)).toBeTruthy(); expect(new Link({ href: 'HTTP://EXAMPLE.COM/IMAGE.JPEG' }).canBrowserDisplayImage(true)).toBeTruthy(); expect(new Link({ href: './image.jpg' }).canBrowserDisplayImage(true)).toBeTruthy(); expect(link('image/jpeg').canBrowserDisplayImage(true)).toBeTruthy(); + expect(new Link({ href: 'https://example.com/icon.svg' }).canBrowserDisplayImage(true)).toBeTruthy(); + expect(new Link({ href: 'https://example.com/icon.svg' }).canBrowserDisplayImage()).toBeFalsy(); expect(new Link({ href: 'https://example.com/image.bmp' }).canBrowserDisplayImage(true)).toBeFalsy(); expect(new Link({ href: 's3://example.com/image.jpg' }).canBrowserDisplayImage(true)).toBeFalsy();