diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe64ef8..3f4e0a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: node-version: ${{ matrix['node-version'] }} cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci - name: JS Tests run: npm run test current-runtime: @@ -49,25 +49,42 @@ jobs: node-version: lts/* cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci - name: JS Tests run: npm run test:coverage - name: Send coverage to Coveralls uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} + iiif-validator: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'npm' + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + working-directory: "validator" + - name: Install dependencies + run: npm ci && cd examples/tiny-iiif && npm i + - name: Run IIIF Validator + run: npm run validate lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: lts/* - cache: 'npm' + cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci - name: Type Check - run: npm run typecheck + run: npm run typecheck - name: Lint run: npm run lint diff --git a/README.md b/README.md index 5092e3b..49171ca 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,14 @@ The `pathPrefix` constructor option provides a tremendous amount of flexibility ### Processing +Primary processing is handled through the `Processor` class's `execute()` method. There are three possible return types: + +- `ContentResult` (`type: "content"`) - includes a `canonicalLink`, a `profileLink`, a `contentType`, and a `body` +- `RedirectResult` (`type: "redirect"`) - includes a redirect `location` (used when the URL ends with the ID and should redirect to `info.json`) +- `ErrorResult` (`type: "Error"`) - includes an HTTP-compatible `statusCode` (e.g., `400` for bad requests; `500` for unhandled errors) and a `message` + +In addition, certain error conditions may result in the throwing of an `IIIFError`, which also includes `statusCode` and `message` properties. + #### Promise ```typescript import { Processor } from "iiif-processor"; diff --git a/examples/tiny-iiif/clover-ui/package.json b/examples/tiny-iiif/clover-ui/package.json index 3f6a7f8..d5c156b 100644 --- a/examples/tiny-iiif/clover-ui/package.json +++ b/examples/tiny-iiif/clover-ui/package.json @@ -1,7 +1,7 @@ { "name": "clover-ui", "private": true, - "version": "6.1.5", + "version": "7.0.0-rc.2", "type": "module", "scripts": { "dev": "vite", diff --git a/examples/tiny-iiif/iiif.ts b/examples/tiny-iiif/iiif.ts index 6a190e1..fc7ff56 100644 --- a/examples/tiny-iiif/iiif.ts +++ b/examples/tiny-iiif/iiif.ts @@ -1,5 +1,12 @@ import { App } from '@tinyhttp/app'; -import { Processor, IIIFError } from 'iiif-processor'; +import { + Processor, + IIIFError, + ContentResult, + ErrorResult, + ProcessorResult, + RedirectResult +} from 'iiif-processor'; import fs from 'fs'; import path from 'path'; import { iiifImagePath, iiifpathPrefix, fileTemplate } from './config'; @@ -14,21 +21,37 @@ const streamImageFromFile = async ({ id }: { id: string }) => { }; const render = async (req: any, res: any) => { - if (req.params && req.params.filename == null) { - req.params.filename = 'info.json'; + try { + const iiifUrl = `${req.protocol}://${req.get('host')}${req.path}`; + const iiifProcessor = new Processor(iiifUrl, streamImageFromFile, { + pathPrefix: iiifpathPrefix, + debugBorder: !!process.env.DEBUG_IIIF_BORDER + }); + const result: ProcessorResult = await iiifProcessor.execute(); + switch (result.type) { + case 'content': + return res + .set('Content-Type', result.contentType) + .set('Link', [ + `<${result.canonicalLink}>;rel="canonical"`, + `<${result.profileLink}>;rel="profile"` + ]) + .status(200) + .send(result.body); + case 'redirect': + return res.redirect(result.location, 302); + case 'error': + return res + .set('Content-Type', 'text/plain') + .status(result.statusCode) + .send(result.message); + } + } catch (err) { + return res + .set('Content-Type', 'text/plain') + .status(err.statusCode || 500) + .send(err.message); } - - const iiifUrl = `${req.protocol}://${req.get('host')}${req.path}`; - const iiifProcessor = new Processor(iiifUrl, streamImageFromFile, { - pathPrefix: iiifpathPrefix, - debugBorder: !!process.env.DEBUG_IIIF_BORDER - }); - const result = await iiifProcessor.execute(); - return res - .set('Content-Type', result.contentType) - .set('Link', [`<${(result as any).canonicalLink}>;rel="canonical"`, `<${(result as any).profileLink}>;rel="profile"`]) - .status(200) - .send(result.body); }; function createRouter (version: number) { diff --git a/examples/tiny-iiif/package.json b/examples/tiny-iiif/package.json index 8f48ee5..58b44ae 100644 --- a/examples/tiny-iiif/package.json +++ b/examples/tiny-iiif/package.json @@ -1,6 +1,6 @@ { "name": "tiny-iiif", - "version": "6.1.5", + "version": "7.0.0-rc.2", "description": "Example server for node-iiif using @tinyhttp", "type": "module", "main": "index.ts", @@ -9,7 +9,8 @@ "lint": "eslint *.ts", "lint-fix": "eslint --fix *.ts", "tiny-iiif": "IIIF_IMAGE_PATH=./tiff tsx index.ts", - "dev:all": "concurrently -n build,server -c blue,green \"npm run --prefix ../.. build:watch\" \"npm run dev\"" + "dev:all": "concurrently -n build,server -c blue,green \"npm run --prefix ../.. build:watch\" \"npm run dev\"", + "validator": "IIIF_IMAGE_PATH=../../validator/fixtures nodemon" }, "repository": { "type": "git", diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..12c02c2 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +node = "24.11.1" +uv = "0.9.5" diff --git a/package-lock.json b/package-lock.json index c138278..1a0d7f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "iiif-processor", - "version": "6.1.5", + "version": "7.0.0-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "iiif-processor", - "version": "6.1.5", + "version": "7.0.0-rc.2", "license": "Apache-2.0", "workspaces": [ "examples/tiny-iiif", @@ -38,7 +38,7 @@ } }, "examples/tiny-iiif": { - "version": "6.1.5", + "version": "7.0.0-rc.2", "license": "Apache-2.0", "dependencies": { "@tinyhttp/app": "^2.0.25", @@ -61,7 +61,7 @@ } }, "examples/tiny-iiif/clover-ui": { - "version": "6.1.5", + "version": "7.0.0-rc.2", "dependencies": { "@samvera/clover-iiif": "^2.12.2", "react": "^19.0.0", @@ -154,6 +154,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -215,6 +216,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1493,6 +1495,7 @@ "resolved": "https://registry.npmjs.org/@iiif/parser/-/parser-2.2.1.tgz", "integrity": "sha512-pHz4WR+1LXm9VglHmcPKthqOkDRB8YzbDkGJE82wgQyZyh8l0iXQcq9MysSqxK5GuKi8qBaOTTUdiFlb1r2ARA==", "license": "MIT", + "peer": true, "dependencies": { "@iiif/presentation-2": "^1.0.4", "@iiif/presentation-3": "^2.2.2", @@ -1514,6 +1517,7 @@ "resolved": "https://registry.npmjs.org/@iiif/presentation-3/-/presentation-3-2.2.3.tgz", "integrity": "sha512-xCLbUr9euqegsrxGe65M2fWbv6gKpiUhHXCpOn+V+qtawkMbOSNWbYOISo2aLQdYVg4DGYD0g2bMzSCF33uNOQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/geojson": "^7946.0.10" } @@ -4443,6 +4447,7 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4453,6 +4458,7 @@ "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -4854,6 +4860,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5316,6 +5323,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.2", "caniuse-lite": "^1.0.30001741", @@ -6348,6 +6356,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6417,6 +6426,7 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7906,6 +7916,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8787,7 +8798,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -10967,7 +10977,6 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -12330,6 +12339,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12810,6 +12820,7 @@ "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -13289,11 +13300,12 @@ } }, "node_modules/vite": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", - "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -13387,6 +13399,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 4207c2f..0b46f12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iiif-processor", - "version": "6.1.5", + "version": "7.0.0-rc.2", "description": "IIIF 2.1 & 3.0 Image API modules for NodeJS", "main": "dist/index.js", "module": "dist/index.mjs", @@ -53,7 +53,8 @@ "lint:fix": "eslint --fix \"{src,tests}/**/*.{js,ts}\"", "lint:examples": "cd examples/tiny-iiif && npm run lint", "test": "node scripts/test.js --env=node", - "test:coverage": "node scripts/test.js --env=node --coverage" + "test:coverage": "node scripts/test.js --env=node --coverage", + "validate": "concurrently -s !command-server -k -n server,iiif --hide server -c blue,green \"cd examples/tiny-iiif && npm run validator\" \"cd validator && sleep 2 && uv run ./run-validator.sh\"" }, "keywords": [ "iiif", diff --git a/src/calculator/base.ts b/src/calculator/base.ts index 3860e4a..4a6cfdf 100644 --- a/src/calculator/base.ts +++ b/src/calculator/base.ts @@ -48,37 +48,58 @@ export class Base { protected _parsedInfo: ParsedInfo; protected _sourceDims: Dimensions; - static _matchers (): typeof Validators { + static _matchers(): typeof Validators { return Validators; } - static _validator (type: ValidatorKey) { + static _validator(type: ValidatorKey) { const result = this._matchers()[type].join('|'); return `(?<${type}>${result})`; } - static parsePath (path: string) { - const transformation = - ['region', 'size', 'rotation'] - .map((type: ValidatorKey) => this._validator(type)) - .join('/') + - '/' + - this._validator('quality') + - '.' + - this._validator('format'); - const re = new RegExp( - `^/?(?.+?)/(?:(?info.json)|${transformation})$` + static parsePath(path: string) { + path = decodeURIComponent(path); + debug('parsing IIIF path: %s', path); + const idOnlyRe = new RegExp('^/?(?.+)/?$'); + const infoJsonRe = new RegExp('^/?(?.+)/(?info.json)$'); + const transformRe = new RegExp( + '^/?(?.+)/(?.+)/(?.+)/(?.+)/(?.+)\\.(?.+)$' ); - const result = re.exec(path)?.groups; - if (!result) { - throw new IIIFError(`Not a valid IIIF path: ${path}`, { - statusCode: 400 - }); + + let result = infoJsonRe.exec(path)?.groups; + debug('info.json match result: %j', result); + if (result) return result; + + result = transformRe.exec(path)?.groups; + debug('transform match result: %j', result); + if (result) { + for (const component of [ + 'region', + 'size', + 'rotation', + 'quality', + 'format' + ] as ValidatorKey[]) { + const validator = new RegExp(this._validator(component)); + if (!validator.test(result[component] as string)) { + throw new IIIFError(`Invalid ${component} in IIIF path: ${path}`, { + statusCode: 400 + }); + } + } + return result; } - return result; + + result = idOnlyRe.exec(path)?.groups; + debug('ID only match result: %j', result); + if (result) return result; + + throw new IIIFError(`Not a valid IIIF path: ${path}`, { + statusCode: 400 + }); } - constructor (dims: Dimensions, opts: CalculatorOptions = {}) { + constructor(dims: Dimensions, opts: CalculatorOptions = {}) { this.dims = { ...dims }; this.opts = { ...opts }; this._sourceDims = { ...dims }; @@ -99,7 +120,7 @@ export class Base { }; } - protected _validate (type: (keyof typeof Validators) | 'density', v: unknown) { + protected _validate(type: keyof typeof Validators | 'density', v: unknown) { if (type === 'density') return validateDensity(v); const re = new RegExp(`^${Base._validator(type)}$`); debug('validating %s %s against %s', type, v, re); @@ -109,7 +130,7 @@ export class Base { return true; } - region (v: string) { + region(v: string) { this._validate('region', v); const pct = PCTR.exec(v); let isFull = false; @@ -128,7 +149,7 @@ export class Base { return this; } - size (v: string) { + size(v: string) { this._validate('size', v); const pct = PCTR.exec(v); let isMax = false; @@ -146,7 +167,7 @@ export class Base { return this; } - rotation (v: string) { + rotation(v: string) { this._validate('rotation', v); this._canonicalInfo.rotation = v; this._parsedInfo.rotation = { @@ -156,14 +177,14 @@ export class Base { return this; } - quality (v: string) { + quality(v: string) { this._validate('quality', v); this._canonicalInfo.quality = v; this._parsedInfo.quality = v; return this; } - format (v: string, density?: number) { + format(v: string, density?: number) { this._validate('format', v); this._validate('density', density); this._canonicalInfo.format = v; @@ -171,31 +192,30 @@ export class Base { return this; } - info (): Calculated { + info(): Calculated { return { ...this._parsedInfo, fullSize: fullSize(this._sourceDims, this._parsedInfo) } as Calculated; } - canonicalPath () { + canonicalPath() { const { region, size, rotation, quality, format } = this._canonicalInfo; return `${region}/${size}/${rotation}/${quality}.${format}`; } - protected _setSize (v: BoundingBox | SizeDesc) { + protected _setSize(v: BoundingBox | SizeDesc) { const max: MaxDimensions = { ...(this.opts?.max || {}) }; max.height = max.height || max.width; this._parsedInfo.size = - ('left' in v) ? { width: v.width, height: v.height, fit: 'fill' } : { ...v }; + 'left' in v + ? { width: v.width, height: v.height, fit: 'fill' } + : { ...v }; this._constrainSize(max); - if (!this._parsedInfo.upscale) { - this._constrainSize(this._sourceDims); - } return this; } - protected _constrainSize (constraints: MaxDimensions) { + protected _constrainSize(constraints: MaxDimensions) { const full = fullSize(this._sourceDims, this._parsedInfo); const constraint = minNum( constraints.width / full.width, @@ -216,13 +236,13 @@ export class Base { } } - protected _canonicalSize () { + protected _canonicalSize() { const { width, height } = this._parsedInfo.size; - const result = (width?.toString() || '') + ',' + (height?.toString() || ''); + const result = `${width},${height}`; return this._parsedInfo.size.fit === 'inside' ? `!${result}` : result; } - protected _constrainRegion () { + protected _constrainRegion() { let { left, top, width, height } = this._parsedInfo.region; left = Math.max(left, 0); top = Math.max(top, 0); @@ -235,30 +255,43 @@ export class Base { } } -function minNum (...args: unknown[]) { +function minNum(...args: unknown[]) { const nums = args.filter((arg) => typeof arg === 'number' && !isNaN(arg)); return Math.min(...(nums as number[])); } -function fillMissingDimension (size: SizeDesc, aspect: number) { - if (!size.width && size.height != null) size.width = Math.floor((size.height) * aspect); - if (!size.height && size.width != null) size.height = Math.floor((size.width) / aspect); +function fillMissingDimension(size: SizeDesc, aspect: number) { + if (!size.width && size.height != null) + size.width = Math.floor(size.height * aspect); + if (!size.height && size.width != null) + size.height = Math.floor(size.width / aspect); } -function fullSize (dims: Dimensions, { region, size }: ParsedInfo) { +function fullSize(dims: Dimensions, { region, size }: ParsedInfo) { const regionAspect = region.width / region.height; - if (!size.width && !size.height) { - throw new IIIFError('Must specify at least one of width or height', { statusCode: 400 }); - } fillMissingDimension(size, regionAspect); const scaleFactor = (size.width as number) / region.width; - const result = { width: Math.floor(dims.width * scaleFactor), height: Math.floor(dims.height * scaleFactor) }; - debug('Region %j at size %j yields full size %j, a scale factor of %f', region, size, result, scaleFactor); + const result = { + width: Math.floor(dims.width * scaleFactor), + height: Math.floor(dims.height * scaleFactor) + }; + debug( + 'Region %j at size %j yields full size %j, a scale factor of %f', + region, + size, + result, + scaleFactor + ); return result; } -function regionSquare (dims: Dimensions): BoundingBox { - let result: BoundingBox = { left: 0, top: 0, width: dims.width, height: dims.height }; +function regionSquare(dims: Dimensions): BoundingBox { + let result: BoundingBox = { + left: 0, + top: 0, + width: dims.width, + height: dims.height + }; if (dims.width !== dims.height) { const side = Math.min(dims.width, dims.height); result = { ...result, width: side, height: side }; @@ -274,24 +307,43 @@ function regionSquare (dims: Dimensions): BoundingBox { return result; } -function regionPct (v: string, dims: Dimensions): BoundingBox { +function regionPct(v: string, dims: Dimensions): BoundingBox { let x: number, y: number, w: number, h: number; - [x, y, w, h] = v.split(/\s*,\s*/).map((pct) => Number(pct) / 100.0) as [number, number, number, number]; - [x, w] = [x, w].map((val) => Math.floor(dims.width * val)) as [number, number]; - [y, h] = [y, h].map((val) => Math.floor(dims.height * val)) as [number, number]; + [x, y, w, h] = v.split(/\s*,\s*/).map((pct) => Number(pct) / 100.0) as [ + number, + number, + number, + number + ]; + [x, w] = [x, w].map((val) => Math.floor(dims.width * val)) as [ + number, + number + ]; + [y, h] = [y, h].map((val) => Math.floor(dims.height * val)) as [ + number, + number + ]; return regionXYWH([x, y, w, h]); } -function regionXYWH (v: string | number[]): BoundingBox { - const parts: number[] = typeof v === 'string' ? v.split(/\s*,\s*/).map((val) => Number(val)) : v; - const result: BoundingBox = { left: parts[0], top: parts[1], width: parts[2], height: parts[3] }; +function regionXYWH(v: string | number[]): BoundingBox { + const parts: number[] = + typeof v === 'string' ? v.split(/\s*,\s*/).map((val) => Number(val)) : v; + const result: BoundingBox = { + left: parts[0], + top: parts[1], + width: parts[2], + height: parts[3] + }; if (result.width === 0 || result.height === 0) { - throw new IIIFError('Region width and height must both be > 0', { statusCode: 400 }); + throw new IIIFError('Region width and height must both be > 0', { + statusCode: 400 + }); } return result; } -function sizePct (v: string, dims: Dimensions) { +function sizePct(v: string, dims: Dimensions) { const pct = Number(v); if (isNaN(pct) || pct <= 0) { throw new IIIFError(`Invalid resize %: ${v}`, { statusCode: 400 }); @@ -300,21 +352,20 @@ function sizePct (v: string, dims: Dimensions) { return sizeWH(`${width},`); } -function sizeWH (v: string | (number | null)[]) { +function sizeWH(v: string) { const result: SizeDesc = { fit: 'fill' }; - let parts: (number | null)[]; - if (typeof v === 'string') { - if (v[0] === '!') { - result.fit = 'inside'; - v = v.slice(1); - } - parts = v.split(/\s*,\s*/).map((val) => (val === '' ? null : Number(val))); - } else { - parts = v; + if (v[0] === '!') { + result.fit = 'inside'; + v = v.slice(1); } + const parts: (number | null)[] = v + .split(/\s*,\s*/) + .map((val) => (val === '' ? null : Number(val))); [result.width, result.height] = parts as [number | null, number | null]; if (result.width === 0 || result.height === 0) { - throw new IIIFError('Resize width and height must both be > 0', { statusCode: 400 }); + throw new IIIFError('Resize width and height must both be > 0', { + statusCode: 400 + }); } return result; } diff --git a/src/calculator/v3.ts b/src/calculator/v3.ts index 9eb0b57..6e96291 100644 --- a/src/calculator/v3.ts +++ b/src/calculator/v3.ts @@ -1,28 +1,44 @@ import { CalculatorOptions } from '../contracts'; import { Base, ValidatorMap } from './base'; +import { IIIFError } from '../error'; export class Calculator extends Base { - static _matchers (): ValidatorMap { + static _matchers(): ValidatorMap { const result: ValidatorMap = { ...super._matchers() }; - result.size = [...result.size].reduce((sizes: string[], pattern: string) => { - if (pattern !== 'full') sizes.push(`\\^?${pattern}`); - return sizes; - }, [] as string[]); + result.size = [...result.size].reduce( + (sizes: string[], pattern: string) => { + if (pattern !== 'full') sizes.push(`\\^?${pattern}`); + return sizes; + }, + [] as string[] + ); return result; } - constructor (dims: { width: number; height: number }, opts: CalculatorOptions = {}) { + constructor( + dims: { width: number; height: number }, + opts: CalculatorOptions = {} + ) { super(dims, opts); this._canonicalInfo.size = 'max'; this._parsedInfo.upscale = false; } - size (v: string) { + size(v: string) { if (v[0] === '^') { this._parsedInfo.upscale = true; v = v.slice(1, v.length); } - return super.size(v); + super.size(v); + const { region, size, upscale } = this._parsedInfo; + if (!upscale) { + if (size.width > region.width || size.height > region.height) { + throw new IIIFError('Requested size requires upscaling', { + statusCode: 400 + }); + } + } + return this; } } diff --git a/src/index.ts b/src/index.ts index 914cdeb..74cad5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,4 +6,10 @@ export { Processor, ProcessorOptions } from './processor'; +export { + ContentResult, + RedirectResult, + ErrorResult, + ProcessorResult +} from './types'; export { Versions } from './versions'; diff --git a/src/processor.ts b/src/processor.ts index f2d2fc8..abe2ed0 100644 --- a/src/processor.ts +++ b/src/processor.ts @@ -5,7 +5,15 @@ import sharp from 'sharp'; import { Operations } from './transform'; import { IIIFError } from './error'; import Versions from './versions'; -import type { Dimensions, MaxDimensions, ResolvedDimensions } from './types'; +import type { + Dimensions, + MaxDimensions, + ProcessorResult, + ResolvedDimensions, + ContentResult, + ErrorResult, + RedirectResult +} from './types'; import type { VersionModule } from './contracts'; const debug = Debug('iiif-processor:main'); @@ -13,33 +21,46 @@ const debugv = Debug('verbose:iiif-processor'); const defaultpathPrefix = '/iiif/{{version}}/'; -function getIiifVersion (url: string, template: string) { +function getIiifVersion(url: string, template: string) { const { origin, pathname } = new URL(url); - const templateMatcher = template.replace(/\{\{version\}\}/, '(?2|3)'); + const templateMatcher = template.replace( + /\{\{version\}\}/, + '(?\\d+)' + ); const pathMatcher = `^(?${templateMatcher})(?.+)$`; const re = new RegExp(pathMatcher); const parsed = re.exec(pathname); if (parsed) { parsed.groups.prefix = origin + parsed.groups.prefix; - return { ...parsed.groups } as { prefix: string; iiifVersion: string; request: string }; + return { ...parsed.groups } as { + prefix: string; + iiifVersion: string; + request: string; + }; } else { throw new IIIFError('Invalid IIIF path'); } } -export type DimensionFunction = (input: { id: string; baseUrl: string }) => Promise; -export type StreamResolver = (input: { id: string; baseUrl: string }) => Promise; +export type DimensionFunction = (input: { + id: string; + baseUrl: string; +}) => Promise; +export type StreamResolver = (input: { + id: string; + baseUrl: string; +}) => Promise; export type StreamResolverWithCallback = ( input: { id: string; baseUrl: string }, callback: (stream: NodeJS.ReadableStream) => Promise ) => Promise; export type ProcessorOptions = { dimensionFunction?: DimensionFunction; - max?: { width: number; height?: number, area?: number }; + max?: { width: number; height?: number; area?: number }; includeMetadata?: boolean; density?: number; debugBorder?: boolean; - iiifVersion?: 2 | 3; + iiifVersion?: number; pageThreshold?: number; pathPrefix?: string; sharpOptions?: Record; @@ -54,7 +75,7 @@ export class Processor { id!: string; baseUrl!: string; - version!: 2 | 3; + version!: number; request!: string; streamResolver!: StreamResolver | StreamResolverWithCallback; filename?: string; @@ -75,8 +96,15 @@ export class Processor { debugBorder = false; pageThreshold?: number; - constructor (url: string, streamResolver: StreamResolver | StreamResolverWithCallback, opts: ProcessorOptions = {}) { - const { prefix, iiifVersion, request } = getIiifVersion(url, opts.pathPrefix || defaultpathPrefix); + constructor( + url: string, + streamResolver: StreamResolver | StreamResolverWithCallback, + opts: ProcessorOptions = {} + ) { + const { prefix, iiifVersion, request } = getIiifVersion( + url, + opts.pathPrefix || defaultpathPrefix + ); if (typeof streamResolver !== 'function') { throw new IIIFError('streamResolver option must be specified'); @@ -91,12 +119,16 @@ export class Processor { density: null }; - this - .setOpts({ ...defaults, iiifVersion, ...opts, prefix, request }) - .initialize(streamResolver); + this.setOpts({ + ...defaults, + iiifVersion, + ...opts, + prefix, + request + }).initialize(streamResolver); } - setOpts (opts) { + setOpts(opts) { this.dimensionFunction = opts.dimensionFunction; this.max = { ...opts.max }; this.includeMetadata = !!opts.includeMetadata; @@ -105,15 +137,17 @@ export class Processor { this.debugBorder = !!opts.debugBorder; this.pageThreshold = opts.pageThreshold; this.sharpOptions = { ...opts.sharpOptions }; - this.version = Number(opts.iiifVersion) as 2 | 3; + this.version = Number(opts.iiifVersion); this.request = opts.request; return this; } - initialize (streamResolver: StreamResolver | StreamResolverWithCallback) { + initialize(streamResolver: StreamResolver | StreamResolverWithCallback) { this.Implementation = Versions[this.version] as VersionModule; if (!this.Implementation) { - throw new IIIFError(`No implementation found for IIIF Image API v${this.version}`); + throw new IIIFError( + `No implementation found for IIIF Image API v${this.version}` + ); } const params = this.Implementation.Calculator.parsePath(this.request); @@ -129,22 +163,37 @@ export class Processor { return this; } - async withStream ({ id, baseUrl }: { id: string; baseUrl: string }, callback: (s: NodeJS.ReadableStream) => Promise) { + async withStream( + { id, baseUrl }: { id: string; baseUrl: string }, + callback: (s: NodeJS.ReadableStream) => Promise + ) { debug('Requesting stream for %s', id); if (this.streamResolver.length === 2) { - return await (this.streamResolver as StreamResolverWithCallback)({ id, baseUrl }, callback); + return await (this.streamResolver as StreamResolverWithCallback)( + { id, baseUrl }, + callback + ); } else { - const stream = await (this.streamResolver as StreamResolver)({ id, baseUrl }); + const stream = await (this.streamResolver as StreamResolver)({ + id, + baseUrl + }); return await callback(stream); } } - async defaultDimensionFunction ({ id, baseUrl }: { id: string; baseUrl: string }): Promise { + 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) => { + return (await this.withStream({ id, baseUrl }, async (stream) => { stream.pipe(target); const { autoOrient, ...metadata } = await target.metadata(); const { width, height, pages } = { ...metadata, ...autoOrient }; @@ -160,18 +209,23 @@ export class Processor { } } return result; - }) as Dimensions[]; + })) as Dimensions[]; } - async dimensions (): Promise { - const fallback = this.dimensionFunction !== this.defaultDimensionFunction.bind(this); + async dimensions(): Promise { + const fallback = + this.dimensionFunction !== this.defaultDimensionFunction.bind(this); if (!this.sizeInfo) { - debug('Attempting to use dimensionFunction to retrieve dimensions for %j', this.id); + debug( + 'Attempting to use dimensionFunction 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().'; + 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); @@ -182,10 +236,14 @@ export class Processor { return this.sizeInfo; } - async infoJson () { + 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))) { + 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] }); } @@ -193,20 +251,35 @@ export class Processor { // 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, max: this.max }); + const doc = this.Implementation.infoDoc({ + id, + ...dim, + sizes, + max: this.max + }); for (const prop in doc) { if (doc[prop] === null || doc[prop] === undefined) delete doc[prop]; } - const body = JSON.stringify(doc, (_key, value) => (value?.constructor === Set ? [...value] : value)); - return { contentType: 'application/json', body } as const; + const body = JSON.stringify(doc, (_key, value) => + value?.constructor === Set ? [...value] : value + ); + return { + type: 'content', + contentType: 'application/ld+json', + body + } as ContentResult; } - operations (dim: Dimensions[]) { + operations(dim: Dimensions[]) { const sharpOpt = this.sharpOptions; const { max, pageThreshold } = this; debug('pageThreshold: %d', pageThreshold); - return new Operations(this.version, dim, { sharp: sharpOpt, max, pageThreshold }) + return new Operations(this.version, dim, { + sharp: sharpOpt, + max, + pageThreshold + }) .region(this.region) .size(this.size) .rotation(this.rotation) @@ -215,16 +288,24 @@ export class Processor { .withMetadata(this.includeMetadata); } - async applyBorder (transformed: sharp.Sharp) { + async applyBorder(transformed: sharp.Sharp) { const buf = await transformed.toBuffer(); const borderPipe = sharp(buf, { limitInputPixels: false }); const { width, height } = await borderPipe.metadata(); const background = { r: 255, g: 0, b: 0, alpha: 1 }; - const topBorder = { create: { width, height: 1, channels: 4, background } as sharp.Create }; - const bottomBorder = { create: { width, height: 1, channels: 4, background } as sharp.Create }; - const leftBorder = { create: { width: 1, height, channels: 4, background } as sharp.Create }; - const rightBorder = { create: { width: 1, height, channels: 4, background } as sharp.Create }; + const topBorder = { + create: { width, height: 1, channels: 4, background } as sharp.Create + }; + const bottomBorder = { + create: { width, height: 1, channels: 4, background } as sharp.Create + }; + const leftBorder = { + create: { width: 1, height, channels: 4, background } as sharp.Create + }; + const rightBorder = { + create: { width: 1, height, channels: 4, background } as sharp.Create + }; return borderPipe.composite([ { input: topBorder, left: 0, top: 0 }, @@ -234,39 +315,70 @@ export class Processor { ]); } - async iiifImage () { + async iiifImage() { debugv('Request %s', this.request); const dim = await this.dimensions(); const operations = this.operations(dim); 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); + 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(); } - debug('converting to buffer'); - return await transformed.toBuffer(); - }); + ); debug('returning %d bytes', (result as Buffer).length); debug('baseUrl', this.baseUrl); - const canonicalUrl = new URL(path.join(this.id, operations.canonicalPath()), this.baseUrl); + const canonicalUrl = new URL( + path.join(this.id, operations.canonicalPath()), + this.baseUrl + ); return { + type: 'content', canonicalLink: canonicalUrl.toString(), profileLink: this.Implementation.profileLink, contentType: mime.lookup(this.format) as string, body: result as Buffer - }; + } as ContentResult; } - async execute () { - if (this.filename === 'info.json') { - return await this.infoJson(); - } else { + async execute(): Promise { + try { + if (this.format === undefined && this.info === undefined) { + debug('No format or info.json requested; redirecting to info.json'); + return { + location: new URL( + path.join(this.id, 'info.json'), + this.baseUrl + ).toString(), + type: 'redirect' + } as RedirectResult; + } + + if (this.filename === 'info.json') { + return await this.infoJson(); + } + return await this.iiifImage(); + } catch (err) { + if (err instanceof IIIFError) { + debug('IIIFError caught: %j', err); + return { + type: 'error', + message: err.message, + statusCode: err.statusCode || 500 + } as ErrorResult; + } else { + throw err; + } } } } diff --git a/src/transform.ts b/src/transform.ts index 99d8e14..d154b7b 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -19,7 +19,7 @@ export class Operations { private pageThreshold: number; constructor (version: number, dims: Dimensions[], opts: CalculatorOptions & { sharp?: Record; pageThreshold?: number }) { - const { sharp, pageThreshold, ...rest } = opts || {}; + const { sharp, pageThreshold, ...rest } = { ...opts }; const Implementation: VersionModule = Versions[version]; this.calculator = new Implementation.Calculator(dims[0], rest); this.pageThreshold = typeof pageThreshold === 'number' ? pageThreshold : DEFAULT_PAGE_THRESHOLD; @@ -83,7 +83,10 @@ export class Operations { } pipeline (): SharpType { - const pipeline = Sharp({ limitInputPixels: false, ...(this.sharpOptions || {}) }); + const pipeline = Sharp({ + limitInputPixels: false, + ...{ ...this.sharpOptions } + }); const { page, scale } = this.computePage(); (pipeline as any).options.input.page = page; // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/src/types.ts b/src/types.ts index fab8337..49aefaa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,3 +31,24 @@ export type MaxDimensions = { }; export type Quality = 'color' | 'gray' | 'bitonal' | 'default'; + +export type ContentResult = { + type: 'content'; + canonicalLink?: string; + profileLink?: string; + contentType: string; + body: Buffer | string; +}; + +export type RedirectResult = { + type: 'redirect'; + location: string; +}; + +export type ErrorResult = { + type: 'error'; + message: string; + statusCode: number; +}; + +export type ProcessorResult = ContentResult | RedirectResult | ErrorResult; \ No newline at end of file diff --git a/src/v3/info.ts b/src/v3/info.ts index 98ebe01..25e60b8 100644 --- a/src/v3/info.ts +++ b/src/v3/info.ts @@ -6,8 +6,8 @@ import type { InfoDocInput, InfoDoc } from '../contracts'; export const profileLink = 'https://iiif.io/api/image/3/level2.json'; -const defaultFormats: Set = new Set(['jpg', 'png']); -const defaultQualities: Set = new Set(['default']); +const defaultFormats: Set = new Set(['jpg', 'png']); +const defaultQualities: Set = new Set(['default']); const IIIFExtras = { extraFeatures: [ 'canonicalLinkHeader', diff --git a/tests/v2/calculator.test.ts b/tests/v2/calculator.test.ts index f7d5eb0..f610373 100644 --- a/tests/v2/calculator.test.ts +++ b/tests/v2/calculator.test.ts @@ -102,4 +102,29 @@ describe('Calculator', () => { subject.region("1014,512,10,256").size("5,").rotation("0").quality("default").format("jpg", 600); assert.deepEqual(subject.info(), expected); }); + + describe('density', () => { + it('returns the density when set', () => { + subject.format('png', 300); + assert.equal(subject.info().format.density, 300); + }); + + it('returns undefined when density is not set', () => { + subject.format('png'); + assert.equal(subject.info().format.density, undefined); + }); + + it('handles explcit null', () => { + subject.format('png', null); + assert.equal(subject.info().format.density, undefined); + }); + }); + + describe('fullSize', () => { + it('returns the full size of the image', () => { + subject = new Calculator({ width: 1024, height: 768 }); + subject.region('50,50,50,50').size('25,25'); + assert.deepEqual(subject.info().fullSize, { width: 512, height: 384 }); + }); + }); }); diff --git a/tests/v2/integration.test.ts b/tests/v2/integration.test.ts index 7080a96..1c6d121 100644 --- a/tests/v2/integration.test.ts +++ b/tests/v2/integration.test.ts @@ -5,7 +5,6 @@ import { describe, it, beforeEach, afterEach, jest } from '@jest/globals'; import assert from 'assert'; import fs from 'fs'; import { Processor } from '../../src/processor'; -import { IIIFError } from '../../src/error'; import Sharp from 'sharp'; import values from '../fixtures/iiif-values'; const { v2: { qualities, formats, regions, sizes, rotations } } = values as any; @@ -17,7 +16,9 @@ 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/2/ab/cd/ef/gh/i'); @@ -27,7 +28,10 @@ describe('info.json', () => { }); it('respects the maxWidth option', 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.profile[1].maxWidth, 600); @@ -39,7 +43,10 @@ describe('info.json', () => { describe('quality', () => { qualities.forEach((value) => { it(`should produce an image with quality ${value}`, async () => { - subject = new Processor(`${base}/full/full/0/${value}.png`, streamResolver); + subject = new Processor( + `${base}/full/full/0/${value}.png`, + streamResolver + ); const result = await subject.execute(); assert.strictEqual(result.contentType, 'image/png'); }); @@ -49,7 +56,10 @@ describe('quality', () => { describe('format', () => { formats.forEach((value) => { it(`should produce an image with format ${value}`, async () => { - subject = new Processor(`${base}/full/full/0/default.${value}`, streamResolver); + subject = new Processor( + `${base}/full/full/0/default.${value}`, + streamResolver + ); const result = await subject.execute(); assert.match(result.contentType, /^image\//); }); @@ -59,19 +69,31 @@ describe('format', () => { describe('region', () => { regions.forEach((value) => { it(`should produce an image with region ${value}`, async () => { - subject = new Processor(`${base}/${value}/full/0/default.png`, streamResolver); + subject = new Processor( + `${base}/${value}/full/0/default.png`, + streamResolver + ); const result = await subject.execute(); assert.strictEqual(result.contentType, 'image/png'); }); }); it('should require valid region size', async () => { - subject = new Processor(`${base}/0,0,0,0/full/0/default.png`, streamResolver); - assert.rejects(() => subject.execute(), IIIFError); + subject = new Processor( + `${base}/0,0,0,0/full/0/default.png`, + streamResolver + ); + const result = await subject.execute(); + assert.strictEqual(result.type, 'error'); + assert.strictEqual(result.statusCode, 400); + assert.match(result.message, /width and height must both be > 0/); }); it('constrains the region to the image bounds', async () => { - subject = new Processor(`${base}/100,100,4000,4000/full/0/default.png`, streamResolver); + subject = new Processor( + `${base}/100,100,4000,4000/full/0/default.png`, + streamResolver + ); const result = await subject.execute(); const size = await Sharp(result.body).metadata(); assert.strictEqual(size.width, 521); @@ -79,15 +101,24 @@ describe('region', () => { }); it('raises an error if the region is invalid', async () => { - subject = new Processor(`${base}/700,0,627,540/full/0/default.png`, streamResolver); - assert.rejects(() => subject.execute(), IIIFError); + subject = new Processor( + `${base}/700,0,627,540/full/0/default.png`, + streamResolver + ); + const result = await subject.execute(); + assert.strictEqual(result.type, 'error'); + assert.strictEqual(result.statusCode, 400); + assert.match(result.message, /out of bounds/); }); }); describe('size', () => { sizes.forEach((value) => { it(`should produce an image with size ${value}`, async () => { - subject = new Processor(`${base}/full/${value}/0/default.png`, streamResolver); + subject = new Processor( + `${base}/full/${value}/0/default.png`, + streamResolver + ); const result = await subject.execute(); assert.strictEqual(result.contentType, 'image/png'); }); @@ -95,23 +126,36 @@ describe('size', () => { it('should require valid size', async () => { subject = new Processor(`${base}/full/pct:0/0/default.png`, streamResolver); - assert.rejects(() => subject.execute(), IIIFError); + const result = await subject.execute(); + assert.strictEqual(result.type, 'error'); + assert.strictEqual(result.statusCode, 400); + assert.match(result.message, /Invalid resize/); }); it('should select the correct page for the size', async () => { let pipeline; - subject = new Processor(`${base}/full/pct:40/0/default.png`, streamResolver); + subject = new Processor( + `${base}/full/pct:40/0/default.png`, + streamResolver + ); pipeline = await subject.operations(await subject.dimensions()).pipeline(); assert.strictEqual(pipeline.options.input.page, 1); }); it('should respect the pixel page buffer', async () => { let pipeline; - subject = new Processor(`${base}/full/312,165/0/default.png`, streamResolver); + subject = new Processor( + `${base}/full/312,165/0/default.png`, + streamResolver + ); pipeline = await subject.operations(await subject.dimensions()).pipeline(); assert.strictEqual(pipeline.options.input.page, 1); - subject = new Processor(`${base}/full/312,165/0/default.png`, streamResolver, { pageThreshold: 0 }); + subject = new Processor( + `${base}/full/312,165/0/default.png`, + streamResolver, + { pageThreshold: 0 } + ); pipeline = await subject.operations(await subject.dimensions()).pipeline(); assert.strictEqual(pipeline.options.input.page, 0); }); diff --git a/tests/v2/processor.test.ts b/tests/v2/processor.test.ts index bc36b32..7e77c55 100644 --- a/tests/v2/processor.test.ts +++ b/tests/v2/processor.test.ts @@ -198,7 +198,20 @@ describe('constructor errors', () => { it('requires a valid IIIF version', () => { assert.throws(() => { - return new Processor('https://example.org/iiif/0/ab/cd/ef/gh/i/info.json', identityResolver); + return new Processor( + 'https://example.org/iiif/0/ab/cd/ef/gh/i/info.json', + identityResolver + ); + }, IIIFError); + }); + + it('cannot have maxHeight without maxWidth', () => { + assert.throws(() => { + return new Processor( + `${base}/10,20,30,40/pct:50/45/default.tif`, + identityResolver, + { max: { height: 1000, width: undefined } } + ); }, IIIFError); }); }); @@ -245,3 +258,17 @@ describe('dimension function', () => { subject.execute(); }) }) + +describe('redirect to info.json', () => { + it('redirects when no format or info.json is requested', async () => { + subject = new Processor( + `https://example.org/iiif/2/ab/cd/ef/gh/i`, + async () => new Stream.Readable({ read() {} }) + ); + const result = await subject.execute(); + expect(result).toHaveProperty('location'); + expect(result.location?.toString()).toEqual( + 'https://example.org/iiif/2/ab/cd/ef/gh/i/info.json' + ); + }); +}); diff --git a/tests/v3/calculator.test.ts b/tests/v3/calculator.test.ts index 8072601..0081e6f 100644 --- a/tests/v3/calculator.test.ts +++ b/tests/v3/calculator.test.ts @@ -130,18 +130,14 @@ describe('Calculator', () => { it ('does not upscale by default', () => { subject = new Calculator({ width: 1024, height: 768 }); - const expected = { - region: { left: 512, top: 384, width: 256, height: 192 }, - size: { fit: 'fill', width: 256, height: 192 }, - rotation: { flop: false, degree: 45 }, - quality: 'default', - format: { type: 'jpg', density: 600 }, - fullSize: { width: 1024, height: 768 }, - upscale: false - }; - - subject.region('pct:50,50,25,25').size('512,384').rotation('45').quality('default').format('jpg', 600); - assert.deepEqual(subject.info(), expected); + assert.throws(() => { + subject + .region('pct:50,50,25,25') + .size('512,384') + .rotation('45') + .quality('default') + .format('jpg', 600); + }, IIIFError); }); it('upscales when requested', () => { @@ -159,4 +155,29 @@ describe('Calculator', () => { assert.deepEqual(subject.info(), expected); }); }); + + describe('density', () => { + it('returns the density when set', () => { + subject.format('png', 300); + assert.equal(subject.info().format.density, 300); + }); + + it('returns undefined when density is not set', () => { + subject.format('png'); + assert.equal(subject.info().format.density, undefined); + }); + + it('handles explcit null', () => { + subject.format('png', null); + assert.equal(subject.info().format.density, undefined); + }); + }); + + describe('fullSize', () => { + it('returns the full size of the image', () => { + subject = new Calculator({ width: 1024, height: 768 }); + subject.region('50,50,50,50').size('25,25'); + assert.deepEqual(subject.info().fullSize, { width: 512, height: 384 }); + }); + }); }); diff --git a/tests/v3/integration.test.ts b/tests/v3/integration.test.ts index 75add28..f32fc4c 100644 --- a/tests/v3/integration.test.ts +++ b/tests/v3/integration.test.ts @@ -5,7 +5,6 @@ import { describe, it, beforeEach, afterEach, jest } from '@jest/globals'; import assert from 'assert'; import fs from 'fs'; import { Processor } from '../../src/processor'; -import { IIIFError } from '../../src/error'; import Sharp from 'sharp'; import values from '../fixtures/iiif-values'; const { v3: { qualities, formats, regions, sizes, rotations } } = values as any; @@ -59,15 +58,24 @@ describe('format', () => { describe('region', () => { regions.forEach((value) => { it(`should produce an image with region ${value}`, async () => { - subject = new Processor(`${base}/${value}/max/0/default.png`, streamResolver); + subject = new Processor( + `${base}/${value}/max/0/default.png`, + streamResolver + ); const result = await subject.execute(); assert.strictEqual(result.contentType, 'image/png'); }); }); it('should require valid region size', async () => { - subject = new Processor(`${base}/0,0,0,0/max/0/default.png`, streamResolver); - assert.rejects(() => subject.execute(), IIIFError); + subject = new Processor( + `${base}/0,0,0,0/max/0/default.png`, + streamResolver + ); + const result = await subject.execute(); + assert.strictEqual(result.type, 'error'); + assert.strictEqual(result.statusCode, 400); + assert.match(result.message, /width and height must both be > 0/); }); it('constrains the region to the image bounds', async () => { @@ -86,14 +94,20 @@ describe('region', () => { `${base}/700,0,627,540/max/0/default.png`, streamResolver ); - assert.rejects(() => subject.execute(), IIIFError); + const result = await subject.execute(); + assert.strictEqual(result.type, 'error'); + assert.strictEqual(result.statusCode, 400); + assert.match(result.message, /out of bounds/); }); }); describe('size', () => { sizes.forEach((value) => { it(`should produce an image with size ${value}`, async () => { - subject = new Processor(`${base}/full/${value}/0/default.png`, streamResolver); + subject = new Processor( + `${base}/full/${value}/0/default.png`, + streamResolver + ); const result = await subject.execute(); assert.strictEqual(result.contentType, 'image/png'); }); @@ -101,12 +115,18 @@ describe('size', () => { it('should require valid size', async () => { subject = new Processor(`${base}/full/pct:0/0/default.png`, streamResolver); - assert.rejects(() => subject.execute(), IIIFError); + const result = await subject.execute(); + assert.strictEqual(result.type, 'error'); + assert.strictEqual(result.statusCode, 400); + assert.match(result.message, /Invalid resize/); }); it('should select the correct page for the size', async () => { let pipeline; - subject = new Processor(`${base}/full/pct:40/0/default.png`, streamResolver); + subject = new Processor( + `${base}/full/pct:40/0/default.png`, + streamResolver + ); pipeline = await subject.operations(await subject.dimensions()).pipeline(); assert.strictEqual(pipeline.options.input.page, 1); }); diff --git a/tests/v3/processor.test.ts b/tests/v3/processor.test.ts index c2163a9..709b17d 100644 --- a/tests/v3/processor.test.ts +++ b/tests/v3/processor.test.ts @@ -188,6 +188,16 @@ describe('constructor errors', () => { return new Processor(`${base}/10,20,30,40/pct:50/45/default.blargh`, identityResolver); }, IIIFError); }); + + it('cannot have maxHeight without maxWidth', () => { + assert.throws(() => { + return new Processor( + `${base}/10,20,30,40/pct:50/45/default.tif`, + identityResolver, + { max: { height: 1000, width: undefined } } + ); + }, IIIFError); + }); }); describe('stream processor', () => { @@ -232,3 +242,17 @@ describe('dimension function', () => { subject.execute(); }) }) + +describe('redirect to info.json', () => { + it('redirects when no format or info.json is requested', async () => { + subject = new Processor( + `https://example.org/iiif/2/ab/cd/ef/gh/i`, + async () => new Stream.Readable({ read() {} }) + ); + const result = await subject.execute(); + expect(result).toHaveProperty('location'); + expect(result.location?.toString()).toEqual( + 'https://example.org/iiif/2/ab/cd/ef/gh/i/info.json' + ); + }); +}); diff --git a/validator/.python-version b/validator/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/validator/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/validator/fixtures/67352ccc-d1b0-11e1-89ae-279075081939.tif b/validator/fixtures/67352ccc-d1b0-11e1-89ae-279075081939.tif new file mode 100644 index 0000000..cf6c23d Binary files /dev/null and b/validator/fixtures/67352ccc-d1b0-11e1-89ae-279075081939.tif differ diff --git a/validator/pyproject.toml b/validator/pyproject.toml new file mode 100644 index 0000000..97327c8 --- /dev/null +++ b/validator/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "validator" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "iiif-validator>=1.0.5", +] diff --git a/validator/run-validator.sh b/validator/run-validator.sh new file mode 100755 index 0000000..5004a53 --- /dev/null +++ b/validator/run-validator.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +for version in $(echo ${IIIF_VERSIONS:-"2 3"}); do + iiif-validate.py -s localhost:3000 -p iiif/${version} -i 67352ccc-d1b0-11e1-89ae-279075081939 --version=${version}.0 --level=2 +done diff --git a/validator/uv.lock b/validator/uv.lock new file mode 100644 index 0000000..aff3354 --- /dev/null +++ b/validator/uv.lock @@ -0,0 +1,120 @@ +version = 1 +requires-python = ">=3.14" + +[[package]] +name = "bottle" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807 }, +] + +[[package]] +name = "iiif-validator" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bottle" }, + { name = "lxml" }, + { name = "pillow" }, + { name = "python-magic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/35d1f09584338addd56cab7540370f55d2a76aa21482ff3e32e64be60da1/iiif-validator-1.0.5.tar.gz", hash = "sha256:c8a5faee9561166827868ed1212525fc98995d2ef993def601854f3b1ab1c8c3", size = 21748 } + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801 }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403 }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974 }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953 }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054 }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421 }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684 }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463 }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437 }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890 }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185 }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895 }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246 }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797 }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404 }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072 }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617 }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930 }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380 }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632 }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171 }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109 }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061 }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233 }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739 }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119 }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665 }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997 }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957 }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372 }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653 }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795 }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023 }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420 }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837 }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205 }, +] + +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531 }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554 }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812 }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689 }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186 }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308 }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222 }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657 }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482 }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416 }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584 }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621 }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916 }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836 }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092 }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158 }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882 }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001 }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146 }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344 }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864 }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911 }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045 }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282 }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630 }, +] + +[[package]] +name = "python-magic" +version = "0.4.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840 }, +] + +[[package]] +name = "validator" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "iiif-validator" }, +] + +[package.metadata] +requires-dist = [{ name = "iiif-validator", specifier = ">=1.0.5" }]