Skip to content

Commit a1722c5

Browse files
committed
Fixes for image services (v4)
1 parent c677a41 commit a1722c5

File tree

7 files changed

+300
-62
lines changed

7 files changed

+300
-62
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { normalize, serialize, serializeConfigPresentation3, serializeConfigPresentation4 } from '../../src/presentation-4';
3+
4+
function createManifestWithImageService() {
5+
return {
6+
'@context': 'http://iiif.io/api/presentation/4/context.json',
7+
id: 'https://example.org/manifest/service-profile',
8+
type: 'Manifest',
9+
label: { en: ['service profile retention'] },
10+
items: [
11+
{
12+
id: 'https://example.org/canvas/1',
13+
type: 'Canvas',
14+
width: 1000,
15+
height: 1000,
16+
items: [
17+
{
18+
id: 'https://example.org/canvas/1/page/1',
19+
type: 'AnnotationPage',
20+
items: [
21+
{
22+
id: 'https://example.org/canvas/1/annotation/1',
23+
type: 'Annotation',
24+
motivation: ['painting'],
25+
body: [
26+
{
27+
id: 'https://example.org/image/1/full/max/0/default.jpg',
28+
type: 'Image',
29+
format: 'image/jpeg',
30+
service: [
31+
{
32+
id: 'https://example.org/image/1',
33+
type: 'ImageService3',
34+
profile: 'level1',
35+
},
36+
],
37+
},
38+
],
39+
target: ['https://example.org/canvas/1'],
40+
},
41+
],
42+
},
43+
],
44+
},
45+
],
46+
};
47+
}
48+
49+
describe('presentation-4 service profile retention', () => {
50+
test('keeps service profile when serializing to presentation-4', () => {
51+
const normalized = normalize(createManifestWithImageService() as any);
52+
const storedService = normalized.entities.Service['https://example.org/image/1'] as any;
53+
expect(storedService.profile).toBe('level1');
54+
55+
const serialized = serialize<any>(
56+
{
57+
entities: normalized.entities as any,
58+
mapping: normalized.mapping as any,
59+
requests: {},
60+
},
61+
normalized.resource,
62+
serializeConfigPresentation4
63+
);
64+
65+
const serializedService = serialized.items[0].items[0].items[0].body[0].service[0];
66+
expect(serializedService.id).toBe('https://example.org/image/1');
67+
expect(serializedService.type).toBe('ImageService3');
68+
expect(serializedService.profile).toBe('level1');
69+
});
70+
71+
test('keeps service profile when downgrading to presentation-3', () => {
72+
const normalized = normalize(createManifestWithImageService() as any);
73+
74+
const serialized = serialize<any>(
75+
{
76+
entities: normalized.entities as any,
77+
mapping: normalized.mapping as any,
78+
requests: {},
79+
},
80+
normalized.resource,
81+
serializeConfigPresentation3
82+
);
83+
84+
const body = serialized.items[0].items[0].items[0].body;
85+
const serializedBody = Array.isArray(body) ? body[0] : body;
86+
const serializedService = serializedBody.service[0];
87+
expect(serializedService.id).toBe('https://example.org/image/1');
88+
expect(serializedService.type).toBe('ImageService3');
89+
expect(serializedService.profile).toBe('level1');
90+
});
91+
});
Lines changed: 106 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,35 @@
1-
import { describe, expect, test } from "vitest";
2-
import { normalize } from "../../src/presentation-4";
1+
import { describe, expect, test } from 'vitest';
2+
import { normalize, serialize, serializeConfigPresentation4 } from '../../src/presentation-4';
33

4-
describe("presentation-4 specific resource parity", () => {
5-
test("coerces start, range items, and annotation target for v3 input and keeps selectors", () => {
4+
describe('presentation-4 specific resource parity', () => {
5+
test('coerces start, range items, and annotation target for v3 input and keeps selectors', () => {
66
const manifest = {
7-
"@context": "http://iiif.io/api/presentation/3/context.json",
8-
id: "https://example.org/manifest/p3-upgrade",
9-
type: "Manifest",
10-
label: { en: ["p3 specific resource parity"] },
11-
start: "https://example.org/canvas/1#t=5,15",
7+
'@context': 'http://iiif.io/api/presentation/3/context.json',
8+
id: 'https://example.org/manifest/p3-upgrade',
9+
type: 'Manifest',
10+
label: { en: ['p3 specific resource parity'] },
11+
start: 'https://example.org/canvas/1#t=5,15',
1212
items: [
1313
{
14-
id: "https://example.org/canvas/1",
15-
type: "Canvas",
14+
id: 'https://example.org/canvas/1',
15+
type: 'Canvas',
1616
width: 1000,
1717
height: 1000,
1818
items: [
1919
{
20-
id: "https://example.org/canvas/1/page/1",
21-
type: "AnnotationPage",
20+
id: 'https://example.org/canvas/1/page/1',
21+
type: 'AnnotationPage',
2222
items: [
2323
{
24-
id: "https://example.org/canvas/1/annotation/1",
25-
type: "Annotation",
26-
motivation: "painting",
24+
id: 'https://example.org/canvas/1/annotation/1',
25+
type: 'Annotation',
26+
motivation: 'painting',
2727
body: {
28-
id: "https://example.org/image/1.jpg",
29-
type: "Image",
30-
format: "image/jpeg",
28+
id: 'https://example.org/image/1.jpg',
29+
type: 'Image',
30+
format: 'image/jpeg',
3131
},
32-
target: "https://example.org/canvas/1#xywh=10,20,30,40",
32+
target: 'https://example.org/canvas/1#xywh=10,20,30,40',
3333
},
3434
],
3535
},
@@ -38,17 +38,17 @@ describe("presentation-4 specific resource parity", () => {
3838
],
3939
structures: [
4040
{
41-
id: "https://example.org/range/1",
42-
type: "Range",
43-
items: ["https://example.org/canvas/1#t=0,10"],
41+
id: 'https://example.org/range/1',
42+
type: 'Range',
43+
items: ['https://example.org/canvas/1#t=0,10'],
4444
},
4545
],
4646
};
4747

4848
const result = normalize(manifest as any);
49-
const normalizedManifest = result.entities.Manifest["https://example.org/manifest/p3-upgrade"] as any;
50-
const normalizedRange = result.entities.Range["https://example.org/range/1"] as any;
51-
const normalizedAnnotation = result.entities.Annotation["https://example.org/canvas/1/annotation/1"] as any;
49+
const normalizedManifest = result.entities.Manifest['https://example.org/manifest/p3-upgrade'] as any;
50+
const normalizedRange = result.entities.Range['https://example.org/range/1'] as any;
51+
const normalizedAnnotation = result.entities.Annotation['https://example.org/canvas/1/annotation/1'] as any;
5252
const startSelector = Array.isArray(normalizedManifest.start.selector)
5353
? normalizedManifest.start.selector[0]
5454
: normalizedManifest.start.selector;
@@ -59,24 +59,91 @@ describe("presentation-4 specific resource parity", () => {
5959
? normalizedAnnotation.target[0].selector[0]
6060
: normalizedAnnotation.target[0].selector;
6161

62-
expect(normalizedManifest.start.type).toBe("SpecificResource");
63-
expect(normalizedManifest.start.source.id).toBe("https://example.org/canvas/1");
64-
expect(startSelector.type).toBe("FragmentSelector");
65-
expect(startSelector.value).toBe("t=5,15");
62+
expect(normalizedManifest.start.type).toBe('SpecificResource');
63+
expect(normalizedManifest.start.source.id).toBe('https://example.org/canvas/1');
64+
expect(startSelector.type).toBe('FragmentSelector');
65+
expect(startSelector.value).toBe('t=5,15');
6666

67-
expect(normalizedRange.items[0].type).toBe("SpecificResource");
68-
expect(normalizedRange.items[0].source.id).toBe("https://example.org/canvas/1");
69-
expect(rangeSelector.type).toBe("FragmentSelector");
70-
expect(rangeSelector.value).toBe("t=0,10");
67+
expect(normalizedRange.items[0].type).toBe('SpecificResource');
68+
expect(normalizedRange.items[0].source.id).toBe('https://example.org/canvas/1');
69+
expect(rangeSelector.type).toBe('FragmentSelector');
70+
expect(rangeSelector.value).toBe('t=0,10');
7171

72-
expect(normalizedAnnotation.target[0].type).toBe("SpecificResource");
73-
expect(normalizedAnnotation.target[0].source.id).toBe("https://example.org/canvas/1");
74-
expect(targetSelector.type).toBe("FragmentSelector");
75-
expect(targetSelector.value).toBe("xywh=10,20,30,40");
72+
expect(normalizedAnnotation.target[0].type).toBe('SpecificResource');
73+
expect(normalizedAnnotation.target[0].source.id).toBe('https://example.org/canvas/1');
74+
expect(targetSelector.type).toBe('FragmentSelector');
75+
expect(targetSelector.value).toBe('xywh=10,20,30,40');
7676

7777
const selectorId = targetSelector.id;
7878
expect(selectorId).toBeTruthy();
7979
expect(result.entities.Selector[selectorId]).toBeTruthy();
80-
expect(result.mapping[selectorId]).toBe("Selector");
80+
expect(result.mapping[selectorId]).toBe('Selector');
81+
});
82+
83+
test('preserves selector through normalize and serialize for native p4 fragment targets', () => {
84+
const manifest = {
85+
'@context': 'http://iiif.io/api/presentation/4/context.json',
86+
id: 'https://example.org/manifest/p4-fragment-target',
87+
type: 'Manifest',
88+
label: { en: ['native p4 selector parity'] },
89+
items: [
90+
{
91+
id: 'https://example.org/canvas/1',
92+
type: 'Canvas',
93+
width: 1000,
94+
height: 1000,
95+
items: [
96+
{
97+
id: 'https://example.org/canvas/1/page/1',
98+
type: 'AnnotationPage',
99+
items: [
100+
{
101+
id: 'https://example.org/canvas/1/annotation/1',
102+
type: 'Annotation',
103+
motivation: ['commenting'],
104+
body: [
105+
{
106+
type: 'Text',
107+
id: 'https://example.org/body/1',
108+
format: 'text/plain',
109+
},
110+
],
111+
target: ['https://example.org/canvas/1#xywh=11,22,33,44'],
112+
},
113+
],
114+
},
115+
],
116+
},
117+
],
118+
};
119+
120+
const normalized = normalize(manifest as any);
121+
const annotation = normalized.entities.Annotation['https://example.org/canvas/1/annotation/1'] as any;
122+
const target = annotation.target[0];
123+
const targetSelector = Array.isArray(target.selector) ? target.selector[0] : target.selector;
124+
125+
expect(target.type).toBe('SpecificResource');
126+
expect(target.source.id).toBe('https://example.org/canvas/1');
127+
expect(targetSelector.type).toBe('FragmentSelector');
128+
expect(targetSelector.value).toBe('xywh=11,22,33,44');
129+
130+
const serialized = serialize<any>(
131+
{
132+
entities: normalized.entities as any,
133+
mapping: normalized.mapping as any,
134+
requests: {},
135+
},
136+
normalized.resource,
137+
serializeConfigPresentation4
138+
);
139+
const serializedTarget = serialized.items[0].items[0].items[0].target[0];
140+
const serializedSelector = Array.isArray(serializedTarget.selector)
141+
? serializedTarget.selector[0]
142+
: serializedTarget.selector;
143+
144+
expect(serializedTarget.type).toBe('SpecificResource');
145+
expect(serializedTarget.source.id).toBe('https://example.org/canvas/1');
146+
expect(serializedSelector.type).toBe('FragmentSelector');
147+
expect(serializedSelector.value).toBe('xywh=11,22,33,44');
81148
});
82149
});

__tests__/presentation-4-parser/traverse.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,36 @@ describe("presentation-4 traverse", () => {
4343
expect(seen.selector).toBeGreaterThan(0);
4444
expect(seen.contentResource).toBeGreaterThan(0);
4545
});
46+
47+
test("traverses selector on implicit specific resource annotation target", () => {
48+
let selectorCount = 0;
49+
const traverse = new Traverse({
50+
selector: [() => void (selectorCount += 1)],
51+
});
52+
53+
const annotation = {
54+
id: "https://example.org/anno/1",
55+
type: "Annotation",
56+
motivation: ["painting"],
57+
target: [
58+
{
59+
source: {
60+
id: "https://example.org/canvas/1",
61+
type: "Canvas",
62+
},
63+
selector: {
64+
type: "FragmentSelector",
65+
value: "xywh=10,20,30,40",
66+
},
67+
},
68+
],
69+
};
70+
71+
const traversed = traverse.traverseAnnotation(annotation, undefined, "$.annotation");
72+
const target = traversed.target[0];
73+
74+
expect(target.type).toBe("SpecificResource");
75+
expect(target.selector[0].type).toBe("FragmentSelector");
76+
expect(selectorCount).toBe(1);
77+
});
4678
});

src/presentation-4/serialize-presentation-3.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { type SerializeConfig, UNSET } from "./serialize";
12
import { PRESENTATION_3_CONTEXT, sceneComponentTypes } from "./utilities";
2-
import { SerializeConfig } from "./serialize";
33

44
const unsupportedSelectorTypes = new Set(["PointSelector", "WktSelector", "AnimationSelector"]);
55
const unsupportedContainerTypes = new Set(["Scene"]);
@@ -20,12 +20,12 @@ function unsupported(message: string): never {
2020
throw new Error(`Presentation 4 -> 3 downgrade unsupported: ${message}`);
2121
}
2222

23-
function filterList<T>(value: T[] | T | undefined): T[] | undefined {
24-
if (!value) {
23+
function filterList<T>(value: T[] | T | typeof UNSET | undefined): T[] | undefined {
24+
if (!value || value === UNSET) {
2525
return undefined;
2626
}
2727
const list = Array.isArray(value) ? value : [value];
28-
const filtered = list.filter(Boolean);
28+
const filtered = list.filter((item) => item !== UNSET && Boolean(item));
2929
return filtered.length ? filtered : undefined;
3030
}
3131

@@ -103,8 +103,8 @@ function* linkedProperties(entity: any): Generator<any, any, any> {
103103
["thumbnail", filterList(yield entity.thumbnail)],
104104
["provider", filterList(yield entity.provider)],
105105
["seeAlso", filterList(yield entity.seeAlso)],
106-
["service", filterList(entity.service || [])],
107-
["services", filterList(entity.services || [])],
106+
["service", filterList(yield entity.service)],
107+
["services", filterList(yield entity.services)],
108108
["homepage", filterList(yield entity.homepage)],
109109
["rendering", filterList(yield entity.rendering)],
110110
["partOf", filterList(yield entity.partOf)],

src/presentation-4/serialize-presentation-4.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { type SerializeConfig, UNSET } from "./serialize";
12
import { PRESENTATION_4_CONTEXT } from "./utilities";
2-
import { SerializeConfig, UNSET } from "./serialize";
33

44
function filterList<T>(value: T[] | typeof UNSET): T[] | undefined {
55
if (value === UNSET) {
@@ -59,8 +59,8 @@ function* withLinkedProperties(entity: any): Generator<any, Array<[string, any]>
5959
["thumbnail", filterList(yield entity.thumbnail)],
6060
["provider", filterList(yield entity.provider)],
6161
["seeAlso", filterList(yield entity.seeAlso)],
62-
["service", filterList(entity.service || [])],
63-
["services", filterList(entity.services || [])],
62+
["service", filterList(yield entity.service)],
63+
["services", filterList(yield entity.services)],
6464
["homepage", filterList(yield entity.homepage)],
6565
["rendering", filterList(yield entity.rendering)],
6666
["partOf", filterList(yield entity.partOf)],

src/presentation-4/serialize.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ function resolveResource(
5959
}
6060

6161
const id = value.id || value["@id"];
62-
const type = value.type || value["@type"] || state.mapping[id];
62+
const explicitType = value.type || value["@type"];
63+
const mappedType = id ? state.mapping[id] : undefined;
64+
const type = explicitType && state.entities[explicitType] ? explicitType : mappedType || explicitType;
6365
const store = type ? state.entities[type] : undefined;
6466
if (!id || !type || !store) {
6567
return [undefined, undefined];
@@ -102,12 +104,17 @@ export function serialize<Return>(
102104
throw new Error(`Circular reference at ${sub.type}(${sub.id})`);
103105
}
104106

105-
const generator = config[sub.type];
107+
const mappedType = sub.id ? state.mapping[sub.id] : undefined;
108+
const serializerType = config[sub.type] ? sub.type : mappedType && config[mappedType] ? mappedType : sub.type;
109+
const generator = config[serializerType];
106110
if (!generator) {
107111
return UNSET;
108112
}
109113

110-
const [resource, full] = resolveResource(state, sub);
114+
const [resource, full] = resolveResource(
115+
state,
116+
serializerType === sub.type ? sub : { ...sub, type: serializerType }
117+
);
111118
if (!resource) {
112119
return UNSET;
113120
}

0 commit comments

Comments
 (0)