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();