Skip to content
Snippets Groups Projects
Unverified Commit cae29835 authored by Jack Reed's avatar Jack Reed Committed by GitHub
Browse files

Merge pull request #3394 from UCLALibrary/static-thumbnail-selection

Support two additional modes of canvas thumbnail specification
parents 057b6eea cfe94aae
Branches
No related tags found
No related merge requests found
...@@ -72,11 +72,6 @@ describe('IIIFThumbnail', () => { ...@@ -72,11 +72,6 @@ describe('IIIFThumbnail', () => {
expect(wrapper.find('img').props().style).toMatchObject({ height: 60, width: 50 }); expect(wrapper.find('img').props().style).toMatchObject({ height: 60, width: 50 });
}); });
it('relaxes constraints when the image dimensions are unknown', () => {
wrapper = createWrapper({ thumbnail: { url } });
expect(wrapper.find('img').props().style).toMatchObject({ height: 'auto', width: 'auto' });
});
it('constrains what it can when the image dimensions are unknown', () => { it('constrains what it can when the image dimensions are unknown', () => {
wrapper = createWrapper({ maxHeight: 90, thumbnail: { height: 120, url } }); wrapper = createWrapper({ maxHeight: 90, thumbnail: { height: 120, url } });
expect(wrapper.find('img').props().style).toMatchObject({ height: 90, width: 'auto' }); expect(wrapper.find('img').props().style).toMatchObject({ height: 90, width: 'auto' });
......
import { ManifestResource, Resource, Utils } from 'manifesto.js/dist-esmodule'; import {
import getThumbnail from '../../../src/lib/ThumbnailFactory'; ManifestResource, Resource, Service, Utils,
} from 'manifesto.js/dist-esmodule';
import getThumbnail, { ThumbnailFactory } from '../../../src/lib/ThumbnailFactory';
import fixture from '../../fixtures/version-2/019.json'; import fixture from '../../fixtures/version-2/019.json';
const manifest = Utils.parseManifest(fixture); const manifest = Utils.parseManifest(fixture);
const canvas = manifest.getSequences()[0].getCanvases()[0]; const canvas = manifest.getSequences()[0].getCanvases()[0];
/** */ /** */
function createSubject(jsonld, iiifOpts) { function createSubject(jsonld, resourceType, iiifOpts) {
if (resourceType === 'Image') {
return createImageSubject(jsonld, iiifOpts);
}
return getThumbnail(new ManifestResource(jsonld, {}), iiifOpts); return getThumbnail(new ManifestResource(jsonld, {}), iiifOpts);
} }
...@@ -36,38 +41,48 @@ describe('getThumbnail', () => { ...@@ -36,38 +41,48 @@ describe('getThumbnail', () => {
const iiifLevel0Service = iiifService(url, {}, { profile: 'level0' }); const iiifLevel0Service = iiifService(url, {}, { profile: 'level0' });
const iiifLevel1Service = iiifService(url, { height: 2000, width: 1000 }, { profile: 'level1' }); const iiifLevel1Service = iiifService(url, { height: 2000, width: 1000 }, { profile: 'level1' });
const iiifLevel2Service = iiifService(url, { height: 2000, width: 1000 }, { profile: 'level2' }); const iiifLevel2Service = iiifService(url, { height: 2000, width: 1000 }, { profile: 'level2' });
const sizes = [
{ height: 25, width: 25 },
{ height: 100, width: 100 },
{ height: 125, width: 125 },
{ height: 1000, width: 1000 },
];
describe('with a IIIF resource', () => {
for (const type of ['Collection', 'Manifest', 'Canvas', 'Image']) {
describe('with a thumbnail', () => { describe('with a thumbnail', () => {
it('return the thumbnail and metadata', () => { it('return the thumbnail and metadata', () => {
expect(createSubject({ '@id': 'xyz', '@type': 'Whatever', thumbnail: { '@id': url, height: 70, width: 50 } })).toMatchObject({ height: 70, url, width: 50 }); expect(createSubject({ '@id': 'xyz', '@type': type, thumbnail: { '@id': url, height: 70, width: 50 } }, type)).toMatchObject({ height: 70, url, width: 50 });
}); });
it('return the IIIF service of the thumbnail', () => { it('return the IIIF service of the thumbnail', () => {
expect(createSubject({ '@id': 'xyz', '@type': 'Whatever', thumbnail: iiifLevel1Service })).toMatchObject({ url: `${url}/full/,120/0/default.jpg` }); expect(createSubject({ '@id': 'xyz', '@type': type, thumbnail: iiifLevel1Service }, type)).toMatchObject({ url: `${url}/full/,120/0/default.jpg` });
}); });
describe('with image size constraints', () => { describe('with image size constraints', () => {
it('does nothing with a static resource', () => { it('does nothing with a static resource', () => {
expect(createSubject({ '@id': 'xyz', '@type': 'Whatever', thumbnail: { '@id': url } }, { maxWidth: 50 })).toMatchObject({ url }); expect(createSubject({ '@id': 'xyz', '@type': type, thumbnail: { '@id': url } }, type, { maxWidth: 50 })).toMatchObject({ url });
}); });
it('does nothing with a IIIF level 0 service', () => { it('does nothing with a IIIF level 0 service', () => {
expect(createSubject({ '@id': 'xyz', '@type': 'Whatever', thumbnail: iiifLevel0Service }, { maxWidth: 50 })).toMatchObject({ url: 'arbitrary-url' }); expect(createSubject({ '@id': 'xyz', '@type': type, thumbnail: iiifLevel0Service }, type, { maxWidth: 50 })).toMatchObject({ url: 'arbitrary-url' });
}); });
it('calculates constraints for a IIIF level 1 service', () => { it('calculates constraints for a IIIF level 1 service', () => {
expect(createSubject({ '@id': 'xyz', '@type': 'Whatever', thumbnail: iiifLevel1Service }, { maxWidth: 150 })).toMatchObject({ height: 300, url: `${url}/full/150,/0/default.jpg`, width: 150 }); expect(createSubject({ '@id': 'xyz', '@type': type, thumbnail: iiifLevel1Service }, type, { maxWidth: 150 })).toMatchObject({ height: 300, url: `${url}/full/150,/0/default.jpg`, width: 150 });
}); });
it('calculates constraints for a IIIF level 2 service', () => { it('calculates constraints for a IIIF level 2 service', () => {
expect(createSubject({ '@id': 'xyz', '@type': 'Whatever', thumbnail: iiifLevel2Service }, { maxHeight: 200, maxWidth: 150 })).toMatchObject({ height: 200, url: `${url}/full/!150,200/0/default.jpg`, width: 100 }); expect(createSubject({ '@id': 'xyz', '@type': type, thumbnail: iiifLevel2Service }, type, { maxHeight: 200, maxWidth: 150 })).toMatchObject({ height: 200, url: `${url}/full/!150,200/0/default.jpg`, width: 100 });
}); });
it('applies a minumum size to image constraints to encourage asset reuse', () => { it('applies a minumum size to image constraints to encourage asset reuse', () => {
expect(createSubject({ '@id': 'xyz', '@type': 'Whatever', thumbnail: iiifLevel2Service }, { maxHeight: 100, maxWidth: 100 })).toMatchObject({ height: 120, url: `${url}/full/!120,120/0/default.jpg`, width: 60 }); expect(createSubject({ '@id': 'xyz', '@type': type, thumbnail: iiifLevel2Service }, type, { maxHeight: 100, maxWidth: 100 })).toMatchObject({ height: 120, url: `${url}/full/!120,120/0/default.jpg`, width: 60 });
}); });
}); });
}); });
}
});
describe('with an image resource', () => { describe('with an image resource', () => {
describe('without a IIIF service', () => { describe('without a IIIF service', () => {
...@@ -86,12 +101,6 @@ describe('getThumbnail', () => { ...@@ -86,12 +101,6 @@ describe('getThumbnail', () => {
}); });
it('uses embedded sizes to find an appropriate size', () => { it('uses embedded sizes to find an appropriate size', () => {
const sizes = [
{ height: 25, width: 25 },
{ height: 100, width: 100 },
{ height: 125, width: 125 },
{ height: 1000, width: 1000 },
];
const obj = { const obj = {
...(iiifService('some-url', {}, { profile: 'level0', sizes })), ...(iiifService('some-url', {}, { profile: 'level0', sizes })),
id: 'xyz', id: 'xyz',
...@@ -125,15 +134,55 @@ describe('getThumbnail', () => { ...@@ -125,15 +134,55 @@ describe('getThumbnail', () => {
describe('with a canvas', () => { describe('with a canvas', () => {
it('uses the thumbnail', () => { it('uses the thumbnail', () => {
expect(createSubject({ ...canvas.__jsonld, thumbnail: { ...iiifLevel1Service } })).toMatchObject({ url: `${url}/full/,120/0/default.jpg` }); expect(createSubject({ ...canvas.__jsonld, thumbnail: { ...iiifLevel1Service } }, 'Canvas')).toMatchObject({ url: `${url}/full/,120/0/default.jpg` });
}); });
it('uses the first image resource', () => { it('uses the first image resource', () => {
expect(getThumbnail(canvas)).toMatchObject({ url: 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/,120/0/default.jpg' }); expect(getThumbnail(canvas)).toMatchObject({ url: 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/,120/0/default.jpg' });
}); });
it('uses the width and height of a thumbnail without a IIIF Image API service', () => {
const myCanvas = {
...canvas.__jsonld,
thumbnail: {
height: 240,
id: 'arbitrary-url',
width: 180,
},
};
expect(createSubject(myCanvas, 'Canvas')).toMatchObject({ height: 240, url: 'arbitrary-url', width: 180 });
});
it('uses embedded sizes of a IIIF Image API service to find an appropriate size', () => {
const myCanvas = {
...canvas.__jsonld,
thumbnail: {
height: 100,
id: 'arbitrary-url',
service: [{
id: url,
profile: 'level2',
sizes,
type: 'ImageService3',
}],
width: 100,
},
};
expect(createSubject(myCanvas, 'Canvas', { maxHeight: 120, maxWidth: 120 }))
.toMatchObject({ height: 125, url: `${url}/full/125,125/0/default.jpg`, width: 125 });
});
}); });
describe('with a manifest', () => { describe('with a manifest', () => {
it('does nothing with a plain URL', () => {
const manifestWithThumbnail = Utils.parseManifest({
...manifest.__jsonld,
thumbnail: url,
});
expect(getThumbnail(manifestWithThumbnail, { maxWidth: 50 })).toMatchObject({ url });
});
it('uses the thumbnail', () => { it('uses the thumbnail', () => {
const manifestWithThumbnail = Utils.parseManifest({ const manifestWithThumbnail = Utils.parseManifest({
...manifest.__jsonld, ...manifest.__jsonld,
...@@ -198,3 +247,42 @@ describe('getThumbnail', () => { ...@@ -198,3 +247,42 @@ describe('getThumbnail', () => {
}); });
}); });
}); });
describe('selectBestImageSize', () => {
const targetWidth = 120;
const targetHeight = 120;
it('selects the smallest size larger than the target, if one is available', () => {
const sizes = [
{ height: 75, width: 75 },
{ height: 150, width: 150 },
{ height: 300, width: 300 },
];
const service = new Service({
id: 'arbitrary-url',
profile: 'level0',
sizes,
type: 'ImageService3',
});
expect(ThumbnailFactory.selectBestImageSize(service, targetWidth * targetHeight))
.toEqual(sizes[1]);
});
it('selects the largest size smaller than the target, if none larger are available', () => {
const sizes = [
{ height: 25, width: 25 },
{ height: 50, width: 50 },
{ height: 75, width: 75 },
];
const service = new Service({
id: 'arbitrary-url',
profile: 'level0',
sizes,
type: 'ImageService3',
});
expect(ThumbnailFactory.selectBestImageSize(service, targetWidth * targetHeight))
.toEqual(sizes[2]);
});
});
...@@ -98,6 +98,12 @@ export class IIIFThumbnail extends Component { ...@@ -98,6 +98,12 @@ export class IIIFThumbnail extends Component {
styleProps.height = maxHeight; styleProps.height = maxHeight;
} else if (!thumbHeight && thumbWidth) { } else if (!thumbHeight && thumbWidth) {
styleProps.width = maxWidth; styleProps.width = maxWidth;
} else {
// The thumbnail wasn't retrieved via an Image API service,
// and its dimensions are not specified in the JSON-LD
// (note that this may result in a blurry image)
styleProps.width = maxWidth;
styleProps.height = maxHeight;
} }
return { return {
......
...@@ -67,47 +67,32 @@ class ThumbnailFactory { ...@@ -67,47 +67,32 @@ class ThumbnailFactory {
} }
/** /**
* Creates a canonical image request for a thumb * Selects the image resource that is representative of the given canvas.
* @param {Number} height * @param {Object} canvas A Manifesto Canvas
* @return {Object} A Manifesto Image Resource
*/ */
iiifThumbnailUrl(resource) { static getPreferredImage(canvas) {
let size; const miradorCanvas = new MiradorCanvas(canvas);
let width; return miradorCanvas.iiifImageResources[0] || miradorCanvas.imageResource;
let height; }
const minDimension = 120;
let maxHeight = minDimension;
let maxWidth = minDimension;
const { maxHeight: requestedMaxHeight, maxWidth: requestedMaxWidth } = this.iiifOpts;
if (requestedMaxHeight) maxHeight = Math.max(requestedMaxHeight, minDimension);
if (requestedMaxWidth) maxWidth = Math.max(requestedMaxWidth, minDimension);
const service = iiifImageService(resource);
if (!service) return undefined;
const aspectRatio = resource.getWidth()
&& resource.getHeight()
&& (resource.getWidth() / resource.getHeight());
// just bail to a static image, even though sizes might provide something better /**
if (isLevel0ImageProfile(service)) { * Chooses the best available image size based on a target area (w x h) value.
* @param {Object} service A IIIF Image API service that has a `sizes` array
* @param {Number} targetArea The target area value to compare potential sizes against
* @return {Object|undefined} The best size, or undefined if none are acceptable
*/
static selectBestImageSize(service, targetArea) {
const sizes = asArray(service.getProperty('sizes')); const sizes = asArray(service.getProperty('sizes'));
const serviceHeight = service.getProperty('height');
const serviceWidth = service.getProperty('width');
const target = (requestedMaxWidth && requestedMaxHeight)
? requestedMaxWidth * requestedMaxHeight
: maxHeight * maxWidth;
let closestSize = { let closestSize = {
default: true, default: true,
height: serviceHeight || Number.MAX_SAFE_INTEGER, height: service.getProperty('height') || Number.MAX_SAFE_INTEGER,
width: serviceWidth || Number.MAX_SAFE_INTEGER, width: service.getProperty('width') || Number.MAX_SAFE_INTEGER,
}; };
/** Compare the total image area to our target */ /** Compare the total image area to our target */
const imageFitness = (test) => test.width * test.height - target; const imageFitness = (test) => test.width * test.height - targetArea;
/** Look for the size that's just bigger than we prefer... */ /** Look for the size that's just bigger than we prefer... */
closestSize = sizes.reduce( closestSize = sizes.reduce(
...@@ -123,7 +108,7 @@ class ThumbnailFactory { ...@@ -123,7 +108,7 @@ class ThumbnailFactory {
); );
/** .... but not "too" big; we'd rather scale up an image than download too much */ /** .... but not "too" big; we'd rather scale up an image than download too much */
if (closestSize.width * closestSize.height > target * 6) { if (closestSize.width * closestSize.height > targetArea * 6) {
closestSize = sizes.reduce( closestSize = sizes.reduce(
(best, test) => ( (best, test) => (
Math.abs(imageFitness(test)) < Math.abs(imageFitness(best)) Math.abs(imageFitness(test)) < Math.abs(imageFitness(best))
...@@ -133,14 +118,50 @@ class ThumbnailFactory { ...@@ -133,14 +118,50 @@ class ThumbnailFactory {
); );
} }
/** Bail if the best available size is the full size.. maybe we'll get lucky with the @id */ if (closestSize.default) return undefined;
if (closestSize.default && !serviceHeight && !serviceWidth) {
return ThumbnailFactory.staticImageUrl(resource); return closestSize;
} }
/**
* Determines the appropriate thumbnail to use to represent an Image Resource.
* @param {Object} resource The Image Resource from which to derive a thumbnail
* @return {Object} The thumbnail URL and any spatial dimensions that can be determined
*/
iiifThumbnailUrl(resource) {
let size;
let width;
let height;
const minDimension = 120;
let maxHeight = minDimension;
let maxWidth = minDimension;
const { maxHeight: requestedMaxHeight, maxWidth: requestedMaxWidth } = this.iiifOpts;
if (requestedMaxHeight) maxHeight = Math.max(requestedMaxHeight, minDimension);
if (requestedMaxWidth) maxWidth = Math.max(requestedMaxWidth, minDimension);
const service = iiifImageService(resource);
if (!service) return ThumbnailFactory.staticImageUrl(resource);
const aspectRatio = resource.getWidth()
&& resource.getHeight()
&& (resource.getWidth() / resource.getHeight());
const target = (requestedMaxWidth && requestedMaxHeight)
? requestedMaxWidth * requestedMaxHeight
: maxHeight * maxWidth;
const closestSize = ThumbnailFactory.selectBestImageSize(service, target);
if (closestSize) {
// Embedded service advertises an appropriate size
width = closestSize.width; width = closestSize.width;
height = closestSize.height; height = closestSize.height;
size = `${width},${height}`; size = `${width},${height}`;
} else if (isLevel0ImageProfile(service)) {
/** Bail if the best available size is the full size.. maybe we'll get lucky with the @id */
if (!service.getProperty('height') && !service.getProperty('width')) {
return ThumbnailFactory.staticImageUrl(resource);
}
} else if (requestedMaxHeight && requestedMaxWidth) { } else if (requestedMaxHeight && requestedMaxWidth) {
// IIIF level 2, no problem. // IIIF level 2, no problem.
if (isLevel2ImageProfile(service)) { if (isLevel2ImageProfile(service)) {
...@@ -184,77 +205,76 @@ class ThumbnailFactory { ...@@ -184,77 +205,76 @@ class ThumbnailFactory {
}; };
} }
/** */ /**
getThumbnail(resource, { requireIiif, quirksMode }) { * Determines the content resource from which to derive a thumbnail to represent a given resource.
if (!resource) return undefined; * This method is recursive.
const thumb = resource.getThumbnail(); * @param {Object} resource A IIIF resource to derive a thumbnail from
if (thumb && iiifImageService(thumb)) return this.iiifThumbnailUrl(thumb); * @return {Object|undefined} The Image Resource to derive a thumbnail from, or undefined
* if no appropriate resource exists
if (requireIiif) return undefined; */
if (thumb && typeof thumb.__jsonld !== 'string') return ThumbnailFactory.staticImageUrl(thumb); getSourceContentResource(resource) {
const thumbnail = resource.getThumbnail();
if (!quirksMode) return undefined;
// Any resource type may have a thumbnail
return (thumb && typeof thumb.__jsonld === 'string') ? { url: thumb.__jsonld } : undefined; if (thumbnail) {
if (typeof thumbnail.__jsonld === 'string') return thumbnail.__jsonld;
// Prefer an image's ImageService over its image's thumbnail
// Note that Collection, Manifest, and Canvas don't have `getType()`
if (!resource.isCollection() && !resource.isManifest() && !resource.isCanvas()) {
if (resource.getType() === 'image' && iiifImageService(resource) && !iiifImageService(thumbnail)) {
return resource;
} }
/** */
getResourceThumbnail(resource) {
const thumb = this.getThumbnail(resource, { requireIiif: true });
if (thumb) return thumb;
if (iiifImageService(resource)) return this.iiifThumbnailUrl(resource);
if (['image', 'dctypes:Image'].includes(resource.getProperty('type'))) return ThumbnailFactory.staticImageUrl(resource);
return this.getThumbnail(resource, { quirksMode: true, requireIiif: false });
} }
/** */ return thumbnail;
getIIIFThumbnail(canvas) { }
const thumb = this.getThumbnail(canvas, { requireIiif: true });
if (thumb) return thumb;
const miradorCanvas = new MiradorCanvas(canvas);
const preferredCanvasResource = miradorCanvas.iiifImageResources[0] if (resource.isCollection()) {
|| canvas.imageResource; const firstManifest = resource.getManifests()[0];
if (firstManifest) return this.getSourceContentResource(firstManifest);
return (preferredCanvasResource && this.getResourceThumbnail(preferredCanvasResource)) return undefined;
|| this.getThumbnail(canvas, { quirksMode: true, requireIiif: false });
} }
/** */ if (resource.isManifest()) {
getManifestThumbnail(manifest) { const miradorManifest = new MiradorManifest(resource);
const thumb = this.getThumbnail(manifest, { requireIiif: true });
if (thumb) return thumb;
const miradorManifest = new MiradorManifest(manifest);
const canvas = miradorManifest.startCanvas || miradorManifest.canvasAt(0); const canvas = miradorManifest.startCanvas || miradorManifest.canvasAt(0);
if (canvas) return this.getSourceContentResource(canvas);
return (canvas && this.getIIIFThumbnail(canvas)) return undefined;
|| this.getThumbnail(manifest, { quirksMode: true, requireIiif: false });
} }
/** */ if (resource.isCanvas()) {
getCollectionThumbnail(collection) { const image = ThumbnailFactory.getPreferredImage(resource);
const thumb = this.getThumbnail(collection, { requireIiif: true }); if (image) return this.getSourceContentResource(image);
if (thumb) return thumb;
const firstManifest = this.resource.getManifests()[0]; return undefined;
}
return (firstManifest && this.getManifestThumbnail(firstManifest)) if (resource.getType() === 'image') {
|| this.getThumbnail(collection, { quirksMode: true, requireIiif: false }); return resource;
} }
/** */ return undefined;
}
/**
* Gets a thumbnail representing the resource.
* @return {Object|undefined} A thumbnail representing the resource, or undefined if none could
* be determined
*/
get() { get() {
if (!this.resource) return undefined; if (!this.resource) return undefined;
if (this.resource.isCanvas()) return this.getIIIFThumbnail(this.resource); // Determine which content resource we should use to derive a thumbnail
if (this.resource.isManifest()) return this.getManifestThumbnail(this.resource); const sourceContentResource = this.getSourceContentResource(this.resource);
if (this.resource.isCollection()) return this.getCollectionThumbnail(this.resource); if (!sourceContentResource) return undefined;
return this.getResourceThumbnail(this.resource, { requireIiif: true });
// Special treatment for external resources
if (typeof sourceContentResource === 'string') return { url: sourceContentResource };
return this.iiifThumbnailUrl(sourceContentResource);
} }
} }
...@@ -263,4 +283,4 @@ function getBestThumbnail(resource, iiifOpts) { ...@@ -263,4 +283,4 @@ function getBestThumbnail(resource, iiifOpts) {
return new ThumbnailFactory(resource, iiifOpts).get(); return new ThumbnailFactory(resource, iiifOpts).get();
} }
export default getBestThumbnail; export { getBestThumbnail as default, ThumbnailFactory };
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment