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:
+
+
Display image
+
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;