diff --git a/__tests__/presentation-2-parser/upgrade.test.ts b/__tests__/presentation-2-parser/upgrade.test.ts index ebbf288..ac1a508 100644 --- a/__tests__/presentation-2-parser/upgrade.test.ts +++ b/__tests__/presentation-2-parser/upgrade.test.ts @@ -34,6 +34,7 @@ import goettingen from '../../fixtures/presentation-2/uni-goettingen.json'; import villanovaManifest from '../../fixtures/presentation-2/villanova-manifest.json'; import wikimediaProxy from '../../fixtures/presentation-2/wikimedia-proxy.json'; import malformedImageAnnotation from '../../fixtures/presentation-2/malformed-image-annotation.json'; +import stAndrewsMalformed from '../../fixtures/presentation-2/st-andrews-malformed.json'; import { convertPresentation2, presentation2to3 } from '../../src/presentation-2'; describe('Presentation 2 to 3', () => { @@ -2636,4 +2637,38 @@ describe('Presentation 2 to 3', () => { expect(annotation?.motivation).toEqual('painting'); expect(annotation?.body).toBeDefined(); }); + + test('V2 context manifest with V3 structure (items instead of sequences)', () => { + // Some manifests (e.g., St. Andrews) have @context: "http://iiif.io/api/presentation/2/context.json" + // but use v3-style structure with items array instead of v2 sequences[0].canvases. + // The converter should detect this and handle it appropriately. + // Note: We don't validate strictly here because the source manifest may be missing + // required properties like canvas width/height - we just verify the structure converts. + const result = presentation2to3.traverseManifest(stAndrewsMalformed as any); + + // Verify the canvas was correctly converted + expect(result.items).toBeDefined(); + expect(result.items?.length).toBeGreaterThan(0); + + const canvas = result.items?.[0]; + expect(canvas).toBeDefined(); + expect(canvas?.id).toEqual('https://collections.st-andrews.ac.uk/762345/manifest/canvas/406403'); + expect(canvas?.type).toEqual('Canvas'); + + // Verify the annotation page and annotation were correctly converted + const annotationPage = canvas?.items?.[0]; + expect(annotationPage).toBeDefined(); + expect(annotationPage?.type).toEqual('AnnotationPage'); + + const annotation = annotationPage?.items?.[0]; + expect(annotation).toBeDefined(); + expect(annotation?.type).toEqual('Annotation'); + expect(annotation?.motivation).toEqual('painting'); + + // Verify the body (3D model) was correctly converted + const body = annotation?.body as any; + expect(body).toBeDefined(); + expect(body?.id).toEqual('https://collections.st-andrews.ac.uk/media/406403/406403.glb'); + expect(body?.format).toEqual('model/gltf-binary'); + }); }); diff --git a/fixtures/presentation-2/st-andrews-malformed.json b/fixtures/presentation-2/st-andrews-malformed.json new file mode 100644 index 0000000..719cc28 --- /dev/null +++ b/fixtures/presentation-2/st-andrews-malformed.json @@ -0,0 +1,62 @@ +{ + "@context": "http://iiif.io/api/presentation/2/context.json", + "@type": "sc:Manifest", + "@id": "https://collections.st-andrews.ac.uk/762345/manifest", + "label": "Dura Den Fossil Fish", + "description": "Slab containing two fossilised Holopychius flemingi fish, which lived during the Devonian period, around 400 million years ago. The unusual bone structure of the fish, with fins attached to stumps, attracted the attention of prominent scientists including Charles Lyell. This fueled the debate on evolution as they could be interpreted as examples of Darwin's 'Transitional Forms' between fish with fins and animals with legs.

The fossil is unusual as the fish are perfectly formed. Normally as they are subjected to lots of heat and pressure, flattened and often scavenged before found, you do not get a full skeleton. The fish appear clustered together as they were buried during a sandstorm whilst they were forced together as the rivers were drying up.", + "attribution": "© The University of St Andrews", + "logo": "https://collections.st-andrews.ac.uk/uv/logo.png", + "metadata": [ + { "label": "Identifier", "value": "GE1049" }, + { "label": "Title", "value": "Dura Den Fossil Fish" }, + { + "label": "Record URL", + "value": "https://collections.st-andrews.ac.uk/item/dura-den-fossil-fish/762345" + }, + { + "label": "Subjects", + "value": " geology,fossil,fish,dura den" + }, + { "label": "Department", "value": "Museums" }, + { + "label": "Collection", + "value": "Geology Collection" + }, + { "label": "Record level", "value": "Item" }, + { + "label": "Conditions of use", + "value": "CC BY-NC Creative Commons Attribution-NonCommercial 4.0 International Public License" + }, + { "label": "Credit line", "value": "Image Courtesy of the University of St Andrews Library, ID GE1049" }, + { "label": "IRN", "value": "762345" }, + { + "label": "Image delivery", + "value": "Working in partnership with Kakadu to deliver high quality image reproductions via IIIF." + } + ], + "items": [ + { + "id": "https://collections.st-andrews.ac.uk/762345/manifest/canvas/406403", + "type": "Canvas", + "items": [ + { + "id": "https://collections.st-andrews.ac.uk/762345/manifest/canvas/406403/annotationpage/0", + "type": "AnnotationPage", + "items": [ + { + "id": "https://collections.st-andrews.ac.uk/762345/manifest/canvas/406403/annotation/0", + "type": "Annotation", + "motivation": "painting", + "body": { + "id": "https://collections.st-andrews.ac.uk/media/406403/406403.glb", + "type": "Model", + "format": "model/gltf-binary" + }, + "target": "https://collections.st-andrews.ac.uk/762345/manifest/canvas/406403" + } + ] + } + ] + } + ] +} diff --git a/src/presentation-2/traverse.ts b/src/presentation-2/traverse.ts index 20b501c..b072076 100644 --- a/src/presentation-2/traverse.ts +++ b/src/presentation-2/traverse.ts @@ -229,6 +229,18 @@ export class Traverse< } traverseManifestItems(manifest: Manifest): Manifest { + // Handle malformed manifests that have v2 @context but use v3 structure (items instead of sequences). + // Convert v3 items structure to v2 sequences structure before traversal. + if (!manifest.sequences && (manifest as any).items && Array.isArray((manifest as any).items)) { + manifest.sequences = [ + { + '@id': `${manifest['@id']}/sequence/0`, + '@type': 'sc:Sequence', + canvases: (manifest as any).items.map((item: any) => this.convertV3CanvasToV2(item)), + } as any, + ]; + delete (manifest as any).items; + } if (manifest.sequences) { manifest.sequences = manifest.sequences.map((sequence) => this.traverseSequence(sequence)); } @@ -238,6 +250,68 @@ export class Traverse< return manifest; } + /** + * Convert a v3-style Canvas to v2-style Canvas structure. + * Handles manifests with v2 context but v3 structure. + */ + convertV3CanvasToV2(canvas: any): any { + // If it's already v2 style, return as-is + if (canvas['@type'] || canvas['@id']) { + return canvas; + } + + const v2Canvas: any = { + '@id': canvas.id, + '@type': 'sc:Canvas', + label: canvas.label, + height: canvas.height, + width: canvas.width, + }; + + // Convert v3 items (AnnotationPages) to v2 images array + if (canvas.items && Array.isArray(canvas.items)) { + v2Canvas.images = []; + for (const annotationPage of canvas.items) { + if (annotationPage.items && Array.isArray(annotationPage.items)) { + for (const annotation of annotationPage.items) { + v2Canvas.images.push(this.convertV3AnnotationToV2(annotation, canvas.id)); + } + } + } + } + + return v2Canvas; + } + + /** + * Convert a v3-style Annotation to v2-style Annotation structure. + */ + convertV3AnnotationToV2(annotation: any, canvasId: string): any { + return { + '@id': annotation.id, + '@type': 'oa:Annotation', + motivation: annotation.motivation === 'painting' ? 'sc:painting' : annotation.motivation, + on: annotation.target || canvasId, + resource: this.convertV3BodyToV2Resource(annotation.body), + }; + } + + /** + * Convert a v3-style body to v2-style resource. + */ + convertV3BodyToV2Resource(body: any): any { + if (!body) return undefined; + + return { + '@id': body.id, + '@type': body.type === 'Image' ? 'dctypes:Image' : `dctypes:${body.type}`, + format: body.format, + height: body.height, + width: body.width, + service: body.service, + }; + } + traverseSequence(sequence: Sequence): T['Sequence'] { return this.traverseType( this.traverseDescriptive(this.traverseLinking(this.traverseSequenceItems(sequence))),