Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/mediatypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` elements with scripts and external resources disabled.
*
* @type {Array.<string>}
*/
Expand All @@ -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',
];

/**
Expand Down
2 changes: 1 addition & 1 deletion src/reference.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 18 additions & 4 deletions src/stac.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions tests/item.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
Expand Down
3 changes: 3 additions & 0 deletions tests/link.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down