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
No related branches found
No related tags found
No related merge requests found
......@@ -72,11 +72,6 @@ describe('IIIFThumbnail', () => {
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', () => {
wrapper = createWrapper({ maxHeight: 90, thumbnail: { height: 120, url } });
expect(wrapper.find('img').props().style).toMatchObject({ height: 90, width: 'auto' });
......
import { ManifestResource, Resource, Utils } from 'manifesto.js/dist-esmodule';
import getThumbnail from '../../../src/lib/ThumbnailFactory';
import {
ManifestResource, Resource, Service, Utils,
} from 'manifesto.js/dist-esmodule';
import getThumbnail, { ThumbnailFactory } from '../../../src/lib/ThumbnailFactory';
import fixture from '../../fixtures/version-2/019.json';
const manifest = Utils.parseManifest(fixture);
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);
}
......@@ -36,38 +41,48 @@ describe('getThumbnail', () => {
const iiifLevel0Service = iiifService(url, {}, { profile: 'level0' });
const iiifLevel1Service = iiifService(url, { height: 2000, width: 1000 }, { profile: 'level1' });
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', () => {
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', () => {
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', () => {
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', () => {
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', () => {
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', () => {
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', () => {
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('without a IIIF service', () => {
......@@ -86,12 +101,6 @@ describe('getThumbnail', () => {
});
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 = {
...(iiifService('some-url', {}, { profile: 'level0', sizes })),
id: 'xyz',
......@@ -125,15 +134,55 @@ describe('getThumbnail', () => {
describe('with a canvas', () => {
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', () => {
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', () => {
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', () => {
const manifestWithThumbnail = Utils.parseManifest({
...manifest.__jsonld,
......@@ -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 {
styleProps.height = maxHeight;
} else if (!thumbHeight && thumbWidth) {
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 {
......
......@@ -67,47 +67,32 @@ class ThumbnailFactory {
}
/**
* Creates a canonical image request for a thumb
* @param {Number} height
* Selects the image resource that is representative of the given canvas.
* @param {Object} canvas A Manifesto Canvas
* @return {Object} A Manifesto Image Resource
*/
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 undefined;
const aspectRatio = resource.getWidth()
&& resource.getHeight()
&& (resource.getWidth() / resource.getHeight());
static getPreferredImage(canvas) {
const miradorCanvas = new MiradorCanvas(canvas);
return miradorCanvas.iiifImageResources[0] || miradorCanvas.imageResource;
}
// 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 serviceHeight = service.getProperty('height');
const serviceWidth = service.getProperty('width');
const target = (requestedMaxWidth && requestedMaxHeight)
? requestedMaxWidth * requestedMaxHeight
: maxHeight * maxWidth;
let closestSize = {
default: true,
height: serviceHeight || Number.MAX_SAFE_INTEGER,
width: serviceWidth || Number.MAX_SAFE_INTEGER,
height: service.getProperty('height') || Number.MAX_SAFE_INTEGER,
width: service.getProperty('width') || Number.MAX_SAFE_INTEGER,
};
/** 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... */
closestSize = sizes.reduce(
......@@ -123,7 +108,7 @@ class ThumbnailFactory {
);
/** .... 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(
(best, test) => (
Math.abs(imageFitness(test)) < Math.abs(imageFitness(best))
......@@ -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 && !serviceHeight && !serviceWidth) {
return ThumbnailFactory.staticImageUrl(resource);
if (closestSize.default) return undefined;
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;
height = closestSize.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) {
// IIIF level 2, no problem.
if (isLevel2ImageProfile(service)) {
......@@ -184,77 +205,76 @@ class ThumbnailFactory {
};
}
/** */
getThumbnail(resource, { requireIiif, quirksMode }) {
if (!resource) return undefined;
const thumb = resource.getThumbnail();
if (thumb && iiifImageService(thumb)) return this.iiifThumbnailUrl(thumb);
if (requireIiif) return undefined;
if (thumb && typeof thumb.__jsonld !== 'string') return ThumbnailFactory.staticImageUrl(thumb);
if (!quirksMode) return undefined;
return (thumb && typeof thumb.__jsonld === 'string') ? { url: thumb.__jsonld } : undefined;
/**
* Determines the content resource from which to derive a thumbnail to represent a given resource.
* This method is recursive.
* @param {Object} resource A IIIF resource to derive a thumbnail from
* @return {Object|undefined} The Image Resource to derive a thumbnail from, or undefined
* if no appropriate resource exists
*/
getSourceContentResource(resource) {
const thumbnail = resource.getThumbnail();
// Any resource type may have a thumbnail
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 });
}
/** */
getIIIFThumbnail(canvas) {
const thumb = this.getThumbnail(canvas, { requireIiif: true });
if (thumb) return thumb;
const miradorCanvas = new MiradorCanvas(canvas);
return thumbnail;
}
const preferredCanvasResource = miradorCanvas.iiifImageResources[0]
|| canvas.imageResource;
if (resource.isCollection()) {
const firstManifest = resource.getManifests()[0];
if (firstManifest) return this.getSourceContentResource(firstManifest);
return (preferredCanvasResource && this.getResourceThumbnail(preferredCanvasResource))
|| this.getThumbnail(canvas, { quirksMode: true, requireIiif: false });
return undefined;
}
/** */
getManifestThumbnail(manifest) {
const thumb = this.getThumbnail(manifest, { requireIiif: true });
if (thumb) return thumb;
const miradorManifest = new MiradorManifest(manifest);
if (resource.isManifest()) {
const miradorManifest = new MiradorManifest(resource);
const canvas = miradorManifest.startCanvas || miradorManifest.canvasAt(0);
if (canvas) return this.getSourceContentResource(canvas);
return (canvas && this.getIIIFThumbnail(canvas))
|| this.getThumbnail(manifest, { quirksMode: true, requireIiif: false });
return undefined;
}
/** */
getCollectionThumbnail(collection) {
const thumb = this.getThumbnail(collection, { requireIiif: true });
if (thumb) return thumb;
if (resource.isCanvas()) {
const image = ThumbnailFactory.getPreferredImage(resource);
if (image) return this.getSourceContentResource(image);
const firstManifest = this.resource.getManifests()[0];
return undefined;
}
return (firstManifest && this.getManifestThumbnail(firstManifest))
|| this.getThumbnail(collection, { quirksMode: true, requireIiif: false });
if (resource.getType() === 'image') {
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() {
if (!this.resource) return undefined;
if (this.resource.isCanvas()) return this.getIIIFThumbnail(this.resource);
if (this.resource.isManifest()) return this.getManifestThumbnail(this.resource);
if (this.resource.isCollection()) return this.getCollectionThumbnail(this.resource);
return this.getResourceThumbnail(this.resource, { requireIiif: true });
// Determine which content resource we should use to derive a thumbnail
const sourceContentResource = this.getSourceContentResource(this.resource);
if (!sourceContentResource) return undefined;
// Special treatment for external resources
if (typeof sourceContentResource === 'string') return { url: sourceContentResource };
return this.iiifThumbnailUrl(sourceContentResource);
}
}
......@@ -263,4 +283,4 @@ function getBestThumbnail(resource, iiifOpts) {
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 to comment