Skip to content

Commit a1b573a

Browse files
committed
Add IIIF validator and fix compliance issues
1 parent a56f311 commit a1b573a

File tree

14 files changed

+294
-55
lines changed

14 files changed

+294
-55
lines changed

.github/workflows/test.yml

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
node-version: ${{ matrix['node-version'] }}
2626
cache: 'npm'
2727
- name: Install dependencies
28-
run: npm ci
28+
run: npm ci
2929
- name: JS Tests
3030
run: npm run test
3131
current-runtime:
@@ -49,25 +49,42 @@ jobs:
4949
node-version: lts/*
5050
cache: 'npm'
5151
- name: Install dependencies
52-
run: npm ci
52+
run: npm ci
5353
- name: JS Tests
5454
run: npm run test:coverage
5555
- name: Send coverage to Coveralls
5656
uses: coverallsapp/github-action@v2
5757
with:
5858
github-token: ${{ secrets.GITHUB_TOKEN }}
59+
iiif-validator:
60+
runs-on: ubuntu-latest
61+
steps:
62+
- uses: actions/checkout@v4
63+
- name: Setup Node.js
64+
uses: actions/setup-node@v4
65+
with:
66+
node-version: lts/*
67+
cache: 'npm'
68+
- name: Setup uv
69+
uses: astral-sh/setup-uv@v7
70+
with:
71+
working-directory: "validator"
72+
- name: Install dependencies
73+
run: npm ci && cd examples/tiny-iiif && npm i
74+
- name: Run IIIF Validator
75+
run: npm run validate
5976
lint:
6077
runs-on: ubuntu-latest
6178
steps:
62-
- uses: actions/checkout@v4
79+
- uses: actions/checkout@v4
6380
- name: Setup Node.js
6481
uses: actions/setup-node@v4
6582
with:
6683
node-version: lts/*
67-
cache: 'npm'
84+
cache: 'npm'
6885
- name: Install dependencies
69-
run: npm ci
86+
run: npm ci
7087
- name: Type Check
71-
run: npm run typecheck
88+
run: npm run typecheck
7289
- name: Lint
7390
run: npm run lint

examples/tiny-iiif/iiif.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,39 @@ const streamImageFromFile = async ({ id }: { id: string }) => {
1414
};
1515

1616
const render = async (req: any, res: any) => {
17-
if (req.params && req.params.filename == null) {
18-
req.params.filename = 'info.json';
19-
}
17+
try {
18+
const iiifUrl = `${req.protocol}://${req.get('host')}${req.path}`;
19+
const iiifProcessor = new Processor(iiifUrl, streamImageFromFile, {
20+
pathPrefix: iiifpathPrefix,
21+
debugBorder: !!process.env.DEBUG_IIIF_BORDER
22+
});
23+
const result = await iiifProcessor.execute();
2024

21-
const iiifUrl = `${req.protocol}://${req.get('host')}${req.path}`;
22-
const iiifProcessor = new Processor(iiifUrl, streamImageFromFile, {
23-
pathPrefix: iiifpathPrefix,
24-
debugBorder: !!process.env.DEBUG_IIIF_BORDER
25-
});
26-
const result = await iiifProcessor.execute();
27-
return res
28-
.set('Content-Type', result.contentType)
29-
.set('Link', [`<${(result as any).canonicalLink}>;rel="canonical"`, `<${(result as any).profileLink}>;rel="profile"`])
30-
.status(200)
31-
.send(result.body);
25+
if (result.redirect) {
26+
return res.redirect(result.location, 302);
27+
}
28+
29+
if (result.error) {
30+
return res
31+
.set('Content-Type', 'text/plain')
32+
.status(result.statusCode)
33+
.send(result.message);
34+
}
35+
36+
return res
37+
.set('Content-Type', result.contentType)
38+
.set('Link', [
39+
`<${(result as any).canonicalLink}>;rel="canonical"`,
40+
`<${(result as any).profileLink}>;rel="profile"`
41+
])
42+
.status(200)
43+
.send(result.body);
44+
} catch (err) {
45+
return res
46+
.set('Content-Type', 'text/plain')
47+
.status(err.statusCode || 500)
48+
.send(err.message);
49+
}
3250
};
3351

3452
function createRouter (version: number) {

examples/tiny-iiif/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"lint": "eslint *.ts",
1010
"lint-fix": "eslint --fix *.ts",
1111
"tiny-iiif": "IIIF_IMAGE_PATH=./tiff tsx index.ts",
12-
"dev:all": "concurrently -n build,server -c blue,green \"npm run --prefix ../.. build:watch\" \"npm run dev\""
12+
"dev:all": "concurrently -n build,server -c blue,green \"npm run --prefix ../.. build:watch\" \"npm run dev\"",
13+
"validator": "IIIF_IMAGE_PATH=../../validator/fixtures nodemon"
1314
},
1415
"repository": {
1516
"type": "git",

mise.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[tools]
2+
uv = "0.9.5"

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
"lint:fix": "eslint --fix \"{src,tests}/**/*.{js,ts}\"",
5454
"lint:examples": "cd examples/tiny-iiif && npm run lint",
5555
"test": "node scripts/test.js --env=node",
56-
"test:coverage": "node scripts/test.js --env=node --coverage"
56+
"test:coverage": "node scripts/test.js --env=node --coverage",
57+
"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\""
5758
},
5859
"keywords": [
5960
"iiif",

src/calculator/base.ts

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,24 +58,44 @@ export class Base {
5858
}
5959

6060
static parsePath (path: string) {
61-
const transformation =
62-
['region', 'size', 'rotation']
63-
.map((type: ValidatorKey) => this._validator(type))
64-
.join('/') +
65-
'/' +
66-
this._validator('quality') +
67-
'.' +
68-
this._validator('format');
69-
const re = new RegExp(
70-
`^/?(?<id>.+?)/(?:(?<info>info.json)|${transformation})$`
61+
debug('parsing IIIF path: %s', path);
62+
const idOnlyRe = new RegExp('^/?(?<id>.+)/?$');
63+
const infoJsonRe = new RegExp('^/?(?<id>.+)/(?<info>info.json)$');
64+
const transformRe = new RegExp(
65+
'^/?(?<id>.+)/(?<region>.+)/(?<size>.+)/(?<rotation>.+)/(?<quality>.+)\\.(?<format>.+)$'
7166
);
72-
const result = re.exec(path)?.groups;
73-
if (!result) {
74-
throw new IIIFError(`Not a valid IIIF path: ${path}`, {
75-
statusCode: 400
76-
});
67+
68+
let result = transformRe.exec(path)?.groups;
69+
debug('transform match result: %j', result);
70+
if (result) {
71+
for (const component of [
72+
'region',
73+
'size',
74+
'rotation',
75+
'quality',
76+
'format'
77+
] as ValidatorKey[]) {
78+
const validator = new RegExp(this._validator(component));
79+
if (!validator.test(result[component] as string)) {
80+
throw new IIIFError(`Invalid ${component} in IIIF path: ${path}`, {
81+
statusCode: 400
82+
});
83+
}
84+
}
85+
return result;
7786
}
78-
return result;
87+
88+
result = infoJsonRe.exec(path)?.groups;
89+
debug('info.json match result: %j', result);
90+
if (result) return result;
91+
92+
result = idOnlyRe.exec(path)?.groups;
93+
debug('ID only match result: %j', result);
94+
if (result) return result;
95+
96+
throw new IIIFError(`Not a valid IIIF path: ${path}`, {
97+
statusCode: 400
98+
});
7999
}
80100

81101
constructor (dims: Dimensions, opts: CalculatorOptions = {}) {
@@ -187,11 +207,10 @@ export class Base {
187207
const max: MaxDimensions = { ...(this.opts?.max || {}) };
188208
max.height = max.height || max.width;
189209
this._parsedInfo.size =
190-
('left' in v) ? { width: v.width, height: v.height, fit: 'fill' } : { ...v };
210+
'left' in v
211+
? { width: v.width, height: v.height, fit: 'fill' }
212+
: { ...v };
191213
this._constrainSize(max);
192-
if (!this._parsedInfo.upscale) {
193-
this._constrainSize(this._sourceDims);
194-
}
195214
return this;
196215
}
197216

src/calculator/v3.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,44 @@
11
import { CalculatorOptions } from '../contracts';
22
import { Base, ValidatorMap } from './base';
3+
import { IIIFError } from '../error';
34

45
export class Calculator extends Base {
5-
static _matchers (): ValidatorMap {
6+
static _matchers(): ValidatorMap {
67
const result: ValidatorMap = { ...super._matchers() };
7-
result.size = [...result.size].reduce((sizes: string[], pattern: string) => {
8-
if (pattern !== 'full') sizes.push(`\\^?${pattern}`);
9-
return sizes;
10-
}, [] as string[]);
8+
result.size = [...result.size].reduce(
9+
(sizes: string[], pattern: string) => {
10+
if (pattern !== 'full') sizes.push(`\\^?${pattern}`);
11+
return sizes;
12+
},
13+
[] as string[]
14+
);
1115
return result;
1216
}
1317

14-
constructor (dims: { width: number; height: number }, opts: CalculatorOptions = {}) {
18+
constructor(
19+
dims: { width: number; height: number },
20+
opts: CalculatorOptions = {}
21+
) {
1522
super(dims, opts);
1623
this._canonicalInfo.size = 'max';
1724
this._parsedInfo.upscale = false;
1825
}
1926

20-
size (v: string) {
27+
size(v: string) {
2128
if (v[0] === '^') {
2229
this._parsedInfo.upscale = true;
2330
v = v.slice(1, v.length);
2431
}
25-
return super.size(v);
32+
super.size(v);
33+
const { region, size, upscale } = this._parsedInfo;
34+
if (!upscale) {
35+
if (size.width > region.width || size.height > region.height) {
36+
throw new IIIFError('Requested size requires upscaling', {
37+
statusCode: 400
38+
});
39+
}
40+
}
41+
return this;
2642
}
2743
}
2844

src/processor.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,34 @@ export class Processor {
263263
}
264264

265265
async execute () {
266-
if (this.filename === 'info.json') {
267-
return await this.infoJson();
268-
} else {
266+
try {
267+
if (this.format === undefined && this.info === undefined) {
268+
debug('No format or info.json requested; redirecting to info.json');
269+
return {
270+
location: new URL(
271+
path.join(this.id, 'info.json'),
272+
this.baseUrl
273+
).toString(),
274+
redirect: true
275+
};
276+
}
277+
278+
if (this.filename === 'info.json') {
279+
return await this.infoJson();
280+
}
281+
269282
return await this.iiifImage();
283+
} catch (err) {
284+
if (err instanceof IIIFError) {
285+
debug('IIIFError caught: %j', err);
286+
return {
287+
error: true,
288+
message: err.message,
289+
statusCode: err.statusCode || 500
290+
};
291+
} else {
292+
throw err;
293+
}
270294
}
271295
}
272296
}

src/v3/info.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import type { InfoDocInput, InfoDoc } from '../contracts';
66

77
export const profileLink = 'https://iiif.io/api/image/3/level2.json';
88

9-
const defaultFormats: Set<String> = new Set(['jpg', 'png']);
10-
const defaultQualities: Set<String> = new Set(['default']);
9+
const defaultFormats: Set<string> = new Set(['jpg', 'png']);
10+
const defaultQualities: Set<string> = new Set(['default']);
1111
const IIIFExtras = {
1212
extraFeatures: [
1313
'canonicalLinkHeader',

validator/.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.14

0 commit comments

Comments
 (0)