diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b149e..a349a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Only features and major fixes are listed. Everything else can be considered a minor bugfix or maintenance release. +##### v8.0.0 +- **BREAKING CHANGE**: `DimensionFunction` has been replaced with `GeometryFunction`, changing + both the option name and the shape of the expected return value +- Can now probe the image stream for tile size information +- Information document (`info.json`) is now rendered using tile size information from the image + (or from the `GeometryFunction`), falling back to a default of 256 + ##### v7.0.0 - Made entire suite able to pass the [IIIF Image API Validator](https://iiif.io/api/image/validator/) - Added automatic redirect for requests that don't specify a transformation or `info.json` diff --git a/README.md b/README.md index 49171ca..f257065 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ const processor = new Processor(url, streamResolver, opts); * `url` (string, required) - the URL of the IIIF resource to process * `streamResolver` (async function, required) – returns a Promise of a readable image stream for a given request ([see below](#stream-resolver)); the legacy two-argument callback form is deprecated * `opts`: - * `dimensionFunction` (function) – a callback function that returns the image dimensions for a given request ([see below](#dimension-function)) + * `geometryFunction` (function) – a callback function that returns the image geometry for a given request ([see below](#geometry-function)) * `max` (object) – optional maximum size constraints of an image that can be returned * `width` (integer) - the maximum pixel width of the returned image * `height` (integer) - the maximum pixel height of the returned image @@ -90,32 +90,61 @@ Note: The two-argument callback form is still supported but deprecated; prefer t promise-based resolver shown above. If you currently return a stream synchronously, wrap it with `Promise.resolve()` or mark your function `async`. -### Dimension Function +### Geometry Function -The calling function can also supply the processor with an optional Dimension callback that takes information about the request [(`id` and `baseUrl`)](#id--baseurl) and returns the dimensions of the source image. This allows for caching dimensions and avoiding an expensive image request. +The calling function can also supply the processor with an optional Geometry callback that takes information about the request [(`id` and `baseUrl`)](#id--baseurl) and returns information about the geometry of the source image. This allows for caching dimensions and other information, and avoiding an expensive image request. -The function should return either: +The function should return an object conforming to the `ImageGeometry` type, for example: -* a `{width: w, height: h}` object indicating the dimensions of the source image -* an array of `{width: w, height: h}` objects indicating the dimensions of all of the pages available within the source image, if it is a multi-resolution image (e.g., a pyramidal TIFF), e.g.: - ``` - [ - { width: 14499, height: 12069 }, - { width: 7249, height: 6034 }, - { width: 3624, height: 3017 }, - { width: 1812, height: 1508 }, - { width: 906, height: 754 }, - { width: 453, height: 377 }, - { width: 226, height: 188 } - ] - ``` +```typescript +{ + width: 4096, + height: 3072, + pages: 6, + sizes: [ + {width: 4096, height: 3072}, + {width: 2048, height: 1536}, + {width: 1024, height: 768}, + {width: 512, height: 384}, + {width: 256, height: 192}, + {width: 128, height: 96} + ], + tileWidth: 128, + tileHeight: 128 +} +``` + +Any information not included will be calculated or probed for, if possible. For example: + +| Fields Provided | Fields Calculated | Fields Probed | +| -------------------------- | ----------------- | -------------------------- | +| none | `sizes` | `width`, `height`, `pages` | +| `width`, `height`, `sizes` | `pages` | | +| `width`, `height`, `pages` | `sizes` | | + +#### Tile Size + +Tile size information is independent of dimension and page information, and is only checked +when rendering the image information document (`info.json`). If either `tileWidth` or `tileHeight` +is left `undefined` by the Geometry Function, the image stream will be probed for them, which +can be an expensive operation. If both are provided – even if they are `null` – the given values +will be used. (`null` values will be replaced by a default value of `256` when rendering the +information document). -Providing the dimensions of all available pages allows the processor to choose the most efficient starting image for the size requested. +The following example shows a Geometry Function that looks up the width, height, and number of +pages in the target image in a database and returns them along with hardcoded tile sizes. The +`sizes` array will be automatically calculated by the processor. ```typescript -async function dimensionFunction({ id: string, baseUrl: string }): Promise { +async function geometryFunction({ id: string, baseUrl: string }): Promise { let dimensions = lookDimensionsUpInDatabase(id); - return { width: dimensions.width, height: dimensions.height }; + return { + width: dimensions.width, + height: dimensions.height, + pages: dimensions.pages, + tileWidth: 128, + tileHeight: 128 + }; } ``` @@ -142,7 +171,7 @@ In addition, certain error conditions may result in the throwing of an `IIIFErro import { Processor } from "iiif-processor"; let url = "http://iiif.example.com/iiif/2/abcdefgh/full/400,/0/default.jpg" -let processor = new Processor(url, streamResolver, { dimensionFunction }); +let processor = new Processor(url, streamResolver, { geometryFunction }); processor.execute() .then(result => handleResult(result)) .catch(err => handleError(err)); @@ -153,7 +182,7 @@ processor.execute() import { Processor } from "iiif-processor"; let url = "http://iiif.example.com/iiif/2/abcdefgh/full/400,/0/default.jpg" -let processor = new Processor(url, streamResolver, { dimensionFunction }); +let processor = new Processor(url, streamResolver, { geometryFunction }); try { return await processor.execute(); } catch (err) { diff --git a/examples/tiny-iiif/iiif.ts b/examples/tiny-iiif/iiif.ts index fc7ff56..73bfe61 100644 --- a/examples/tiny-iiif/iiif.ts +++ b/examples/tiny-iiif/iiif.ts @@ -1,12 +1,5 @@ import { App } from '@tinyhttp/app'; -import { - Processor, - IIIFError, - ContentResult, - ErrorResult, - ProcessorResult, - RedirectResult -} from 'iiif-processor'; +import { Processor, IIIFError, ProcessorResult } from 'iiif-processor'; import fs from 'fs'; import path from 'path'; import { iiifImagePath, iiifpathPrefix, fileTemplate } from './config'; @@ -54,12 +47,15 @@ const render = async (req: any, res: any) => { } }; -function createRouter (version: number) { +function createRouter(version: number) { const router = new App(); router.use((_req, res, next) => { res.set('Access-Control-Allow-Headers', '*'); - res.set('Access-Control-Allow-Methods', 'OPTIONS, HEAD, GET, POST, PUT, DELETE'); + res.set( + 'Access-Control-Allow-Methods', + 'OPTIONS, HEAD, GET, POST, PUT, DELETE' + ); res.set('Access-Control-Allow-Origin', '*'); next(); }); @@ -67,7 +63,9 @@ function createRouter (version: number) { router.options('*', (_req, res) => { res.status(204).send(''); }); - router.get('/', (_req, res) => res.status(200).send(`IIIF v${version}.x endpoint OK`)); + router.get('/', (_req, res) => + res.status(200).send(`IIIF v${version}.x endpoint OK`) + ); router.get('/:id', render); router.get('/:id/info.json', render); router.get('/:id/:region/:size/:rotation/:filename', render); diff --git a/examples/tiny-iiif/package.json b/examples/tiny-iiif/package.json index 68c0505..b27db88 100644 --- a/examples/tiny-iiif/package.json +++ b/examples/tiny-iiif/package.json @@ -8,7 +8,7 @@ "dev": "IIIF_IMAGE_PATH=${IIIF_IMAGE_PATH:-./tiff} nodemon", "lint": "eslint *.ts", "lint-fix": "eslint --fix *.ts", - "tiny-iiif": "IIIF_IMAGE_PATH=./tiff tsx index.ts", + "tiny-iiif": "IIIF_IMAGE_PATH=${IIIF_IMAGE_PATH:-./tiff} tsx index.ts", "dev:all": "concurrently -n build,server -c blue,green \"npm run --prefix ../.. build:watch\" \"npm run dev\"", "validator": "IIIF_IMAGE_PATH=../../validator/fixtures nodemon" }, diff --git a/examples/tiny-iiif/tsconfig.json b/examples/tiny-iiif/tsconfig.json index 4bac7c4..a001203 100644 --- a/examples/tiny-iiif/tsconfig.json +++ b/examples/tiny-iiif/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "module": "ESNext", + "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2020", "types": ["node"], diff --git a/package-lock.json b/package-lock.json index 1ecc30b..c360572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "iiif-processor", - "version": "7.0.0", + "version": "8.0.0-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "iiif-processor", - "version": "7.0.0", + "version": "8.0.0-alpha.2", "license": "Apache-2.0", "workspaces": [ "examples/tiny-iiif", @@ -5221,13 +5221,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.4.tgz", - "integrity": "sha512-L+YvJwGAgwJBV1p6ffpSTa2KRc69EeeYGYjRVWKs0GKrK+LON0GC0gV+rKSNtALEDvMDqkvCFq9r1r94/Gjwxw==", + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bcrypt-pbkdf": { diff --git a/package.json b/package.json index 455e089..c2ce021 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iiif-processor", - "version": "7.0.0", + "version": "8.0.0-alpha.2", "description": "IIIF 2.1 & 3.0 Image API modules for NodeJS", "main": "dist/index.js", "module": "dist/index.mjs", @@ -106,7 +106,8 @@ "src/**/*.{js,ts}" ], "coveragePathIgnorePatterns": [ - "/tsup.config.ts" + "/tsup.config.ts", + "src/tile-size.ts" ], "testPathIgnorePatterns": [ "[/\\\\](build|docs|node_modules|scripts)[/\\\\]" diff --git a/src/contracts.ts b/src/contracts.ts index c25f793..57a4c0c 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,4 +1,12 @@ -import type { BoundingBox, Dimensions, Format, IIIFSpec, MaxDimensions, Quality } from './types'; +import type { + BoundingBox, + Dimensions, + Format, + IIIFSpec, + ImageGeometry, + MaxDimensions, + Quality +} from './types'; export interface Calculated { region: BoundingBox; @@ -28,9 +36,7 @@ export type CalculatorCtor = { export interface InfoDocInput { id: string; - width: number; - height: number; - sizes: Dimensions[]; + geometry: ImageGeometry; max?: MaxDimensions; } diff --git a/src/geometry.ts b/src/geometry.ts new file mode 100644 index 0000000..951cfbb --- /dev/null +++ b/src/geometry.ts @@ -0,0 +1,107 @@ +import Debug from 'debug'; +import type { ImageGeometry } from './types'; +import sharp from 'sharp'; +import { Readable } from 'stream'; +import { getTileSize } from './tile-size'; + +const debug = Debug('iiif:geometry'); + +type StreamCallback = (stream: Readable) => Promise; + +export async function readGeometry( + withStream: (callback: StreamCallback) => Promise, + geometry: ImageGeometry +): Promise { + let metadata = {}; + let tileSize = {}; + const result = { ...geometry }; + + debug('Initial geometry: %O', geometry); + + if ( + !geometry.width || + !geometry.height || + !(geometry.pages || geometry.sizes) + ) { + await withStream(async (metadataStream) => { + metadata = await readMetadata(metadataStream); + }); + debug('Read metadata: %O', metadata); + } + + if (geometry.tileWidth === undefined || geometry.tileHeight === undefined) { + await withStream(async (sizeStream) => { + const size = await getTileSize(sizeStream); + tileSize = { tileWidth: size.width, tileHeight: size.height }; + }); + debug('Read tile size: %O', tileSize); + } + + const final = { ...result, ...metadata, ...tileSize }; + debug('Final geometry: %O', final); + return final; +} + +export function calculateGeometry(geometry: ImageGeometry): ImageGeometry { + if (geometry.sizes) { + const result: ImageGeometry = { ...geometry }; + if (!geometry.pages) { + result.pages = geometry.sizes.length; + } + if (!geometry.width || !geometry.height) { + result.width = geometry.sizes[0].width; + result.height = geometry.sizes[0].height; + } + return result; + } + + if (geometry.width && geometry.height) { + if (geometry.pages) + if (geometry.pages === 1) { + return { + ...geometry, + sizes: [{ width: geometry.width, height: geometry.height }] + }; + } + if (geometry.pages > 1) { + return calculateSizesFromPages(geometry); + } + if (geometry.tileWidth && geometry.tileHeight) { + return calculateSizesFromTiles(geometry); + } + } + + return geometry; +} + +async function readMetadata(stream: Readable): Promise { + const target = sharp({ limitInputPixels: false, page: 0 }); + + stream.pipe(target); + const { autoOrient, ...metadata } = await target.metadata(); + const { width, height, pages } = { ...metadata, ...autoOrient }; + return { width, height, pages }; +} + +function calculateSizesFromTiles(geometry: ImageGeometry): ImageGeometry { + const pages = + Math.max( + Math.ceil(Math.log2(geometry.width! / geometry.tileWidth!)), + Math.ceil(Math.log2(geometry.height! / geometry.tileHeight!)) + ) + 1; + return calculateSizesFromPages({ ...geometry, pages }); +} + +function calculateSizesFromPages(geometry: ImageGeometry): ImageGeometry { + const result: ImageGeometry = { ...geometry }; + result.sizes = [{ width: geometry.width, height: geometry.height }]; + let page = 0; + for (page += 1; page < geometry.pages; page++) { + const scale = 1 / 2 ** page; + result.sizes.push({ + width: Math.floor(geometry.width * scale), + height: Math.floor(geometry.height * scale) + }); + } + return result; +} diff --git a/src/index.ts b/src/index.ts index 74cad5b..4ad0d1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,13 @@ export { IIIFError } from './error'; export { - DimensionFunction, + GeometryFunction, StreamResolver, StreamResolverWithCallback, Processor, ProcessorOptions } from './processor'; export { + ImageGeometry, ContentResult, RedirectResult, ErrorResult, diff --git a/src/processor.ts b/src/processor.ts index abe2ed0..6ec01e5 100644 --- a/src/processor.ts +++ b/src/processor.ts @@ -2,14 +2,14 @@ import Debug from 'debug'; import mime from 'mime-types'; import path from 'path'; import sharp from 'sharp'; +import { calculateGeometry, readGeometry } from './geometry'; import { Operations } from './transform'; import { IIIFError } from './error'; import Versions from './versions'; import type { - Dimensions, MaxDimensions, + ImageGeometry, ProcessorResult, - ResolvedDimensions, ContentResult, ErrorResult, RedirectResult @@ -42,10 +42,10 @@ function getIiifVersion(url: string, template: string) { } } -export type DimensionFunction = (input: { +export type GeometryFunction = (input: { id: string; baseUrl: string; -}) => Promise; +}) => Promise; export type StreamResolver = (input: { id: string; baseUrl: string; @@ -55,7 +55,7 @@ export type StreamResolverWithCallback = ( callback: (stream: NodeJS.ReadableStream) => Promise ) => Promise; export type ProcessorOptions = { - dimensionFunction?: DimensionFunction; + geometryFunction?: GeometryFunction; max?: { width: number; height?: number; area?: number }; includeMetadata?: boolean; density?: number; @@ -70,7 +70,7 @@ export type ProcessorOptions = { export class Processor { private errorClass = IIIFError; private Implementation!: VersionModule; - private sizeInfo?: Dimensions[]; + private imageGeometry?: ImageGeometry; private sharpOptions?: Record; id!: string; @@ -89,7 +89,7 @@ export class Processor { format!: string; // options - dimensionFunction!: DimensionFunction; + geometryFunction!: GeometryFunction; max?: MaxDimensions; includeMetadata = false; density?: number | null; @@ -115,7 +115,7 @@ export class Processor { } const defaults = { - dimensionFunction: this.defaultDimensionFunction.bind(this), + geometryFunction: null, density: null }; @@ -129,7 +129,7 @@ export class Processor { } setOpts(opts) { - this.dimensionFunction = opts.dimensionFunction; + this.geometryFunction = opts.geometryFunction; this.max = { ...opts.max }; this.includeMetadata = !!opts.includeMetadata; this.density = opts.density; @@ -163,10 +163,8 @@ export class Processor { return this; } - async withStream( - { id, baseUrl }: { id: string; baseUrl: string }, - callback: (s: NodeJS.ReadableStream) => Promise - ) { + async withStream(callback: (s: NodeJS.ReadableStream) => Promise) { + const { id, baseUrl } = this; debug('Requesting stream for %s', id); if (this.streamResolver.length === 2) { return await (this.streamResolver as StreamResolverWithCallback)( @@ -182,79 +180,36 @@ export class Processor { } } - async defaultDimensionFunction({ - id, - baseUrl - }: { - id: string; - baseUrl: string; - }): Promise { - const result: Dimensions[] = []; - let page = 0; - const target = sharp({ limitInputPixels: false, page }); - - return (await this.withStream({ id, baseUrl }, async (stream) => { - stream.pipe(target); - const { autoOrient, ...metadata } = await target.metadata(); - const { width, height, pages } = { ...metadata, ...autoOrient }; - if (!width || !height) return result; - result.push({ width, height }); - if (!isNaN(pages)) { - for (page += 1; page < pages; page++) { - const scale = 1 / 2 ** page; - result.push({ - width: Math.floor(width * scale), - height: Math.floor(height * scale) - }); - } - } - return result; - })) as Dimensions[]; - } - - async dimensions(): Promise { - const fallback = - this.dimensionFunction !== this.defaultDimensionFunction.bind(this); - - if (!this.sizeInfo) { + async geometry(includeTile = false): Promise { + if (!this.imageGeometry) { debug( - 'Attempting to use dimensionFunction to retrieve dimensions for %j', + 'Attempting to use geometryFunction to retrieve dimensions for %j', this.id ); const params = { id: this.id, baseUrl: this.baseUrl }; - let dims: ResolvedDimensions = await this.dimensionFunction(params); - if (fallback && !dims) { - const warning = - 'Unable to get dimensions for %s using custom function. Falling back to sharp.metadata().'; - debug(warning, this.id); - console.warn(warning, this.id); - dims = await this.defaultDimensionFunction(params); + let geometry: ImageGeometry = {}; + if (this.geometryFunction) { + geometry = await this.geometryFunction(params); } - if (!Array.isArray(dims)) dims = [dims]; - this.sizeInfo = dims as Dimensions[]; + if (!(geometry.tileWidth && geometry.tileHeight) && !includeTile) { + geometry.tileWidth = null; + geometry.tileHeight = null; + } + geometry = await readGeometry(this.withStream.bind(this), geometry); + this.imageGeometry = calculateGeometry(geometry); } - return this.sizeInfo; + return this.imageGeometry; } async infoJson() { - const [dim] = await this.dimensions(); - const sizes: Array<{ width: number; height: number }> = []; - for ( - let size = [dim.width, dim.height]; - size.every((x) => x >= 64); - size = size.map((x) => Math.floor(x / 2)) - ) { - sizes.push({ width: size[0], height: size[1] }); - } - + const geometry = await this.geometry(true); const uri = new URL(this.baseUrl); // Node's URL has readonly pathname in types; construct via join on new URL uri.pathname = path.join(uri.pathname, this.id); const id = uri.toString(); const doc = this.Implementation.infoDoc({ id, - ...dim, - sizes, + geometry, max: this.max }); for (const prop in doc) { @@ -271,11 +226,11 @@ export class Processor { } as ContentResult; } - operations(dim: Dimensions[]) { + operations({ sizes }: ImageGeometry) { const sharpOpt = this.sharpOptions; const { max, pageThreshold } = this; debug('pageThreshold: %d', pageThreshold); - return new Operations(this.version, dim, { + return new Operations(this.version, sizes, { sharp: sharpOpt, max, pageThreshold @@ -317,23 +272,20 @@ export class Processor { async iiifImage() { debugv('Request %s', this.request); - const dim = await this.dimensions(); - const operations = this.operations(dim); + const geometry = await this.geometry(); + const operations = this.operations(geometry); debugv('Operations: %j', operations); const pipeline = await operations.pipeline(); - const result = await this.withStream( - { id: this.id, baseUrl: this.baseUrl }, - async (stream) => { - debug('piping stream to pipeline'); - let transformed = await stream.pipe(pipeline); - if (this.debugBorder) { - transformed = await this.applyBorder(transformed); - } - debug('converting to buffer'); - return await transformed.toBuffer(); + const result = await this.withStream(async (stream) => { + debug('piping stream to pipeline'); + let transformed = await stream.pipe(pipeline); + if (this.debugBorder) { + transformed = await this.applyBorder(transformed); } - ); + debug('converting to buffer'); + return await transformed.toBuffer(); + }); debug('returning %d bytes', (result as Buffer).length); debug('baseUrl', this.baseUrl); diff --git a/src/tile-size.ts b/src/tile-size.ts new file mode 100644 index 0000000..05be48f --- /dev/null +++ b/src/tile-size.ts @@ -0,0 +1,182 @@ +import { Readable } from 'stream'; + +export interface TileSize { + width: number | undefined | null; + height: number | undefined | null; +} + +type ImageFormat = 'tiff-le' | 'tiff-be' | 'jp2' | 'unknown'; + +const CHUNK_SIZE = 5 * 1024; // 5KB + +/** + * Wraps a Readable stream in an async interface that accumulates chunks + * on demand. Call `ensure(n)` to buffer at least `n` bytes, then read + * from `buf` directly. + */ +class StreamBuffer { + private chunks: Buffer[] = []; + private _length = 0; + private done = false; + private iterator: AsyncIterableIterator; + + constructor(stream: Readable) { + stream.pause(); + this.iterator = stream[ + Symbol.asyncIterator + ]() as AsyncIterableIterator; + } + + get length() { + return this._length; + } + + get buf(): Buffer { + return Buffer.concat(this.chunks); + } + + /** Buffer at least `needed` bytes, or until stream is exhausted. */ + async ensure(needed: number): Promise { + while (this._length < needed && !this.done) { + const { value, done } = await this.iterator.next(); + if (done) { + this.done = true; + } else { + this.chunks.push(value); + this._length += value.length; + } + } + } + + /** Read `count` bytes starting at `offset`, fetching more chunks if needed. */ + async read(offset: number, count: number): Promise { + await this.ensure(offset + count); + return this.buf.subarray(offset, offset + count); + } +} + +const magicNumbers = [ + { type: 'tiff-le', magic: Buffer.from([0x49, 0x49, 0x2a, 0x00]) }, + { type: 'tiff-be', magic: Buffer.from([0x4d, 0x4d, 0x00, 0x2a]) }, + { type: 'jp2', magic: Buffer.from([0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50]) }, + { type: 'jp2', magic: Buffer.from([0xff, 0x4f]) } +]; + +function detectFormat(buf: Buffer): ImageFormat { + if (buf.length < 8) return 'unknown'; + for (const { type, magic } of magicNumbers) { + if (buf.subarray(0, magic.length).equals(magic)) return type as ImageFormat; + } + return 'unknown'; +} + +async function getTiffTileSize( + sb: StreamBuffer, + littleEndian: boolean +): Promise { + const readUInt16 = (buf: Buffer, offset: number) => + littleEndian ? buf.readUInt16LE(offset) : buf.readUInt16BE(offset); + const readUInt32 = (buf: Buffer, offset: number) => + littleEndian ? buf.readUInt32LE(offset) : buf.readUInt32BE(offset); + + // Bytes 4-7 contain the IFD offset + const header = await sb.read(0, 8); + const ifdOffset = readUInt32(header, 4); + + // First 2 bytes of the IFD are the entry count + const ifdHeader = await sb.read(ifdOffset, 2); + const entryCount = readUInt16(ifdHeader, 0); + + // Each IFD entry is 12 bytes + const ifdData = await sb.read(ifdOffset + 2, entryCount * 12); + + let width: number | undefined | null; + let height: number | undefined | null; + + for (let i = 0; i < entryCount; i++) { + const entryOffset = i * 12; + const tag = readUInt16(ifdData, entryOffset); + const value = readUInt32(ifdData, entryOffset + 8); + + if (tag === 322) width = value; // TileWidth + if (tag === 323) height = value; // TileLength + + if (width !== undefined && height !== undefined) break; + } + + return { width, height }; +} + +async function getJP2TileSize(sb: StreamBuffer): Promise { + const magic = await sb.read(0, 2); + const isRawCodestream = magic[0] === 0xff && magic[1] === 0x4f; + + let offset = 0; + + if (!isRawCodestream) { + // Walk JP2 boxes to find the jp2c (codestream) box + let foundCodestream = false; + while (true) { + const boxHeader = await sb.read(offset, 8); + if (boxHeader.length < 8) break; + + const boxLength = boxHeader.readUInt32BE(0); + const boxType = boxHeader.readUInt32BE(4); + + if (boxType === 0x6a703263) { + // 'jp2c' + offset += 8; // skip box header, now pointing at codestream + foundCodestream = true; + break; + } + + if (boxLength < 8) break; // malformed + offset += boxLength; + } + + if (!foundCodestream) return { width: null, height: null }; + } + + // Scan for SIZ marker (FF51), reading in chunks to avoid buffering the whole file + while (true) { + const chunk = await sb.read(offset, CHUNK_SIZE); + if (chunk.length < 2) break; + + for (let i = 0; i < chunk.length - 1; i++) { + if (chunk[i] === 0xff && chunk[i + 1] === 0x51) { + // SIZ layout from marker start: + // FF51 (2) + segment length (2) + Rsiz (2) + Xsiz (4) + Ysiz (4) + // + XOsiz (4) + YOsiz (4) = 22 bytes before XTsiz + const sizData = await sb.read(offset + i + 22, 8); + if (sizData.length < 8) return { width: null, height: null }; + return { + width: sizData.readUInt32BE(0), // XTsiz + height: sizData.readUInt32BE(4) // YTsiz + }; + } + } + + if (chunk.length < CHUNK_SIZE) break; // end of stream + offset += CHUNK_SIZE - 1; // overlap by 1 to avoid missing a marker at a chunk boundary + } + + return { width: null, height: null }; +} + +export async function getTileSize(stream: Readable): Promise { + const sb = new StreamBuffer(stream); + + // Read just enough to detect the format + await sb.ensure(8); + const format = detectFormat(sb.buf); + + if (format === 'tiff-le' || format === 'tiff-be') { + return getTiffTileSize(sb, format === 'tiff-le'); + } + + if (format === 'jp2') { + return getJP2TileSize(sb); + } + + return { width: null, height: null }; +} diff --git a/src/types.ts b/src/types.ts index 49aefaa..238b2bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,15 @@ export type IIIFSpec = { density?: number; }; +export type ImageGeometry = { + width?: number; + height?: number; + pages?: number; + sizes?: Dimensions[]; + tileWidth?: number; + tileHeight?: number; +}; + export type MaxDimensions = { width?: number; height?: number; @@ -51,4 +60,4 @@ export type ErrorResult = { statusCode: number; }; -export type ProcessorResult = ContentResult | RedirectResult | ErrorResult; \ No newline at end of file +export type ProcessorResult = ContentResult | RedirectResult | ErrorResult; diff --git a/src/v2/info.ts b/src/v2/info.ts index 5f5f5d3..dc85ef3 100644 --- a/src/v2/info.ts +++ b/src/v2/info.ts @@ -32,13 +32,14 @@ const IIIFProfile = { ]) }; -export function infoDoc ({ id, width, height, sizes, max }: InfoDocInput): InfoDoc { +export function infoDoc({ id, geometry, max }: InfoDocInput): InfoDoc { const maxAttrs = { maxWidth: max?.width, maxHeight: max?.height, maxArea: max?.area }; + const { width, height, sizes } = geometry; return { '@context': 'http://iiif.io/api/image/2/context.json', '@id': id, @@ -47,7 +48,11 @@ export function infoDoc ({ id, width, height, sizes, max }: InfoDocInput): InfoD height, sizes, tiles: [ - { width: 512, height: 512, scaleFactors: sizes.map((_v: Dimensions, i: number) => 2 ** i) } + { + width: geometry.tileWidth, + height: geometry.tileHeight, + scaleFactors: sizes.map((_v: Dimensions, i: number) => 2 ** i) + } ], profile: [profileLink, { ...IIIFProfile, ...maxAttrs }] }; diff --git a/src/v3/info.ts b/src/v3/info.ts index 25e60b8..62c8755 100644 --- a/src/v3/info.ts +++ b/src/v3/info.ts @@ -6,6 +6,7 @@ import type { InfoDocInput, InfoDoc } from '../contracts'; export const profileLink = 'https://iiif.io/api/image/3/level2.json'; +const DEFAULT_TILE_SIZE = 512; const defaultFormats: Set = new Set(['jpg', 'png']); const defaultQualities: Set = new Set(['default']); const IIIFExtras = { @@ -23,19 +24,14 @@ const IIIFExtras = { extraQualities: new Set(Qualities.filter((q) => !defaultQualities.has(q))) }; -export function infoDoc({ - id, - width, - height, - sizes, - max -}: InfoDocInput): InfoDoc { +export function infoDoc({ id, geometry, max }: InfoDocInput): InfoDoc { const maxAttrs = { maxWidth: max?.width, maxHeight: max?.height, maxArea: max?.area }; + const { width, height, sizes } = geometry; return { '@context': 'http://iiif.io/api/image/3/context.json', id, @@ -47,8 +43,8 @@ export function infoDoc({ sizes, tiles: [ { - width: 512, - height: 512, + width: geometry.tileWidth || DEFAULT_TILE_SIZE, + height: geometry.tileHeight || DEFAULT_TILE_SIZE, scaleFactors: sizes.map((_v: Dimensions, i: number) => 2 ** i) } ], diff --git a/tests/fixtures/samvera.tif b/tests/fixtures/samvera.tif index 5d3977b..b926a1e 100644 Binary files a/tests/fixtures/samvera.tif and b/tests/fixtures/samvera.tif differ diff --git a/tests/fixtures/samvera_128.tif b/tests/fixtures/samvera_128.tif new file mode 100644 index 0000000..276ac96 Binary files /dev/null and b/tests/fixtures/samvera_128.tif differ diff --git a/tests/fixtures/samvera_256.tif b/tests/fixtures/samvera_256.tif new file mode 100644 index 0000000..5d3977b Binary files /dev/null and b/tests/fixtures/samvera_256.tif differ diff --git a/tests/geometry.test.ts b/tests/geometry.test.ts new file mode 100644 index 0000000..47c77b1 --- /dev/null +++ b/tests/geometry.test.ts @@ -0,0 +1,167 @@ +/// +'use strict'; + +import { describe, it, expect } from '@jest/globals'; +import { calculateGeometry, readGeometry } from '../src/geometry'; +import fs from 'node:fs'; + +describe('Geometry', () => { + describe('calculateGeometry', () => { + it('leaves existing geometry unchanged', () => { + const geometry = { + width: 1024, + height: 768, + pages: 3, + tileWidth: 128, + tileHeight: 128, + sizes: [ + { width: 1024, height: 768 }, + { width: 512, height: 384 }, + { width: 256, height: 192 } + ] + }; + const result = calculateGeometry(geometry); + expect(result).toEqual(geometry); + }); + + it('calculates sizes for a given width/height/pages', () => { + const result = calculateGeometry({ width: 1024, height: 768, pages: 3 }); + expect(result.sizes).toHaveLength(3); + expect(result.sizes[0]).toEqual({ width: 1024, height: 768 }); + expect(result.sizes[1]).toEqual({ width: 512, height: 384 }); + expect(result.sizes[2]).toEqual({ width: 256, height: 192 }); + }); + + it('calculates sizes for a given width/height/tile size', () => { + const result = calculateGeometry({ + width: 1024, + height: 768, + tileWidth: 128, + tileHeight: 128 + }); + expect(result.pages).toEqual(4); + expect(result.sizes).toHaveLength(4); + expect(result.sizes[0]).toEqual({ width: 1024, height: 768 }); + expect(result.sizes[1]).toEqual({ width: 512, height: 384 }); + expect(result.sizes[2]).toEqual({ width: 256, height: 192 }); + expect(result.sizes[3]).toEqual({ width: 128, height: 96 }); + }); + + it('calculates pages for a given width/height/sizes', () => { + const result = calculateGeometry({ + width: 1024, + height: 768, + sizes: [ + { width: 512, height: 384 }, + { width: 256, height: 192 } + ] + }); + expect(result.pages).toEqual(2); + }); + + it('calculates width/height for a given sizes', () => { + const result = calculateGeometry({ + sizes: [ + { width: 1024, height: 768 }, + { width: 512, height: 384 }, + { width: 256, height: 192 } + ] + }); + expect(result.pages).toEqual(3); + expect(result.width).toEqual(1024); + expect(result.height).toEqual(768); + }); + }); + + describe('readGeometry', () => { + it('uses the provided geometry if it is complete', async () => { + const streamer = async () => { + throw new Error('Should not be called'); + }; + const geometry = { + width: 1024, + height: 768, + pages: 3, + tileWidth: 128, + tileHeight: 128 + }; + const result = await readGeometry(streamer, geometry); + expect(result).toEqual(geometry); + }); + + it('reads width, height, pages, and tile size', async () => { + const streamer = async (callback) => { + await callback(fs.createReadStream('./tests/fixtures/samvera_256.tif')); + }; + const result = await readGeometry(streamer, {}); + expect(result).toEqual({ + width: 621, + height: 327, + pages: 4, + tileWidth: 256, + tileHeight: 256 + }); + }); + + it('reads width, height, and pages but leaves tile size intact', async () => { + const streamer = async (callback) => { + await callback(fs.createReadStream('./tests/fixtures/samvera_256.tif')); + }; + let result = await readGeometry(streamer, { + tileWidth: 512, + tileHeight: 512 + }); + expect(result).toEqual({ + width: 621, + height: 327, + pages: 4, + tileWidth: 512, + tileHeight: 512 + }); + + result = await readGeometry(streamer, { + tileWidth: null, + tileHeight: null + }); + expect(result).toEqual({ + width: 621, + height: 327, + pages: 4, + tileWidth: null, + tileHeight: null + }); + }); + + it('reads tile size but leaves width/height/pages intact', async () => { + const streamer = async (callback) => { + await callback(fs.createReadStream('./tests/fixtures/samvera_128.tif')); + }; + const result = await readGeometry(streamer, { + width: 1242, + height: 654, + pages: 5 + }); + expect(result).toEqual({ + width: 1242, + height: 654, + pages: 5, + tileWidth: 128, + tileHeight: 128 + }); + }); + + it('leaves tile size undefined if it cannot be read', async () => { + const streamer = async (callback) => { + await callback(fs.createReadStream('./tests/fixtures/samvera.tif')); + }; + const result = await readGeometry(streamer, {}); + expect(result).toEqual({ + width: 621, + height: 327, + pages: 1, + tileWidth: undefined, + tileHeight: undefined + }); + }); + }); +}); diff --git a/tests/v2/integration.test.ts b/tests/v2/integration.test.ts index d85759e..473866d 100644 --- a/tests/v2/integration.test.ts +++ b/tests/v2/integration.test.ts @@ -7,10 +7,13 @@ import fs from 'fs'; import { Processor } from '../../src/processor'; import Sharp from 'sharp'; import values from '../fixtures/iiif-values'; -const { v2: { qualities, formats, regions, sizes, rotations } } = values as any; +const { + v2: { qualities, formats, regions, sizes, rotations } +} = values as any; const base = 'https://example.org/iiif/2/ab/cd/ef/gh/i'; -const streamResolver: any = async () => fs.createReadStream('./tests/fixtures/samvera.tif'); +const streamResolver: any = async () => + fs.createReadStream('./tests/fixtures/samvera_256.tif'); let subject; let consoleWarnMock; @@ -154,7 +157,7 @@ describe('size', () => { `${base}/full/pct:40/0/default.png`, streamResolver ); - pipeline = await subject.operations(await subject.dimensions()).pipeline(); + pipeline = await subject.operations(await subject.geometry()).pipeline(); assert.strictEqual(pipeline.options.input.page, 1); }); @@ -164,7 +167,7 @@ describe('size', () => { `${base}/full/312,165/0/default.png`, streamResolver ); - pipeline = await subject.operations(await subject.dimensions()).pipeline(); + pipeline = await subject.operations(await subject.geometry()).pipeline(); assert.strictEqual(pipeline.options.input.page, 1); subject = new Processor( @@ -172,7 +175,7 @@ describe('size', () => { streamResolver, { pageThreshold: 0 } ); - pipeline = await subject.operations(await subject.dimensions()).pipeline(); + pipeline = await subject.operations(await subject.geometry()).pipeline(); assert.strictEqual(pipeline.options.input.page, 0); }); }); @@ -180,7 +183,10 @@ describe('size', () => { describe('rotation', () => { rotations.forEach((value) => { it(`should produce an image with rotation ${value}`, async () => { - subject = new Processor(`${base}/full/full/${value}/default.png`, streamResolver); + subject = new Processor( + `${base}/full/full/${value}/default.png`, + streamResolver + ); const result = await subject.execute(); assert.strictEqual(result.contentType, 'image/png'); }); @@ -189,22 +195,24 @@ describe('rotation', () => { describe('IIIF transformation', () => { beforeEach(() => { - consoleWarnMock = jest.spyOn(global.console, 'warn').mockImplementation(() => undefined); + consoleWarnMock = jest + .spyOn(global.console, 'warn') + .mockImplementation(() => undefined); subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.png`, streamResolver, - { dimensionFunction: () => null } + { geometryFunction: async () => ({}) } ); }); - + afterEach(() => { consoleWarnMock.mockRestore(); }); - + it('transforms the image', async () => { const result = await subject.execute(); const size = await Sharp(result.body).metadata(); - + assert(result.canonicalLink); assert(result.profileLink); assert.strictEqual(size.width, 25); @@ -212,14 +220,14 @@ describe('IIIF transformation', () => { assert.strictEqual(size.format, 'png'); }); }); - + describe('Two-argument streamResolver', () => { beforeEach(() => { subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.png`, - async ({id, baseUrl}, callback) => { - const stream = await streamResolver({id, baseUrl}); - return callback(stream); + async ({ id, baseUrl }, callback) => { + const stream = await streamResolver({ id, baseUrl }); + return callback(stream); } ); }); @@ -244,7 +252,9 @@ describe('Debug border', () => { }); it('should add a border when `debugBorder` is specified', async () => { - subject = new Processor(`${base}/full/full/0/default.png`, streamResolver, { debugBorder: true }); + subject = new Processor(`${base}/full/full/0/default.png`, streamResolver, { + debugBorder: true + }); const result = await subject.execute(); const image = await Sharp(result.body).removeAlpha().raw().toBuffer(); const pixel = image.readUInt32LE(0); diff --git a/tests/v2/processor.test.ts b/tests/v2/processor.test.ts index 7e77c55..15061cc 100644 --- a/tests/v2/processor.test.ts +++ b/tests/v2/processor.test.ts @@ -9,12 +9,20 @@ import { Processor } from '../../src/processor'; let subject; const base = 'https://example.org/iiif/2/ab/cd/ef/gh/i'; -const dims = [{ width: 1024, height: 768 }]; -const identityResolver = async (_input) => new Stream.Readable({ read () {} }); +const geometry = { + width: 1024, + height: 768, + pages: 1, + sizes: [{ width: 1024, height: 768 }] +}; +const identityResolver = async (_input) => new Stream.Readable({ read() {} }); describe('IIIF Processor', () => { beforeEach(() => { - subject = new Processor(`${base}/10,20,30,40/pct:50/45/default.png`, identityResolver); + subject = new Processor( + `${base}/10,20,30,40/pct:50/45/default.png`, + identityResolver + ); }); it('Parse URL', () => { @@ -28,7 +36,7 @@ describe('IIIF Processor', () => { }); it('Create pipeline', async () => { - const pipe = await subject.operations(dims).pipeline(); + const pipe = await subject.operations(geometry).pipeline(); const opts = pipe.options; assert.strictEqual(opts.width, 15); @@ -41,19 +49,27 @@ describe('IIIF Processor', () => { describe('Minimum width and height', () => { beforeEach(() => { - subject = new Processor(`${base}/8192,0,7,5466/1,342/0/default.jpg`, identityResolver); + subject = new Processor( + `${base}/8192,0,7,5466/1,342/0/default.jpg`, + identityResolver + ); }); it('Avoids having a width or height < 1', async () => { - const dims = [ - { width: 8199, height: 5466 }, - { width: 4099, height: 2733 }, - { width: 2049, height: 1366 }, - { width: 1024, height: 683 }, - { width: 512, height: 341 }, - { width: 256, height: 170 } - ]; - const pipe = await subject.operations(dims).pipeline(); + const geometry = { + width: 8199, + height: 5466, + pages: 6, + sizes: [ + { width: 8199, height: 5466 }, + { width: 4099, height: 2733 }, + { width: 2049, height: 1366 }, + { width: 1024, height: 683 }, + { width: 512, height: 341 }, + { width: 256, height: 170 } + ] + }; + const pipe = await subject.operations(geometry).pipeline(); const opts = pipe.options; assert.notEqual(opts.width, 0); assert.notEqual(opts.height, 0); @@ -62,15 +78,15 @@ describe('Minimum width and height', () => { describe('Include metadata', () => { beforeEach(() => { - subject = new Processor( + subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.jpg`, - async () => new Stream.Readable({ read () {} }), + async () => new Stream.Readable({ read() {} }), { includeMetadata: true } ); }); it('Includes preexisting metadata', async () => { - const pipe = await subject.operations(dims).pipeline(); + const pipe = await subject.operations(geometry).pipeline(); const opts = pipe.options; assert.notEqual(opts.keepMetadata, 0); @@ -79,11 +95,14 @@ describe('Include metadata', () => { describe('TIFF Download', () => { beforeEach(() => { - subject = new Processor(`${base}/10,20,30,40/pct:50/45/default.tif`, identityResolver); + subject = new Processor( + `${base}/10,20,30,40/pct:50/45/default.tif`, + identityResolver + ); }); it('Output TIFF format', async () => { - const pipe = await subject.operations(dims).pipeline(); + const pipe = await subject.operations(geometry).pipeline(); const opts = pipe.options; assert.strictEqual(opts.width, 15); @@ -96,9 +115,9 @@ describe('TIFF Download', () => { describe('Density', () => { beforeEach(() => { subject = (ext) => { - return new Processor( + return new Processor( `https://example.org/iiif/2/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.${ext}`, - async () => new Stream.Readable({ read () {} }), + async () => new Stream.Readable({ read() {} }), { density: 600 } ); }; @@ -106,7 +125,7 @@ describe('Density', () => { it('Adds density to TIFF', async () => { const processor = subject('tif'); - const pipe = await processor.operations(dims).pipeline(); + const pipe = await processor.operations(geometry).pipeline(); const opts = pipe.options; assert.strictEqual(opts.tiffXres, 600 / 25.4); @@ -115,7 +134,7 @@ describe('Density', () => { it('Adds density to JPEG', async () => { const processor = subject('jpg'); - const pipe = await processor.operations(dims).pipeline(); + const pipe = await processor.operations(geometry).pipeline(); const opts = pipe.options; assert.strictEqual(opts.withMetadataDensity, 600); @@ -123,7 +142,7 @@ describe('Density', () => { it('Adds density to PNG', async () => { const processor = subject('png'); - const pipe = await processor.operations(dims).pipeline(); + const pipe = await processor.operations(geometry).pipeline(); const opts = pipe.options; assert.strictEqual(opts.withMetadataDensity, 600); @@ -134,11 +153,21 @@ describe('constructor', () => { it('must parse the object-based constructor', async () => { subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.tif`, - async () => new Stream.Readable({ read () {} }), - { dimensionFunction: () => Promise.resolve({width: 1024, height: 768}), max: { width: 1000 }, includeMetadata: true, density: 600 } + async () => new Stream.Readable({ read() {} }), + { + geometryFunction: () => + Promise.resolve({ width: 1024, height: 768, pages: 1 }), + max: { width: 1000 }, + includeMetadata: true, + density: 600 + } ); - expect(subject.dimensionFunction()).resolves.toEqual({width: 1024, height: 768}); + expect(subject.geometryFunction()).resolves.toEqual({ + width: 1024, + height: 768, + pages: 1 + }); assert.equal(typeof subject.streamResolver, 'function'); assert.strictEqual(subject.max.width, 1000); assert.strictEqual(subject.includeMetadata, true); @@ -147,30 +176,30 @@ describe('constructor', () => { it('properly handles custom sharp options', async () => { let pipe; - + subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.tif`, - async () => new Stream.Readable({ read () {} }), + async () => new Stream.Readable({ read() {} }), { sharpOptions: { sequentialRead: false } } ); - pipe = await subject.operations(dims).pipeline(); + pipe = await subject.operations(geometry).pipeline(); assert.strictEqual(pipe.options.input.sequentialRead, false); - - subject = new Processor( + + subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.tif`, - async () => new Stream.Readable({ read () {} }), + async () => new Stream.Readable({ read() {} }), { sharpOptions: { sequentialRead: true } } ); - pipe = await subject.operations(dims).pipeline(); + pipe = await subject.operations(geometry).pipeline(); assert.strictEqual(pipe.options.input.sequentialRead, true); }); it('takes a custom version and path prefix', () => { subject = new Processor( - 'https://example.org/iiif/III/ab/cd/ef/gh/i/info.json', - async () => new Stream.Readable({ read () {} }), - { iiifVersion: 3, pathPrefix: '/iiif/III/' } - ); + 'https://example.org/iiif/III/ab/cd/ef/gh/i/info.json', + async () => new Stream.Readable({ read() {} }), + { iiifVersion: 3, pathPrefix: '/iiif/III/' } + ); assert.strictEqual(subject.version, 3); assert.strictEqual(subject.id, 'ab/cd/ef/gh/i'); assert.strictEqual(subject.baseUrl, 'https://example.org/iiif/III/'); @@ -180,19 +209,28 @@ describe('constructor', () => { describe('constructor errors', () => { it('requires a streamResolver', () => { assert.throws(() => { - return new Processor(`${base}/10,20,30,40/pct:50/45/default.tif`, {} as any); + return new Processor( + `${base}/10,20,30,40/pct:50/45/default.tif`, + {} as any + ); }, IIIFError); }); it('requires a valid URL', () => { assert.throws(() => { - return new Processor(`${base}/10,20,30,40/pct:50/45/default.blargh`, identityResolver); + return new Processor( + `${base}/10,20,30,40/pct:50/45/default.blargh`, + identityResolver + ); }, IIIFError); }); it('needs to be able to infer the version', () => { assert.throws(() => { - return new Processor('https://example.org/iiif/X/ab/cd/ef/gh/i/info.json', identityResolver); + return new Processor( + 'https://example.org/iiif/X/ab/cd/ef/gh/i/info.json', + identityResolver + ); }, IIIFError); }); @@ -218,7 +256,7 @@ describe('constructor errors', () => { describe('stream processor', () => { it('passes the id and baseUrl to the function', () => { - expect.assertions(2) // ensures our streamResolver assertions are both executed in this test + expect.assertions(2); // ensures our streamResolver assertions are both executed in this test const streamResolver = async ({ id, baseUrl }) => { expect(id).toEqual('i'); @@ -227,37 +265,41 @@ describe('stream processor', () => { return new Stream.Readable({ read() {} }); - } + }; - const subject = new Processor(`https://example.org/iiif/2/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`, streamResolver, {pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/'}); + const subject = new Processor( + `https://example.org/iiif/2/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`, + streamResolver, + { pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' } + ); subject.execute(); - }) -}) + }); +}); describe('dimension function', () => { it('passes the id and baseUrl to the function', () => { - expect.assertions(2) // ensures our dimension function assertions are both executed in this test + expect.assertions(2); // ensures our dimension function assertions are both executed in this test const streamResolver = async () => { return new Stream.Readable({ read() {} }); - } + }; - const dimensionFunction = async ({ id, baseUrl }) => { + const geometryFunction = async ({ id, baseUrl }) => { expect(id).toEqual('i'); expect(baseUrl).toEqual('https://example.org/iiif/2/ab/cd/ef/gh/'); - return { width: 100, height: 100 } - } + return { width: 100, height: 100, pages: 1 }; + }; - const subject = new Processor( + const subject = new Processor( `https://example.org/iiif/2/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`, streamResolver, - { dimensionFunction, pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' } + { geometryFunction, pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' } ); subject.execute(); - }) -}) + }); +}); describe('redirect to info.json', () => { it('redirects when no format or info.json is requested', async () => { diff --git a/tests/v3/integration.test.ts b/tests/v3/integration.test.ts index 305850a..f7d4116 100644 --- a/tests/v3/integration.test.ts +++ b/tests/v3/integration.test.ts @@ -7,16 +7,21 @@ import fs from 'fs'; import { Processor } from '../../src/processor'; import Sharp from 'sharp'; import values from '../fixtures/iiif-values'; -const { v3: { qualities, formats, regions, sizes, rotations } } = values as any; +const { + v3: { qualities, formats, regions, sizes, rotations } +} = values as any; const base = 'https://example.org/iiif/3/ab/cd/ef/gh/i'; -const streamResolver: any = () => fs.createReadStream('./tests/fixtures/samvera.tif'); +const streamResolver: any = () => + fs.createReadStream('./tests/fixtures/samvera_256.tif'); let subject; let consoleWarnMock; describe('info.json', () => { it('produces a valid info.json', async () => { - subject = new Processor(`${base}/info.json`, streamResolver, { pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' }); + subject = new Processor(`${base}/info.json`, streamResolver, { + pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' + }); const result = await subject.execute(); const info = JSON.parse(result.body); assert.strictEqual(info.id, 'https://example.org/iiif/3/ab/cd/ef/gh/i'); @@ -26,7 +31,10 @@ describe('info.json', () => { }); it('respects max size options', async () => { - subject = new Processor(`${base}/info.json`, streamResolver, { pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/', max: { width: 600 } }); + subject = new Processor(`${base}/info.json`, streamResolver, { + pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/', + max: { width: 600 } + }); const result = await subject.execute(); const info = JSON.parse(result.body); assert.strictEqual(info.maxWidth, 600); @@ -54,7 +62,10 @@ describe('info.json', () => { describe('quality', () => { qualities.forEach((value) => { it(`should produce an image with quality ${value}`, async () => { - subject = new Processor(`${base}/full/max/0/${value}.png`, streamResolver); + subject = new Processor( + `${base}/full/max/0/${value}.png`, + streamResolver + ); const result = await subject.execute(); assert.strictEqual(result.contentType, 'image/png'); }); @@ -64,7 +75,10 @@ describe('quality', () => { describe('format', () => { formats.forEach((value) => { it(`should produce an image with format ${value}`, async () => { - subject = new Processor(`${base}/full/max/0/default.${value}`, streamResolver); + subject = new Processor( + `${base}/full/max/0/default.${value}`, + streamResolver + ); const result = await subject.execute(); assert.match(result.contentType, /^image\//); }); @@ -143,7 +157,7 @@ describe('size', () => { `${base}/full/pct:40/0/default.png`, streamResolver ); - pipeline = await subject.operations(await subject.dimensions()).pipeline(); + pipeline = await subject.operations(await subject.geometry()).pipeline(); assert.strictEqual(pipeline.options.input.page, 1); }); }); @@ -151,7 +165,10 @@ describe('size', () => { describe('rotation', () => { rotations.forEach((value) => { it(`should produce an image with rotation ${value}`, async () => { - subject = new Processor(`${base}/full/max/${value}/default.png`, streamResolver); + subject = new Processor( + `${base}/full/max/${value}/default.png`, + streamResolver + ); const result = await subject.execute(); assert.strictEqual(result.contentType, 'image/png'); }); @@ -160,35 +177,37 @@ describe('rotation', () => { describe('IIIF transformation', () => { beforeEach(() => { - consoleWarnMock = jest.spyOn(global.console, 'warn').mockImplementation(() => undefined); + consoleWarnMock = jest + .spyOn(global.console, 'warn') + .mockImplementation(() => undefined); subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.png`, streamResolver, - { dimensionFunction: () => null } + { geometryFunction: async () => ({}) } ); }); - + afterEach(() => { consoleWarnMock.mockRestore(); }); - + it('transforms the image', async () => { const result = await subject.execute(); const size = await Sharp(result.body).metadata(); - + assert.strictEqual(size.width, 25); assert.strictEqual(size.height, 25); assert.strictEqual(size.format, 'png'); }); }); - + describe('Two-argument streamResolver', () => { beforeEach(() => { subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.png`, - ({id, baseUrl}, callback) => { - const stream = streamResolver({id, baseUrl}); - return callback(stream); + ({ id, baseUrl }, callback) => { + const stream = streamResolver({ id, baseUrl }); + return callback(stream); } ); }); @@ -213,7 +232,9 @@ describe('Debug border', () => { }); it('should add a border when `debugBorder` is specified', async () => { - subject = new Processor(`${base}/full/max/0/default.png`, streamResolver, { debugBorder: true }); + subject = new Processor(`${base}/full/max/0/default.png`, streamResolver, { + debugBorder: true + }); const result = await subject.execute(); const image = await Sharp(result.body).removeAlpha().raw().toBuffer(); const pixel = image.readUInt32LE(0); diff --git a/tests/v3/processor.test.ts b/tests/v3/processor.test.ts index 709b17d..660f884 100644 --- a/tests/v3/processor.test.ts +++ b/tests/v3/processor.test.ts @@ -9,12 +9,20 @@ import { Processor } from '../../src/processor'; let subject; const base = 'https://example.org/iiif/3/ab/cd/ef/gh/i'; -const dims = [{ width: 1024, height: 768 }]; -const identityResolver = async (_input) => new Stream.Readable({ read () {} }); +const geometry = { + width: 1024, + height: 768, + pages: 1, + sizes: [{ width: 1024, height: 768 }] +}; +const identityResolver = async (_input) => new Stream.Readable({ read() {} }); describe('IIIF Processor', () => { beforeEach(() => { - subject = new Processor(`${base}/10,20,30,40/pct:50/45/default.png`, identityResolver); + subject = new Processor( + `${base}/10,20,30,40/pct:50/45/default.png`, + identityResolver + ); }); it('Parse URL', () => { @@ -28,7 +36,7 @@ describe('IIIF Processor', () => { }); it('Create pipeline', async () => { - const pipe = await subject.operations(dims).pipeline(); + const pipe = await subject.operations(geometry).pipeline(); const opts = pipe.options; assert.strictEqual(opts.width, 15); @@ -39,7 +47,7 @@ describe('IIIF Processor', () => { }); }); -describe("Minimum width and height", () => { +describe('Minimum width and height', () => { beforeEach(() => { subject = new Processor( `${base}/8192,0,7,5466/1,342/0/default.jpg`, @@ -47,16 +55,21 @@ describe("Minimum width and height", () => { ); }); - it("Avoids having a width or height < 1", async () => { - const dims = [ - { width: 8199, height: 5466 }, - { width: 4099, height: 2733 }, - { width: 2049, height: 1366 }, - { width: 1024, height: 683 }, - { width: 512, height: 341 }, - { width: 256, height: 170 } - ]; - const pipe = await subject.operations(dims).pipeline(); + it('Avoids having a width or height < 1', async () => { + const geometry = { + width: 8199, + height: 5466, + pages: 6, + sizes: [ + { width: 8199, height: 5466 }, + { width: 4099, height: 2733 }, + { width: 2049, height: 1366 }, + { width: 1024, height: 683 }, + { width: 512, height: 341 }, + { width: 256, height: 170 } + ] + }; + const pipe = await subject.operations(geometry).pipeline(); const opts = pipe.options; assert.notEqual(opts.width, 0); assert.notEqual(opts.height, 0); @@ -73,7 +86,7 @@ describe('Include metadata', () => { }); it('Includes preexisting metadata', async () => { - const pipe = await subject.operations(dims).pipeline(); + const pipe = await subject.operations(geometry).pipeline(); const opts = pipe.options; assert.notEqual(opts.keepMetadata, 0); @@ -82,11 +95,14 @@ describe('Include metadata', () => { describe('TIFF Download', () => { beforeEach(() => { - subject = new Processor(`${base}/10,20,30,40/pct:50/45/default.tif`, identityResolver); + subject = new Processor( + `${base}/10,20,30,40/pct:50/45/default.tif`, + identityResolver + ); }); it('Output TIFF format', async () => { - const pipe = await subject.operations(dims).pipeline(); + const pipe = await subject.operations(geometry).pipeline(); const opts = pipe.options; assert.strictEqual(opts.width, 15); @@ -101,7 +117,7 @@ describe('Density', () => { subject = (ext) => { return new Processor( `https://example.org/iiif/3/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.${ext}`, - async () => new Stream.Readable({ read () {} }), + async () => new Stream.Readable({ read() {} }), { density: 600 } ); }; @@ -109,7 +125,7 @@ describe('Density', () => { it('Adds density to TIFF', async () => { const processor = subject('tif'); - const pipe = await processor.operations(dims).pipeline(); + const pipe = await processor.operations(geometry).pipeline(); const opts = pipe.options; assert.strictEqual(opts.tiffXres, 600 / 25.4); @@ -118,7 +134,7 @@ describe('Density', () => { it('Adds density to JPEG', async () => { const processor = subject('jpg'); - const pipe = await processor.operations(dims).pipeline(); + const pipe = await processor.operations(geometry).pipeline(); const opts = pipe.options; assert.strictEqual(opts.withMetadataDensity, 600); @@ -126,7 +142,7 @@ describe('Density', () => { it('Adds density to PNG', async () => { const processor = subject('png'); - const pipe = await processor.operations(dims).pipeline(); + const pipe = await processor.operations(geometry).pipeline(); const opts = pipe.options; assert.strictEqual(opts.withMetadataDensity, 600); @@ -139,15 +155,25 @@ describe('constructor', () => { width: 1000, height: 1000, area: 10000 - } + }; subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.tif`, - async () => new Stream.Readable({ read () {} }), - { dimensionFunction: () => Promise.resolve({width: 1024, height: 768}), max, includeMetadata: true, density: 600 } + async () => new Stream.Readable({ read() {} }), + { + geometryFunction: () => + Promise.resolve({ width: 1024, height: 768, pages: 1 }), + max, + includeMetadata: true, + density: 600 + } ); assert.equal(typeof subject.streamResolver, 'function'); - expect(subject.dimensionFunction()).resolves.toEqual({width: 1024, height: 768}); + expect(subject.geometryFunction()).resolves.toEqual({ + width: 1024, + height: 768, + pages: 1 + }); assert.strictEqual(subject.max.width, 1000); assert.strictEqual(subject.max.height, 1000); assert.strictEqual(subject.max.area, 10000); @@ -157,35 +183,41 @@ describe('constructor', () => { it('properly handles custom sharp options', async () => { let pipe; - + subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.tif`, - async () => new Stream.Readable({ read () {} }), + async () => new Stream.Readable({ read() {} }), { sharpOptions: { sequentialRead: false } } ); - pipe = await subject.operations(dims).pipeline(); + pipe = await subject.operations(geometry).pipeline(); assert.strictEqual(pipe.options.input.sequentialRead, false); - + subject = new Processor( `${base}/10,20,30,40/pct:50/45/default.tif`, - async () => new Stream.Readable({ read () {} }), + async () => new Stream.Readable({ read() {} }), { sharpOptions: { sequentialRead: true } } ); - pipe = await subject.operations(dims).pipeline(); + pipe = await subject.operations(geometry).pipeline(); assert.strictEqual(pipe.options.input.sequentialRead, true); - }) + }); }); describe('constructor errors', () => { it('requires a streamResolver', () => { assert.throws(() => { - return new Processor(`${base}/10,20,30,40/pct:50/45/default.tif`, {} as any); + return new Processor( + `${base}/10,20,30,40/pct:50/45/default.tif`, + {} as any + ); }, IIIFError); }); it('requires a valid URL', () => { assert.throws(() => { - return new Processor(`${base}/10,20,30,40/pct:50/45/default.blargh`, identityResolver); + return new Processor( + `${base}/10,20,30,40/pct:50/45/default.blargh`, + identityResolver + ); }, IIIFError); }); @@ -202,7 +234,7 @@ describe('constructor errors', () => { describe('stream processor', () => { it('passes the id and baseUrl to the function', () => { - expect.assertions(2) // ensures our streamResolver assertions are both executed in this test + expect.assertions(2); // ensures our streamResolver assertions are both executed in this test const streamResolver = async ({ id, baseUrl }) => { expect(id).toEqual('i'); @@ -211,37 +243,41 @@ describe('stream processor', () => { return new Stream.Readable({ read() {} }); - } + }; - const subject = new Processor(`https://example.org/iiif/3/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`, streamResolver, {pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/'}); + const subject = new Processor( + `https://example.org/iiif/3/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`, + streamResolver, + { pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' } + ); subject.execute(); - }) -}) + }); +}); -describe('dimension function', () => { +describe('geometry function', () => { it('passes the id and baseUrl to the function', () => { - expect.assertions(2) // ensures our dimension function assertions are both executed in this test + expect.assertions(2); // ensures our geometry function assertions are both executed in this test const streamResolver = async () => { return new Stream.Readable({ read() {} }); - } + }; - const dimensionFunction = async ({ id, baseUrl }) => { + const geometryFunction = async ({ id, baseUrl }) => { expect(id).toEqual('i'); expect(baseUrl).toEqual('https://example.org/iiif/3/ab/cd/ef/gh/'); - return { width: 100, height: 100 } + return { width: 100, height: 100, pages: 1 }; }; const subject = new Processor( `https://example.org/iiif/3/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`, streamResolver as any, - { dimensionFunction, pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' } + { geometryFunction, pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' } ); subject.execute(); - }) -}) + }); +}); describe('redirect to info.json', () => { it('redirects when no format or info.json is requested', async () => {