Skip to content

Commit 78ccb3b

Browse files
committed
IIIF Presentation 4 support
1 parent 49ce5b4 commit 78ccb3b

File tree

62 files changed

+6707
-419
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+6707
-419
lines changed

README.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ npm i @iiif/parser
77
This is a parser and set of low-level utilities for the following IIIF Specifications:
88

99
- [IIIF Presentation 3](https://iiif.io/api/presentation/3.0/) (current)
10+
- [IIIF Presentation 4](https://preview.iiif.io/api/prezi-4/presentation/4.0/) (preview support via `/presentation-4`)
1011
- [IIIF Image 3](https://iiif.io/api/image/3.0/) (current)
1112
- [IIIF Presentation 2](https://iiif.io/api/presentation/2.1/)
1213

@@ -16,8 +17,7 @@ These include:
1617

1718

1819
> [!NOTE]
19-
> A new version of the IIIF Presentation API is being developed (v4) which handles 3D content. This parser will
20-
> support this version soon. You can read about the additions [here](https://github.com/IIIF/3d/blob/main/temp-draft-4.md)
20+
> Presentation API v4 support is available from `@iiif/parser/presentation-4` and is designed for mixed v2.1/v3.0/v4.0 ingestion with a v4 normalization pipeline.
2121
2222
### Features
2323
The features of this library are focussed on encoding the structure of all types of IIIF and providing utilities for extracting data from the IIIF or converting it into another format that is easier to develop with. The aim of the parser is to maximize the IIIF compatibility of other tools built on top of it.
@@ -104,6 +104,35 @@ export type TraversalMap = {
104104
};
105105
```
106106

107+
#### IIIF Presentation 4
108+
109+
- `upgradeToPresentation4()` to ingest v2.1, v3.0 or v4.0 into a v4-compatible shape
110+
- `Traverse` with v4 container support (`Timeline`, `Canvas`, `Scene`)
111+
- `normalize()` with deterministic ID minting for missing IDs
112+
- `validatePresentation4()` with traversal-first diagnostics (`tolerant` or `strict`)
113+
- `serializeConfigPresentation4` for native v4 output
114+
- `serializeConfigPresentation3` for strict v4→v3 downgrade (fails on unsupported v4-only constructs)
115+
- `pnpm run update-cookbook-v4` to refresh local `fixtures/cookbook-v4` from [preview cookbook v4](https://preview.iiif.io/cookbook/v4/)
116+
117+
```ts
118+
import {
119+
upgradeToPresentation4,
120+
normalize,
121+
validatePresentation4,
122+
serialize,
123+
serializeConfigPresentation4
124+
} from '@iiif/parser/presentation-4';
125+
126+
const upgraded = upgradeToPresentation4(loadSomeManifest());
127+
const report = validatePresentation4(upgraded);
128+
const normalized = normalize(upgraded);
129+
const serialized = serialize(
130+
{ entities: normalized.entities, mapping: normalized.mapping, requests: {} },
131+
normalized.resource,
132+
serializeConfigPresentation4
133+
);
134+
```
135+
107136
#### Image 3
108137

109138
The Image 3 parser is adapted from an Image Server implementation, and supports:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import cookbookIndex from '../../fixtures/cookbook-v4/_index.json';
2+
import { promises as fs } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { cwd } from 'node:process';
5+
import { describe, expect, test } from 'vitest';
6+
import { normalize, serialize, serializeConfigPresentation4, validatePresentation4 } from '../../src/presentation-4';
7+
8+
const { readFile } = fs;
9+
10+
describe('Presentation 4 cookbook', function () {
11+
const tests = Object.values(cookbookIndex as Record<string, { id: string; url: string }>).map((item) => [
12+
item.id,
13+
item.url,
14+
]);
15+
16+
test.each(tests)('Testing normalize %p (%p)', async (id: string, url: string) => {
17+
const json = await readFile(join(cwd(), 'fixtures/cookbook-v4', `${id}.json`));
18+
const manifest = JSON.parse(json.toString());
19+
const original = JSON.parse(json.toString());
20+
const normalized = normalize(manifest);
21+
22+
expect(normalized.resource.type).toBe(manifest.type);
23+
expect(normalized.resource.id).toBe(manifest.id);
24+
25+
const report = validatePresentation4(manifest, { mode: 'tolerant' });
26+
const errors = report.issues.filter((issue) => issue.severity === 'error');
27+
expect(errors).toEqual([]);
28+
29+
const reserialized = serialize(
30+
{
31+
entities: normalized.entities as any,
32+
mapping: normalized.mapping as any,
33+
requests: {},
34+
},
35+
normalized.resource,
36+
serializeConfigPresentation4
37+
) as any;
38+
39+
expect(reserialized).toEqual(original);
40+
expect(url).toEqual((cookbookIndex as any)[id].url);
41+
});
42+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { readdirSync, readFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { cwd } from 'node:process';
4+
import { describe, expect, test } from 'vitest';
5+
import { normalize } from '../../src/presentation-4';
6+
7+
const fixtureDir = join(cwd(), 'fixtures/presentation-4');
8+
const fixtureFiles = readdirSync(fixtureDir).filter((file) => file.endsWith('.json'));
9+
10+
describe('presentation-4 normalize', () => {
11+
test.each(fixtureFiles)('normalizes fixture %s', (fixtureName) => {
12+
const fixture = JSON.parse(readFileSync(join(fixtureDir, fixtureName), 'utf8'));
13+
const result = normalize(fixture);
14+
15+
expect(result.resource.type).toBe('Manifest');
16+
expect(result.entities.Manifest[result.resource.id]).toBeTruthy();
17+
expect(Object.keys(result.mapping).length).toBeGreaterThan(1);
18+
});
19+
20+
test('mints deterministic ids for missing resources in tolerant mode', () => {
21+
const input = {
22+
'@context': 'http://iiif.io/api/presentation/4/context.json',
23+
type: 'Manifest',
24+
label: { en: ['No IDs'] },
25+
items: [
26+
{
27+
type: 'Timeline',
28+
duration: 15.5,
29+
items: [
30+
{
31+
type: 'AnnotationPage',
32+
items: [
33+
{
34+
type: 'Annotation',
35+
motivation: 'painting',
36+
body: {
37+
type: 'Sound',
38+
id: 'https://example.org/audio.mp3',
39+
format: 'audio/mp3',
40+
},
41+
target: 'https://example.org/timeline/1',
42+
},
43+
],
44+
},
45+
],
46+
},
47+
],
48+
};
49+
50+
const result = normalize(input as any);
51+
52+
expect(result.resource.id.startsWith('vault://iiif-parser/v4/Manifest/')).toBe(true);
53+
expect(result.diagnostics.some((diagnostic) => diagnostic.code === 'minted-id')).toBe(true);
54+
expect(Object.keys(result.entities.Timeline).length).toBe(1);
55+
});
56+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { performance } from 'node:perf_hooks';
2+
import { describe, expect, test } from 'vitest';
3+
import { normalize, serialize, serializeConfigPresentation4, validatePresentation4 } from '../../src/presentation-4';
4+
5+
function createLargeCanvasManifest(canvasCount = 200, annotationsPerCanvas = 4) {
6+
return {
7+
'@context': 'http://iiif.io/api/presentation/4/context.json',
8+
id: 'https://example.org/iiif/perf/canvas-manifest',
9+
type: 'Manifest',
10+
label: { en: ['Large canvas manifest'] },
11+
items: Array.from({ length: canvasCount }).map((_, canvasIndex) => ({
12+
id: `https://example.org/iiif/perf/canvas/${canvasIndex + 1}`,
13+
type: 'Canvas',
14+
width: 4000,
15+
height: 3000,
16+
items: [
17+
{
18+
id: `https://example.org/iiif/perf/canvas/${canvasIndex + 1}/page/1`,
19+
type: 'AnnotationPage',
20+
items: Array.from({ length: annotationsPerCanvas }).map((__, annotationIndex) => ({
21+
id: `https://example.org/iiif/perf/canvas/${canvasIndex + 1}/annotation/${annotationIndex + 1}`,
22+
type: 'Annotation',
23+
motivation: ['painting'],
24+
body: [
25+
{
26+
id: `https://example.org/iiif/perf/image/${canvasIndex + 1}-${annotationIndex + 1}.jpg`,
27+
type: 'Image',
28+
format: 'image/jpeg',
29+
},
30+
],
31+
target: [
32+
{
33+
id: `https://example.org/iiif/perf/canvas/${canvasIndex + 1}`,
34+
type: 'Canvas',
35+
},
36+
],
37+
})),
38+
},
39+
],
40+
})),
41+
};
42+
}
43+
44+
function createLargeSceneManifest(sceneCount = 120, modelsPerScene = 3) {
45+
return {
46+
'@context': 'http://iiif.io/api/presentation/4/context.json',
47+
id: 'https://example.org/iiif/perf/scene-manifest',
48+
type: 'Manifest',
49+
label: { en: ['Large scene manifest'] },
50+
items: Array.from({ length: sceneCount }).map((_, sceneIndex) => ({
51+
id: `https://example.org/iiif/perf/scene/${sceneIndex + 1}`,
52+
type: 'Scene',
53+
items: [
54+
{
55+
id: `https://example.org/iiif/perf/scene/${sceneIndex + 1}/page/1`,
56+
type: 'AnnotationPage',
57+
items: Array.from({ length: modelsPerScene }).map((__, modelIndex) => ({
58+
id: `https://example.org/iiif/perf/scene/${sceneIndex + 1}/annotation/${modelIndex + 1}`,
59+
type: 'Annotation',
60+
motivation: ['painting'],
61+
body: [
62+
{
63+
id: `https://example.org/iiif/perf/model/${sceneIndex + 1}-${modelIndex + 1}.glb`,
64+
type: 'Model',
65+
format: 'model/gltf-binary',
66+
},
67+
],
68+
target: [
69+
{
70+
id: `https://example.org/iiif/perf/scene/${sceneIndex + 1}`,
71+
type: 'Scene',
72+
},
73+
],
74+
})),
75+
},
76+
],
77+
})),
78+
};
79+
}
80+
81+
describe('presentation-4 performance scale tests', () => {
82+
test('normalizes and serializes large canvas manifests', () => {
83+
const manifest = createLargeCanvasManifest();
84+
const started = performance.now();
85+
const normalized = normalize(manifest);
86+
const serialized = serialize<any>(
87+
{
88+
entities: normalized.entities as any,
89+
mapping: normalized.mapping as any,
90+
requests: {},
91+
},
92+
normalized.resource,
93+
serializeConfigPresentation4
94+
);
95+
const report = validatePresentation4(manifest, { mode: 'tolerant' });
96+
const elapsed = performance.now() - started;
97+
98+
expect(report.valid).toBe(true);
99+
expect(normalized.resource.type).toBe('Manifest');
100+
expect(serialized.id).toBe(manifest.id);
101+
expect(Object.keys(normalized.mapping).length).toBeGreaterThan(1500);
102+
expect(elapsed).toBeLessThan(20000);
103+
});
104+
105+
test('normalizes and serializes large scene manifests', () => {
106+
const manifest = createLargeSceneManifest();
107+
const started = performance.now();
108+
const normalized = normalize(manifest);
109+
const serialized = serialize<any>(
110+
{
111+
entities: normalized.entities as any,
112+
mapping: normalized.mapping as any,
113+
requests: {},
114+
},
115+
normalized.resource,
116+
serializeConfigPresentation4
117+
);
118+
const report = validatePresentation4(manifest, { mode: 'tolerant' });
119+
const elapsed = performance.now() - started;
120+
121+
expect(report.valid).toBe(true);
122+
expect(normalized.resource.type).toBe('Manifest');
123+
expect(serialized.id).toBe(manifest.id);
124+
expect(Object.keys(normalized.entities.Scene).length).toBe(120);
125+
expect(elapsed).toBeLessThan(20000);
126+
});
127+
});

0 commit comments

Comments
 (0)