diff --git a/package-lock.json b/package-lock.json index 8d6102f6..069de286 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,9 @@ "integrity": "sha512-HLK2FS5sZqxPqD53D6hhZxC6C8THTVwlyZDZ7J0iWsrB8JmMA69m/CQuNKZc1kki9WSVeck2fXna26NL0SE7cg==" }, "@iiif/vocabulary": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@iiif/vocabulary/-/vocabulary-1.0.11.tgz", - "integrity": "sha512-JjPbZ+SCn0ljsfs9Nf0U1OWNZK7tauw7iHezDJA+28AAzmMwpFS/lTOe/4N0ynZsnk4x7cA9NL6CK3K0zDd50w==" + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/@iiif/vocabulary/-/vocabulary-1.0.20.tgz", + "integrity": "sha512-cL30/fL+7D+3tJvgGNZE6jWWGe/03ooEmwIfZEezbSE8mNzJB1pKthOrERKbeoMPdk1Qc++ySPgbgeawtYiFzA==" }, "@types/minimatch": { "version": "3.0.3", @@ -2931,10 +2931,9 @@ } }, "lodash": { - "version": "4.17.19", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", - "dev": true + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, "log-symbols": { "version": "2.2.0", @@ -3418,6 +3417,11 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, + "normalize-url": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-5.3.0.tgz", + "integrity": "sha512-9/nOVLYYe/dO/eJeQUNaGUF4m4Z5E7cb9oNTKabH+bNf19mqj60txTcveQxL0GlcWLXCxkOu2/LwL8oW0idIDA==" + }, "npm-run-path": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", diff --git a/package.json b/package.json index 0f27b8bc..c88783b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "manifesto.js", - "version": "4.2.1", + "version": "4.3.0-pre-4", "description": "IIIF Presentation API utility library for client and server", "main": "./dist-commonjs/index.js", "module": "./dist-esmodule/index.js", @@ -60,8 +60,10 @@ }, "dependencies": { "@edsilv/http-status-codes": "^1.0.3", - "@iiif/vocabulary": "^1.0.11", - "isomorphic-unfetch": "^3.0.0" + "@iiif/vocabulary": "^1.0.20", + "isomorphic-unfetch": "^3.0.0", + "lodash": "^4.17.20", + "normalize-url": "^5.3.0" }, "directories": { "test": "test" diff --git a/src/Canvas.ts b/src/Canvas.ts index 88228ab1..0fc41540 100644 --- a/src/Canvas.ts +++ b/src/Canvas.ts @@ -1,4 +1,7 @@ -import { ViewingHint } from "@iiif/vocabulary/dist-commonjs"; +import { + ExternalResourceType, + ViewingHint +} from "@iiif/vocabulary/dist-commonjs"; import { Annotation, AnnotationList, @@ -11,6 +14,8 @@ import { Size, Utils } from "./internal"; +import flatten from "lodash/flatten"; +import flattenDeep from "lodash/flattenDeep"; export class Canvas extends Resource { public ranges: Range[]; @@ -259,7 +264,83 @@ export class Canvas extends Resource { getHeight(): number { return this.getProperty("height"); } + getViewingHint(): ViewingHint | null { return this.getProperty("viewingHint"); } + + get imageResources() { + const resources = flattenDeep([ + this.getImages().map(i => i.getResource()), + this.getContent().map(i => i.getBody()) + ]); + + return flatten( + resources.map(resource => { + switch (resource.getProperty("type").toLowerCase()) { + case ExternalResourceType.CHOICE: + case ExternalResourceType.OA_CHOICE: + return new Canvas( + { + images: flatten([ + resource.getProperty("default"), + resource.getProperty("item") + ]).map(r => ({ resource: r })) + }, + this.options + ) + .getImages() + .map(i => i.getResource()); + default: + return resource; + } + }) + ); + } + + get resourceAnnotations() { + return flattenDeep([this.getImages(), this.getContent()]); + } + + /** + * Returns a given resource Annotation, based on a contained resource or body + * id + */ + resourceAnnotation(id) { + return this.resourceAnnotations.find( + anno => + anno.getResource().id === id || + flatten(new Array(anno.getBody())).some(body => body.id === id) + ); + } + + /** + * Returns the fragment placement values if a resourceAnnotation is placed on + * a canvas somewhere besides the full extent + */ + onFragment(id) { + const resourceAnnotation = this.resourceAnnotation(id); + if (!resourceAnnotation) return undefined; + // IIIF v2 + const on = resourceAnnotation.getProperty("on"); + // IIIF v3 + const target = resourceAnnotation.getProperty("target"); + const fragmentMatch = (on || target).match(/xywh=(.*)$/); + if (!fragmentMatch) return undefined; + return fragmentMatch[1].split(",").map(str => parseInt(str, 10)); + } + + get iiifImageResources() { + return this.imageResources.filter( + r => r && r.getServices()[0] && r.getServices()[0].id + ); + } + + get imageServiceIds() { + return this.iiifImageResources.map(r => r.getServices()[0].id); + } + + get aspectRatio() { + return this.getWidth() / this.getHeight(); + } } diff --git a/src/CanvasWorld.ts b/src/CanvasWorld.ts new file mode 100644 index 00000000..b1fe2e77 --- /dev/null +++ b/src/CanvasWorld.ts @@ -0,0 +1,248 @@ +import { ViewingDirection } from "@iiif/vocabulary/dist-commonjs"; +import normalizeUrl from "normalize-url"; +import { Canvas } from "."; + +type CanvasDimensions = { + canvas: Canvas; + height: number; + width: number; + x: number; + y: number; +}; + +export class CanvasWorld { + public canvases: Canvas[]; + public viewingDirection: ViewingDirection; + public layers: any[]; // todo: type + private _canvasDimensions: CanvasDimensions[] | undefined; + + /** + * @param {Array} canvases - Array of Manifesto:Canvas objects to create a + * world from. + */ + constructor( + canvases: Canvas[], + layers, + viewingDirection = ViewingDirection.LEFT_TO_RIGHT + ) { + this.canvases = canvases; //.map(c => new Canvas(c)); UV has already parsed the canvases by this point + this.layers = layers; + this.viewingDirection = viewingDirection; + } + + get canvasIds() { + return this.canvases.map(canvas => canvas.id); + } + + get canvasDimensions() { + if (this._canvasDimensions) { + return this._canvasDimensions; + } + + const [dirX, dirY] = this.canvasDirection; + const scale = + dirY === 0 + ? Math.min(...this.canvases.map(c => c.getHeight())) + : Math.min(...this.canvases.map(c => c.getWidth())); + let incX = 0; + let incY = 0; + + const canvasDims = this.canvases.reduce((acc, canvas) => { + let canvasHeight = 0; + let canvasWidth = 0; + + if (!isNaN(canvas.aspectRatio)) { + if (dirY === 0) { + // constant height + canvasHeight = scale; + canvasWidth = Math.floor(scale * canvas.aspectRatio); + } else { + // constant width + canvasWidth = scale; + canvasHeight = Math.floor(scale * (1 / canvas.aspectRatio)); + } + } + + acc.push({ + canvas, + height: canvasHeight, + width: canvasWidth, + x: incX, + y: incY + }); + + incX += dirX * canvasWidth; + incY += dirY * canvasHeight; + return acc; + }, [] as CanvasDimensions[]); + + const worldHeight = dirY === 0 ? scale : Math.abs(incY); + const worldWidth = dirX === 0 ? scale : Math.abs(incX); + + this._canvasDimensions = canvasDims.reduce((acc, dims) => { + acc.push({ + ...dims, + x: dirX === -1 ? dims.x + worldWidth - dims.width : dims.x, + y: dirY === -1 ? dims.y + worldHeight - dims.height : dims.y + }); + + return acc; + }, [] as CanvasDimensions[]); + + return this._canvasDimensions; + } + + /** + * contentResourceToWorldCoordinates - calculates the contentResource coordinates + * respective to the world. + */ + contentResourceToWorldCoordinates(contentResource) { + const canvasIndex = this.canvases.findIndex(c => + c.imageResources.find(r => r.id === contentResource.id) + ); + const canvas = this.canvases[canvasIndex]; + if (!canvas) return []; + + const [x, y, w, h] = this.canvasToWorldCoordinates(canvas.id); + + const fragmentOffset = canvas.onFragment(contentResource.id); + if (fragmentOffset) { + return [ + x + fragmentOffset[0], + y + fragmentOffset[1], + fragmentOffset[2], + fragmentOffset[3] + ]; + } + return [x, y, w, h]; + } + + /** */ + canvasToWorldCoordinates(canvasId) { + const canvasDimensions = this.canvasDimensions.find( + c => c.canvas.id === canvasId + ); + + return [ + canvasDimensions!.x, + canvasDimensions!.y, + canvasDimensions!.width, + canvasDimensions!.height + ]; + } + + /** */ + get canvasDirection() { + switch (this.viewingDirection) { + case ViewingDirection.LEFT_TO_RIGHT: + return [1, 0]; + case ViewingDirection.RIGHT_TO_LEFT: + return [-1, 0]; + case ViewingDirection.TOP_TO_BOTTOM: + return [0, 1]; + case ViewingDirection.BOTTOM_TO_TOP: + return [0, -1]; + default: + return [1, 0]; + } + } + + /** Get the IIIF content resource for an image */ + contentResource(infoResponseId) { + const canvas = this.canvases.find(c => + c.imageServiceIds.some( + id => + normalizeUrl(id, { stripAuthentication: false }) === + normalizeUrl(infoResponseId, { stripAuthentication: false }) + ) + ); + if (!canvas) return undefined; + return canvas.imageResources.find( + r => + normalizeUrl(r.getServices()[0].id, { stripAuthentication: false }) === + normalizeUrl(infoResponseId, { stripAuthentication: false }) + ); + } + + /** @private */ + getLayerMetadata(contentResource) { + if (!this.layers) return undefined; + const canvas = this.canvases.find(c => + c.imageResources.find(r => r.id === contentResource.id) + ); + + if (!canvas) return undefined; + + const resourceIndex = canvas.imageResources.findIndex( + r => r.id === contentResource.id + ); + + const layer = this.layers[canvas.id]; + const imageResourceLayer = layer && layer[contentResource.id]; + + return { + index: resourceIndex, + opacity: 1, + total: canvas.imageResources.length, + visibility: true, + ...imageResourceLayer + }; + } + + /** */ + layerOpacityOfImageResource(contentResource) { + const layer = this.getLayerMetadata(contentResource); + if (!layer) return 1; + if (!layer.visibility) return 0; + + return layer.opacity; + } + + /** */ + layerIndexOfImageResource(contentResource) { + const layer = this.getLayerMetadata(contentResource); + if (!layer) return undefined; + + return layer.total - layer.index - 1; + } + + /** + * offsetByCanvas - calculates the offset for a given canvas target. Currently + * assumes a horizontal only layout. + */ + offsetByCanvas(canvasTarget) { + const coordinates = this.canvasToWorldCoordinates(canvasTarget); + return { + x: coordinates[0], + y: coordinates[1] + }; + } + + /** + * worldBounds - calculates the "World" bounds. World in this case is canvases + * lined up horizontally starting from left to right. + */ + worldBounds() { + const worldWidth = Math.max( + ...this.canvasDimensions.map(c => c.x + c.width) + ); + const worldHeight = Math.max( + ...this.canvasDimensions.map(c => c.y + c.height) + ); + + return [0, 0, worldWidth, worldHeight]; + } + + /** */ + canvasAtPoint(point) { + const canvasDimensions = this.canvasDimensions.find( + c => + c.x <= point.x && + point.x <= c.x + c.width && + c.y <= point.y && + point.y <= c.y + c.height + ); + + return canvasDimensions && canvasDimensions.canvas; + } +} diff --git a/src/Sequence.ts b/src/Sequence.ts index 4c9697db..d403261c 100644 --- a/src/Sequence.ts +++ b/src/Sequence.ts @@ -58,7 +58,7 @@ export class Sequence extends ManifestResource { return null; } - getCanvasByIndex(canvasIndex: number): any { + getCanvasByIndex(canvasIndex: number): Canvas { return this.getCanvases()[canvasIndex]; } @@ -276,7 +276,7 @@ export class Sequence extends ManifestResource { getViewingDirection(): ViewingDirection | null { if (this.getProperty("viewingDirection")) { return this.getProperty("viewingDirection"); - } else if ((this.options.resource).getViewingDirection) { + } else if ((this.options.resource).getViewingDirection()) { return (this.options.resource).getViewingDirection(); } diff --git a/src/internal.ts b/src/internal.ts index 2ea9712f..625febf0 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -7,6 +7,7 @@ export * from "./AnnotationBody"; export * from "./AnnotationList"; export * from "./AnnotationPage"; export * from "./Canvas"; +export * from "./CanvasWorld"; export * from "./Collection"; export * from "./Duration"; export * from "./IAccessToken";