diff --git a/README.md b/README.md index 236e493..d3386bd 100644 --- a/README.md +++ b/README.md @@ -327,3 +327,24 @@ upgrade(presentation2Manifest); // Presentation 3 Manifest or Collection // throws Presentation4CompatibilityError with the same diagnostics. const diagnostics = getPresentation3CompatibilityDiagnostics(presentation4Manifest); ``` + +### Downgrade Presentation 3 to Presentation 2 + +```ts +import { presentation3to2downgrade } from "@iiif/parser/presentation-2"; + +const manifest2 = presentation3to2downgrade(manifest3); +``` + +The helper normalizes and serializes a resource without mutating the input. It also +accepts individual canvases, annotation pages, annotations and ranges. Annotation +pages become v2 AnnotationLists with `resources`, `resource`, `chars` and `on` +(including region fragments); `supplementing` becomes `sc:painting`. + +This is a best-effort Presentation JSON conversion, not a service proxy. Service +URLs and protocol versions are preserved, including Search 2 and Image 3. A v2 +viewer that needs Search 1 or Image 2 still needs compatible service endpoints or +a bridge. External annotation-page URLs are preserved too: convert the responses +at those URLs separately, or embed the pages before downgrading. Features without +a Presentation 2 equivalent (such as temporal canvas playback) are not made +compatible by this helper. diff --git a/__tests__/presentation-3-parser/downgrade.test.ts b/__tests__/presentation-3-parser/downgrade.test.ts new file mode 100644 index 0000000..d5abb2e --- /dev/null +++ b/__tests__/presentation-3-parser/downgrade.test.ts @@ -0,0 +1,196 @@ +import { presentation3to2downgrade } from "../../src/presentation-2"; +import type { Manifest } from "../../src/presentation-3/types"; +import type { Manifest as Manifest2 } from "../../src/presentation-2/types"; +import referencedAnnotations from "../../fixtures/cookbook/0269-embedded-or-referenced-annotations-annotationpage.json"; + +const id = "https://example.org/manifest"; + +function manifest(properties: object = {}) { + return { id, type: "Manifest", label: { en: ["Book"] }, items: [], ...properties }; +} + +describe("Presentation 3 to 2 downgrade", () => { + test("exports a typed, non-mutating normalize + serialize helper", () => { + const input: Manifest = { id, type: "Manifest", label: { en: ["Book"] }, items: [] }; + const before = structuredClone(input); + const output: Manifest2 = presentation3to2downgrade(input); + expect(output["@type"]).toBe("sc:Manifest"); + expect(output.sequences[0]?.canvases).toEqual([]); + expect(input).toEqual(before); + }); + + test("keeps search protocols, URLs, extension profiles and nested services", () => { + const output = presentation3to2downgrade( + manifest({ + service: [ + { + id: `${id}/search`, + type: "SearchService2", + service: [{ id: `${id}/autocomplete`, type: "AutoCompleteService2" }], + }, + { + id: `${id}/search-v1`, + type: "SearchService1", + service: { id: `${id}/autocomplete-v1`, type: "AutoCompleteService1" }, + }, + { id: `${id}/custom`, type: "CustomService", profile: "https://example.org/custom", extra: true }, + { "@id": `${id}/auth`, "@type": "AuthCookieService1", profile: "http://iiif.io/api/auth/1/login" }, + ], + }) + ) as any; + expect(output.service).toEqual([ + { + "@id": `${id}/search`, + "@type": "SearchService2", + "@context": "http://iiif.io/api/search/2/context.json", + service: { + "@id": `${id}/autocomplete`, + "@type": "AutoCompleteService2", + "@context": "http://iiif.io/api/search/2/context.json", + }, + }, + { + "@id": `${id}/search-v1`, + "@type": "SearchService1", + "@context": "http://iiif.io/api/search/1/context.json", + profile: "http://iiif.io/api/search/1/search", + service: { + "@id": `${id}/autocomplete-v1`, + "@type": "AutoCompleteService1", + "@context": "http://iiif.io/api/search/1/context.json", + profile: "http://iiif.io/api/search/1/autocomplete", + }, + }, + { "@id": `${id}/custom`, "@type": "CustomService", profile: "https://example.org/custom", extra: true }, + { "@id": `${id}/auth`, "@type": "AuthCookieService1", profile: "http://iiif.io/api/auth/1/login" }, + ]); + }); + + test("uses canonical image profiles without relabelling Image 3 endpoints", () => { + const output = presentation3to2downgrade( + manifest({ + service: [ + { id: `${id}/image2`, type: "ImageService2", profile: "level1" }, + { id: `${id}/image3`, type: "ImageService3", profile: "level2" }, + { "@id": `${id}/legacy`, profile: ["http://iiif.io/api/image/2/level0.json", { supports: ["sizeByWh"] }] }, + ], + }) + ) as any; + expect(output.service[0]).toEqual({ + "@id": `${id}/image2`, + "@context": "http://iiif.io/api/image/2/context.json", + profile: "http://iiif.io/api/image/2/level1.json", + }); + expect(output.service[1]).toMatchObject({ + "@type": "ImageService3", + "@context": "http://iiif.io/api/image/3/context.json", + profile: "level2", + }); + expect(output.service[2].profile).toEqual(["http://iiif.io/api/image/2/level0.json", { supports: ["sizeByWh"] }]); + }); + + test.each(["TextualBody", "Text"])("converts %s line transcriptions to v2 lists, chars and xywh targets", (type) => { + const page = { + id: `${id}/lines`, + type: "AnnotationPage", + items: [ + { + id: `${id}/line1`, + type: "Annotation", + motivation: "supplementing", + body: { type, value: "First line", language: "en" }, + target: `${id}/canvas#xywh=10,20,300,40`, + }, + { + id: `${id}/line2`, + type: "Annotation", + motivation: "supplementing", + body: { type, value: "
Second line
", format: "text/html" }, + target: { + type: "SpecificResource", + source: `${id}/canvas`, + selector: { type: "FragmentSelector", value: "xywh=10,60,300,40" }, + }, + }, + ], + }; + const output = presentation3to2downgrade( + manifest({ + items: [{ id: `${id}/canvas`, type: "Canvas", width: 400, height: 800, items: [], annotations: [page] }], + }) + ) as any; + const list = output.sequences[0].canvases[0].otherContent[0]; + expect(list["@type"]).toBe("sc:AnnotationList"); + expect(list.resources[0]).toMatchObject({ + "@type": "oa:Annotation", + motivation: "sc:painting", + on: `${id}/canvas#xywh=10,20,300,40`, + resource: { "@type": "cnt:ContentAsText", chars: "First line", format: "text/plain", language: "en" }, + }); + expect(list.resources[1]).toMatchObject({ + on: `${id}/canvas#xywh=10,60,300,40`, + resource: { "@type": "cnt:ContentAsText", chars: "Second line
", format: "text/html" }, + }); + expect((presentation3to2downgrade(page) as any).resources).toEqual(list.resources); + expect(JSON.stringify(output)).not.toMatch(/vault:\/\/|TextualBody|supplementing/); + }); + + test("retains nested range membership and standalone resource contexts", () => { + const output = presentation3to2downgrade({ + id: `${id}/range`, + type: "Range", + items: [ + { + id: `${id}/chapter`, + type: "Range", + label: { en: ["Chapter"] }, + items: [{ id: `${id}/canvas`, type: "Canvas" }], + }, + ], + }) as any; + expect(output["@context"]).toBe("http://iiif.io/api/presentation/2/context.json"); + expect(output.members[0]).toMatchObject({ + "@type": "sc:Range", + label: "Chapter", + canvases: [`${id}/canvas`], + within: `${id}/range`, + }); + const canvas = presentation3to2downgrade({ + id: `${id}/canvas`, + type: "Canvas", + width: 100, + height: 100, + items: [], + }) as any; + expect(canvas["@context"]).toBe("http://iiif.io/api/presentation/2/context.json"); + }); + + test("converts external annotation page responses separately", () => { + const output = presentation3to2downgrade(referencedAnnotations) as any; + expect(output["@context"]).toBe("http://iiif.io/api/presentation/2/context.json"); + expect(output.resources[0].resource.chars).toBe(referencedAnnotations.items[0]!.body.value); + }); + + test("preserves rights, viewing hints, links, provider logos and start fragments", () => { + const output = presentation3to2downgrade( + manifest({ + rights: "https://example.org/license", + behavior: ["paged"], + homepage: [{ id: `${id}/home`, type: "Text", format: "text/html" }], + provider: [{ id: `${id}/provider`, type: "Agent", logo: [{ id: `${id}/logo.png`, type: "Image" }] }], + start: { + type: "SpecificResource", + source: { id: `${id}/canvas`, type: "Canvas" }, + selector: { type: "FragmentSelector", value: "xywh=10,20,30,40" }, + }, + }) + ) as any; + expect(output).toMatchObject({ + license: "https://example.org/license", + viewingHint: "paged", + related: { "@id": `${id}/home`, "@type": "dctypes:Text" }, + logo: { "@id": `${id}/logo.png` }, + startCanvas: `${id}/canvas#xywh=10,20,30,40`, + }); + }); +}); diff --git a/__tests__/presentation-3-parser/serializer.test.ts b/__tests__/presentation-3-parser/serializer.test.ts index 4933a8d..43f8564 100644 --- a/__tests__/presentation-3-parser/serializer.test.ts +++ b/__tests__/presentation-3-parser/serializer.test.ts @@ -513,6 +513,18 @@ describe("serializer", () => { "attribution": "Provided by Example Organization", "description": "Book 1, written by Anne Author, published in Paris around 1400.", "label": "Book 1", + "license": "http://creativecommons.org/licenses/by/4.0/", + "logo": { + "@id": "https://example.org/service/inst1/full/max/0/default.png", + "@type": "dctypes:Image", + "format": "image/png", + "service": { + "@context": "http://iiif.io/api/image/3/context.json", + "@id": "https://example.org/service/inst1", + "@type": "ImageService3", + "profile": "level2", + }, + }, "metadata": [ { "label": "Author", @@ -541,14 +553,22 @@ describe("serializer", () => { }, ], "navDate": "1856-01-01T00:00:00Z", + "related": { + "@id": "https://example.org/info/book1/", + "@type": "dctypes:Text", + "format": "text/html", + "label": "Home page for Book 1", + }, "rendering": { "@id": "https://example.org/iiif/book1.pdf", + "@type": "dctypes:Text", "format": "application/pdf", "label": "Download as PDF", }, "seeAlso": { "@id": "https://example.org/library/catalog/book1.xml", "format": "text/xml", + "profile": "https://example.org/profiles/bibliographic", }, "sequences": [ { @@ -566,9 +586,9 @@ describe("serializer", () => { }, ], "service": { - "@context": "http://iiif.io/api/image/2/context.json", "@id": "https://example.org/service/example", - "profile": "http://iiif.io/api/image/2/profiles/https://example.org/docs/example-service.html.json", + "@type": "ExampleExtensionService", + "profile": "https://example.org/docs/example-service.html", }, "startCanvas": "https://example.org/iiif/book1/canvas/p2", "structures": [ @@ -583,12 +603,15 @@ describe("serializer", () => { "@type": "dctypes:Image", "format": "image/jpeg", "service": { - "@context": "http://iiif.io/api/image/2/context.json", + "@context": "http://iiif.io/api/image/3/context.json", "@id": "https://example.org/iiif/book1/page1", - "profile": "http://iiif.io/api/image/2/profiles/level1.json", + "@type": "ImageService3", + "profile": "level1", }, }, "viewingDirection": "right-to-left", + "viewingHint": "paged", + "within": "https://example.org/collections/books/", } `); }); diff --git a/src/presentation-2/downgrade.ts b/src/presentation-2/downgrade.ts new file mode 100644 index 0000000..95a32ca --- /dev/null +++ b/src/presentation-2/downgrade.ts @@ -0,0 +1,41 @@ +import { normalize } from "../presentation-3/normalize"; +import { serialize } from "../presentation-3/serialize"; +import { serializeConfigPresentation2 } from "../presentation-3/serialize-presentation-2"; +import type * as Presentation2 from "./types"; +import type * as Presentation3 from "../presentation-3/types"; + +type DowngradedResource = + | Presentation2.Manifest + | Presentation2.Collection + | Presentation2.Canvas + | Presentation2.AnnotationList + | Presentation2.Annotation + | Presentation2.Range + | Presentation2.Layer + | Presentation2.ContentResource; + +type DowngradeResult