diff --git a/examples/iiif.html b/examples/iiif.html new file mode 100644 index 0000000000..ced6514fb5 --- /dev/null +++ b/examples/iiif.html @@ -0,0 +1,16 @@ +--- +layout: example.html +title: IIIF Image API +shortdesc: Example of a IIIF Image API source. +docs: > + Example of a tile source for an International Image Interoperability Framework (IIIF) Image Service. + Try any Image API version 1 or 2 service. +tags: "IIIF, IIIF Image API, tile source" +--- +
+
+
 
+ Enter info.json URL: + + +
diff --git a/examples/iiif.js b/examples/iiif.js new file mode 100644 index 0000000000..e184ca037f --- /dev/null +++ b/examples/iiif.js @@ -0,0 +1,48 @@ +import Map from '../src/ol/Map.js'; +import View from '../src/ol/View.js'; +import TileLayer from '../src/ol/layer/Tile.js'; +import IIIF from '../src/ol/source/IIIF.js'; +import IIIFInfo from '../src/ol/format/IIIFInfo.js'; + +const layer = new TileLayer(), + map = new Map({ + layers: [layer], + target: 'map' + }); + +const notifyDiv = document.getElementById('iiif-notification'); + +function refreshMap(imageInfoUrl) { + fetch(imageInfoUrl).then(function(response) { + response.json().then(function(imageInfo) { + const options = new IIIFInfo().readFromJson(imageInfo); + if (options === undefined || options.version === undefined) { + notifyDiv.textContent = 'Data seems to be no valid IIIF image information.'; + return; + } + const extent = [0, -options.size[1], options.size[0], 0]; + layer.setSource(new IIIF(options)); + map.setView(new View({ + resolutions: layer.getSource().getTileGrid().getResolutions(), + extent: extent, + constrainOnlyCenter: true + })); + map.getView().fit(extent); + notifyDiv.textContent = ''; + }).catch(function(body) { + notifyDiv.textContent = 'Could not read image info json. ' + body; + }); + }).catch(function() { + notifyDiv.textContent = 'Could not read data from URL.'; + }); +} + +const urlInput = document.getElementById('imageInfoUrl'); +const displayButton = document.getElementById('display'); + +displayButton.addEventListener('click', function() { + const imageInfoUrl = urlInput.value; + refreshMap(imageInfoUrl); +}); + +refreshMap(urlInput.value); diff --git a/src/ol/format.js b/src/ol/format.js index a151e9e415..e18ba77069 100644 --- a/src/ol/format.js +++ b/src/ol/format.js @@ -7,6 +7,7 @@ export {default as GeoJSON} from './format/GeoJSON.js'; export {default as GML} from './format/GML.js'; export {default as GPX} from './format/GPX.js'; export {default as IGC} from './format/IGC.js'; +export {default as IIIFInfo} from './format/IIIFInfo.js'; export {default as KML} from './format/KML.js'; export {default as MVT} from './format/MVT.js'; export {default as OWS} from './format/OWS.js'; diff --git a/src/ol/format/IIIFInfo.js b/src/ol/format/IIIFInfo.js new file mode 100644 index 0000000000..d88db27d22 --- /dev/null +++ b/src/ol/format/IIIFInfo.js @@ -0,0 +1,266 @@ +/** + * @module ol/format/IIIFInfo + */ + +/** + * Supported image formats, qualities and region / size calculation features + * for different image API versions and compliance levels + * @const + * @type {Object>>} + */ +const IIIF_PROFILE_VALUES = { + version1: { + level0: { + features: [], + formats: [], + qualities: ['native'] + }, + level1: { + features: ['regionByPx', 'sizeByW', 'sizeByH', 'sizeByPct'], + formats: ['jpg'], + qualities: ['native'] + }, + level2: { + features: ['regionByPx', 'regionByPct', 'sizeByW', 'sizeByH', 'sizeByPct', + 'sizeByConfinedWh', 'sizeByWh'], + formats: ['jpg', 'png'], + qualities: ['native', 'color', 'grey', 'bitonal'] + } + }, + version2: { + level0: { + features: [], + formats: ['jpg'], + qualities: ['default'] + }, + level1: { + features: ['regionByPx', 'sizeByW', 'sizeByH', 'sizeByPct'], + formats: ['jpg'], + qualities: ['default'] + }, + level2: { + features: ['regionByPx', 'regionByPct', 'sizeByW', 'sizeByH', 'sizeByPct', + 'sizeByConfinedWh', 'sizeByDistortedWh', 'sizeByWh'], + formats: ['jpg', 'png'], + qualities: ['default', 'bitonal'] + } + }, + version3: { + level0: { + features: [], + formats: ['jpg'], + qualities: ['default'] + }, + level1: { + features: ['regionByPx', 'regionSquare', 'sizeByW', 'sizeByH'], + formats: ['jpg'], + qualities: ['default'] + }, + level2: { + features: ['regionByPx', 'regionSquare', 'regionByPct', + 'sizeByW', 'sizeByH', 'sizeByPct', 'sizeByConfinedWh', 'sizeByWh'], + formats: ['jpg'], + qualities: ['default', 'bitonal'] + } + }, + none: { + features: [], + formats: [], + qualities: [] + } +}; + +export const Versions = { + VERSION1: 'version1', + VERSION2: 'version2', + VERSION3: 'version3' +}; + +function getComplianceLevelOfImageInfoForVersion(imageInfo, version) { + switch (version) { + case Versions.VERSION1: + case Versions.VERSION3: + return imageInfo.profile; + case Versions.VERSION2: + if (typeof imageInfo.profile === 'string') { + return imageInfo.profile; + } + if (Array.isArray(imageInfo.profile) && imageInfo.profile.length > 0 + && typeof imageInfo.profile[0] === 'string') { + return imageInfo.profile[0]; + } + // TODO error: cannot get compliance level URL / string + break; + default: + // TODO error: invalid Image API version + } +} + +function getVersionOfImageInfo(imageInfo) { + const context = imageInfo['@context'] || undefined; + switch (context) { + case 'http://library.stanford.edu/iiif/image-api/1.1/context.json': + case 'http://iiif.io/api/image/1/context.json': + return Versions.VERSION1; + case 'http://iiif.io/api/image/2/context.json': + return Versions.VERSION2; + case 'http://iiif.io/api/image/3/context.json': + return Versions.VERSION3; + case undefined: + // Image API 1.0 has no '@context' + if (getComplianceLevelOfImageInfoForVersion(imageInfo, Versions.VERSION1)) { + return Versions.VERSION1; + } + // TODO error: can't detect Image API version + break; + default: + // TODO error: can't detect Image API version + } +} + +function getLevelProfileForImageInfo(imageInfo) { + const version = getVersionOfImageInfo(imageInfo), + complianceLevel = getComplianceLevelOfImageInfoForVersion(imageInfo, version); + let level; + if (version === undefined || complianceLevel === undefined) { + return IIIF_PROFILE_VALUES.none; + } + level = complianceLevel.match(/level[0-2](\.json)?$/g); + level = Array.isArray(level) ? level[0].replace('.json', '') : 'none'; + return IIIF_PROFILE_VALUES[version][level]; +} + +function generateVersion1Options(imageInfo) { + const levelProfile = getLevelProfileForImageInfo(imageInfo); + return { + url: imageInfo['@id'].replace(/\/?(info.json)?$/g, ''), + features: levelProfile.features, + formats: [...levelProfile.formats, imageInfo.formats === undefined ? + [] : imageInfo.formats + ], + qualities: [...levelProfile.qualities, imageInfo.qualities === undefined ? + [] : imageInfo.qualities + ], + resolutions: imageInfo.scale_factors, + tileSize: imageInfo.tile_width !== undefined ? imageInfo.tile_height != undefined ? + [imageInfo.tile_width, imageInfo.tile_height] : [imageInfo.tile_width, imageInfo.tile_width] : + imageInfo.tile_height != undefined ? [imageInfo.tile_height, imageInfo.tile_height] : undefined + }; +} + +function generateVersion2Options(imageInfo) { + const levelProfile = getLevelProfileForImageInfo(imageInfo), + additionalProfile = Array.isArray(imageInfo.profile) && imageInfo.profile.length > 1, + profileFeatures = additionalProfile && imageInfo.profile[1].supports ? imageInfo.profile[1].supports : [], + profileFormats = additionalProfile && imageInfo.profile[1].formats ? imageInfo.profile[1].formats : [], + profileQualities = additionalProfile && imageInfo.profile[1].qualities ? imageInfo.profile[1].qualities : [], + attributions = []; + if (imageInfo.attribution !== undefined) { + // TODO potentially dangerous + attributions.push(imageInfo.attribution); + } + if (imageInfo.license !== undefined) { + let license = imageInfo.license; + if (license.match(/^http(s)?:\/\//g)) { + license = '' + encodeURI(license) + ''; + } + // TODO potentially dangerous + attributions.push(license); + } + return { + url: imageInfo['@id'].replace(/\/?(info.json)?$/g, ''), + sizes: imageInfo.sizes === undefined ? undefined : imageInfo.sizes.map(function(size) { + return [size.width, size.height]; + }), + tileSize: imageInfo.tiles === undefined ? undefined : [ + imageInfo.tiles.map(function(tile) { + return tile.width; + })[0], + imageInfo.tiles.map(function(tile) { + return tile.height; + })[0] + ], + resolutions: imageInfo.tiles === undefined ? undefined : + imageInfo.tiles.map(function(tile) { + return tile.scaleFactors; + })[0], + features: [...levelProfile.features, ...profileFeatures], + formats: [...levelProfile.formats, ...profileFormats], + qualities: [...levelProfile.qualities, ...profileQualities], + attributions: attributions.length == 0 ? undefined : attributions + }; +} + +function generateVersion3Options(imageInfo) { + const levelProfile = getLevelProfileForImageInfo(imageInfo); + return { + url: imageInfo['id'], + sizes: imageInfo.sizes === undefined ? undefined : imageInfo.sizes.map(function(size) { + return [size.width, size.height]; + }), + tileSize: imageInfo.tiles === undefined ? undefined : [ + imageInfo.tiles.map(function(tile) { + return tile.width; + })[0], + imageInfo.tiles.map(function(tile) { + return tile.height; + })[0] + ], + resolutions: imageInfo.tiles === undefined ? undefined : + imageInfo.tiles.map(function(tile) { + return tile.scaleFactors; + })[0], + features: imageInfo.extraFeatures === undefined ? levelProfile.features : + [...levelProfile.features, ...imageInfo.extraFeatures], + formats: imageInfo.extraFormats === undefined ? levelProfile.formats : + [...levelProfile.formats, ...imageInfo.extraFormats], + qualities: imageInfo.extraQualities === undefined ? levelProfile.qualities : + [...levelProfile.extraQualities, ...imageInfo.extraQualities], + maxWidth: undefined, + maxHeight: undefined, + maxArea: undefined, + attributions: undefined + }; +} + +const versionFunctions = {}; +versionFunctions[Versions.VERSION1] = generateVersion1Options; +versionFunctions[Versions.VERSION2] = generateVersion2Options; +versionFunctions[Versions.VERSION3] = generateVersion3Options; + +function getOptionsForImageInformation(imageInfo, preferredOptions) { + const options = preferredOptions || {}, + version = getVersionOfImageInfo(imageInfo), + optionAttributions = options.attributions ? options.attributions : [], + imageOptions = version === undefined ? undefined : versionFunctions[version](imageInfo); + if (imageOptions === undefined) { + return; + } + return { + url: options.url ? options.url : imageOptions.url, + version: version, + size: [imageInfo.width, imageInfo.height], + sizes: imageOptions.sizes, + format: imageOptions.formats.includes(options.format) ? options.format : 'jpg', + features: imageOptions.features, + quality: options.quality && imageOptions.qualities.includes(options.quality) ? + options.quality : imageOptions.qualities.includes('native') ? 'native' : 'default', + resolutions: Array.isArray(imageOptions.resolutions) ? imageOptions.resolutions.sort(function(a, b) { + return b - a; + }) : undefined, + tileSize: imageOptions.tileSize, + attributions: [ + ...optionAttributions, + ...(imageOptions.attributions === undefined ? [] : imageOptions.attributions) + ] + }; +} + +// TODO at the moment, this does not need to be a class. +class IIIFInfo { + readFromJson(imageInfo, preferredOptions) { + return getOptionsForImageInformation(imageInfo, preferredOptions); + } +} + +export default IIIFInfo; diff --git a/src/ol/source.js b/src/ol/source.js index bbf77333ea..155d039c5b 100644 --- a/src/ol/source.js +++ b/src/ol/source.js @@ -5,6 +5,7 @@ export {default as BingMaps} from './source/BingMaps.js'; export {default as CartoDB} from './source/CartoDB.js'; export {default as Cluster} from './source/Cluster.js'; +export {default as IIIF} from './source/IIIF.js'; export {default as Image} from './source/Image.js'; export {default as ImageArcGISRest} from './source/ImageArcGISRest.js'; export {default as ImageCanvas} from './source/ImageCanvas.js'; diff --git a/src/ol/source/IIIF.js b/src/ol/source/IIIF.js new file mode 100644 index 0000000000..e862c5d917 --- /dev/null +++ b/src/ol/source/IIIF.js @@ -0,0 +1,245 @@ +/** + * @module ol/source/IIIF + */ + +import {DEFAULT_TILE_SIZE} from '../tilegrid/common.js'; +import {getTopLeft} from '../extent.js'; +import {CustomTile} from './Zoomify.js'; +import {Versions} from '../format/IIIFInfo.js'; +import {assert} from '../asserts.js'; +import TileGrid from '../tilegrid/TileGrid.js'; +import TileImage from './TileImage.js'; + +/** + * @typedef {Object} Options + * @property {import("./Source.js").AttributionLike} [attributions] Attributions. + * @property {boolean} [attributionsCollapsible=true] Attributions are collapsible. + * @property {number} [cacheSize] + * @property {null|string} [crossOrigin] + * @property {import("../proj.js").ProjectionLike} [projection] + * @property {number} [tilePixelRatio] + * @property {number} [reprojectionErrorThreshold=0.5] + * @property {string} [url] Base URL of the IIIF Image service. + * This shoulf be the same as the IIIF Image @id. + * @property {import("../size.js").Size} [size] Size of the image [width, height]. + * @property {import("../size.js").Size[]} [sizes] Supported scaled image sizes. + * Content of the IIIF info.json 'sizes' property, but as array of Size objects. + * @property {import("../extent.js").Extent} [extent=[0, -height, width, 0]] + * @property {number} [transition] + * @property {number|import("../size.js").Size} [tileSize] Tile size. + * Same tile size is used for all zoom levels. If tile size is a number, + * a square tile is assumed. If the IIIF image service supports arbitrary + * tiling (sizeByH, sizeByW or sizeByPct as well as regionByPx and regionByPct + * are supported), the default tilesize is 256. + * @property {boolean} [wrapX=false] + */ + + +/** + * @classdesc + * Layer source for tile data in IIIF format. + * @api + */ +class IIIF extends TileImage { + + constructor(opt_options) { + + const options = opt_options || {}; + + let baseUrl = options.url || ''; + baseUrl = baseUrl + (baseUrl.lastIndexOf('/') === baseUrl.length - 1 || baseUrl === '' ? '' : '/'); + const version = options.version || Versions.VERSION2; + const sizes = options.sizes || []; + const size = options.size; + // TODO Appropriate error code + assert(size != undefined && Array.isArray(size) && size.length == 2 && + !isNaN(size[0]) && size[0] > 0 && !isNaN(size[1] && size[1] > 0), 999); + const width = size[0]; + const height = size[1]; + const tileSize = options.tileSize; + const format = options.format || 'jpg'; + const quality = options.quality || (options.version == Versions.VERSION1 ? 'native' : 'default'); + let resolutions = options.resolutions || []; + const features = options.features || []; + const extent = options.extent || [0, -height, width, 0]; + + const supportsListedSizes = sizes != undefined && Array.isArray(sizes) && sizes.length > 0; + const supportsListedTiles = tileSize != undefined && (Number.isInteger(tileSize) && tileSize > 0 || Array.isArray(tileSize) && tileSize.length > 0); + const supportsArbitraryTiling = features != undefined && Array.isArray(features) && + (features.includes('regionByPx') || features.includes('regionByPct')) && + (features.includes('sizeByWh') || features.includes('sizeByH') || + features.includes('sizeByW') || features.includes('sizeByPct')); + + let tileWidth, + tileHeight, + maxZoom; + + resolutions.sort(function(a, b) { + return b - a; + }); + + if (supportsListedTiles || supportsArbitraryTiling) { + if (tileSize != undefined) { + if (Number.isInteger(tileSize) && tileSize > 0) { + tileWidth = tileSize; + tileHeight = tileSize; + } else if (Array.isArray(tileSize) && tileSize.length > 0) { + if (tileSize.length == 1 || tileSize[1] == undefined && Number.isInteger(tileSize[0])) { + tileWidth = tileSize[0]; + tileHeight = tileSize[0]; + } + if (tileSize.length == 2) { + if (Number.isInteger(tileSize[0]) && Number.isInteger(tileSize[1])) { + tileWidth = tileSize[0]; + tileHeight = tileSize[1]; + } else if (tileSize[0] == undefined && Number.isInteger(tileSize[1])) { + tileWidth = tileSize[1]; + tileHeight = tileSize[1]; + } + } + } + } + if (tileWidth === undefined || tileHeight === undefined) { + tileWidth = DEFAULT_TILE_SIZE; + tileHeight = DEFAULT_TILE_SIZE; + } + if (resolutions.length == 0) { + maxZoom = Math.max( + Math.ceil(Math.log(width / tileWidth) / Math.LN2), + Math.ceil(Math.log(height / tileHeight) / Math.LN2) + ); + for (let i = maxZoom; i >= 0; i--) { + resolutions.push(Math.pow(2, i)); + } + } else { + const maxScaleFactor = Math.max([...resolutions]); + // TODO maxScaleFactor might not be a power to 2 + maxZoom = Math.round(Math.log(maxScaleFactor) / Math.LN2); + } + } else { + // No tile support. + tileWidth = width; + tileHeight = height; + resolutions = []; + if (supportsListedSizes) { + /* + * 'sizes' provided. Use full region in different resolutions. Every + * resolution has only one tile. + */ + sizes.sort(function(a, b) { + return a[0] - b[0]; + }); + for (let i = 0; i < sizes.length; i++) { + const resolution = width / sizes[i][0]; + resolutions.push(resolution); + maxZoom = i; + } + } else { + // No useful image information at all. Try pseudo tile with full image. + resolutions.push(1); + sizes.push([width, height]); + maxZoom = 0; + } + } + + const tileGrid = new TileGrid({ + tileSize: [tileWidth, tileHeight], + extent: extent, + origin: getTopLeft(extent), + resolutions: resolutions + }); + + const tileUrlFunction = function(tileCoord, pixelRatio, projection) { + let regionParam, + sizeParam; + const zoom = tileCoord[0]; + if (maxZoom < zoom) { + return; + } + const tileX = tileCoord[1], + tileY = tileCoord[2], + scale = resolutions[zoom]; + if (tileX < 0 || Math.ceil(width / scale / tileWidth) <= tileX || + tileY < 0 || Math.ceil(height / scale / tileHeight) <= tileY) { + return; + } + if (supportsArbitraryTiling || supportsListedTiles) { + const regionX = tileX * tileWidth * scale, + regionY = tileY * tileHeight * scale; + let regionW = tileWidth * scale, + regionH = tileHeight * scale, + sizeW = tileWidth, + sizeH = tileHeight; + if (regionX + regionW > width) { + regionW = width - regionX; + } + if (regionY + regionH > height) { + regionH = height - regionY; + } + if (regionX + tileWidth * scale > width) { + sizeW = Math.floor((width - regionX + scale - 1) / scale); + } + if (regionY + tileHeight * scale > height) { + sizeH = Math.floor((height - regionY + scale - 1) / scale); + } + const sizeHBySizeW = Math.round(sizeW / regionW * regionH), + sizeWBySizeH = Math.round(sizeH / regionH * regionW), + preferSizeByH = (sizeHBySizeW > sizeH) && (sizeW == sizeWBySizeH); + if (regionX == 0 && regionW == width && regionY == 0 && regionH == height) { + // canonical full image region parameter is 'full', not 'x,y,w,h' + regionParam = 'full'; + } else if (!supportsArbitraryTiling || features.includes('regionByPx')) { + regionParam = regionX + ',' + regionY + ',' + regionW + ',' + regionH; + } else if (features.includes('regionByPct')) { + const pctX = regionX / width * 100, + pctY = regionY / height * 100, + pctW = regionW / width * 100, + pctH = regionH / height * 100; + regionParam = 'pct:' + pctX + ',' + pctY + ',' + pctW + ',' + pctH; + } + if (version == Versions.VERSION3 && (!supportsArbitraryTiling || features.includes('sizeByWh'))) { + sizeParam = sizeW + ',' + sizeH; + } else if (!supportsArbitraryTiling || features.includes('sizeByW') && (!preferSizeByH || !(features.includes('sizeByH')))) { + sizeParam = sizeW + ','; + } else if (features.includes('sizeByH')) { + sizeParam = ',' + sizeH; + } else if (features.includes('sizeByWh')) { + sizeParam = sizeW + ',' + sizeH; + } else if (features.includes('sizeByPct')) { + sizeParam = 'pct:' + (100 / scale); + } + } else { + regionParam = 'full'; + if (supportsListedSizes) { + sizeParam = sizes[zoom][0] + ',' + (version == Versions.VERSION3 ? sizes[zoom][0] : ''); + } else { + sizeParam = version == Versions.VERSION3 ? 'max' : 'full'; + } + } + return baseUrl + regionParam + '/' + sizeParam + '/0/' + quality + '.' + format; + }; + + const IiifTileClass = CustomTile.bind(null, tileGrid); + + super({ + attributions: options.attributions, + attributionsCollapsible: options.attributionsCollapsible, + cacheSize: options.cacheSize, + crossOrigin: options.crossOrigin, + opaque: options.opaque, + projection: options.projection, + reprojectionErrorThreshold: options.reprojectionErrorThreshold, + state: options.state, + tileClass: IiifTileClass, + transition: options.transition, + wrapX: options.wrapX !== undefined ? options.wrapX : false, + tileGrid: tileGrid, + tilePixelRatio: options.tilePixelRatio, + tileUrlFunction: tileUrlFunction + }); + + } + +} + +export default IIIF;