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 = T extends Presentation3.Manifest + ? Presentation2.Manifest + : T extends Presentation3.Collection + ? Presentation2.Collection + : T extends Presentation3.Canvas + ? Presentation2.Canvas + : T extends Presentation3.AnnotationPage + ? Presentation2.AnnotationList + : T extends Presentation3.Annotation + ? Presentation2.Annotation + : T extends Presentation3.Range + ? Presentation2.Range + : T extends Presentation3.AnnotationCollection + ? Presentation2.Layer + : DowngradedResource; + +/** + * Normalize a Presentation 3 resource and serialize it as Presentation 2. + * Linked services and external annotation pages still need compatible endpoints; + * this converts JSON, not the responses served at those URLs. + */ +export function presentation3to2downgrade(resource: T): DowngradeResult { + const normalized = normalize(structuredClone(resource)); + return serialize({ ...normalized, requests: {} }, normalized.resource, serializeConfigPresentation2); +} diff --git a/src/presentation-2/index.ts b/src/presentation-2/index.ts index e688a98..0e82281 100644 --- a/src/presentation-2/index.ts +++ b/src/presentation-2/index.ts @@ -2,3 +2,5 @@ export * from "./traverse"; export * from "./upgrader"; export * from "./types"; export { infer } from "./types"; + +export { presentation3to2downgrade } from "./downgrade"; diff --git a/src/presentation-3/serialize-presentation-2.ts b/src/presentation-3/serialize-presentation-2.ts index 2f9a456..6c24143 100644 --- a/src/presentation-3/serialize-presentation-2.ts +++ b/src/presentation-3/serialize-presentation-2.ts @@ -9,7 +9,6 @@ import type { } from "./types"; import type { DescriptiveNormalized, LinkingNormalized } from "../presentation-3-normalized/types"; import type * as Presentation2 from "../presentation-2/types"; -import { compressSpecificResource } from "../shared/compress-specific-resource"; import { IS_EXTERNAL } from "./utilities"; export function languageString2to3( @@ -128,18 +127,33 @@ function convertService(service: any) { }; } - if ("@id" in service) { - const newService = { ...service }; - delete newService["@type"]; - return newService; + const { id, type, service: nested, ...properties } = service; + const serviceType = type || service["@type"]; + const converted: any = { ...properties, "@id": service["@id"] || id }; + if (serviceType) converted["@type"] = serviceType; + + // Only image services get image profiles. Preserve other service protocols: + // changing a declaration cannot turn a Search 2 endpoint into Search 1. + if (/^ImageService[123]$/.test(serviceType)) { + const version = serviceType.slice(-1); + converted["@context"] = service["@context"] || `http://iiif.io/api/image/${version}/context.json`; + if (version !== "3" && /^level[012]$/.test(service.profile)) { + converted.profile = `http://iiif.io/api/image/${version}/${service.profile}.json`; + } + if (version !== "3") delete converted["@type"]; + } else if (/^(Search|AutoComplete)Service[12]$/.test(serviceType)) { + const version = serviceType.endsWith("1") ? "1" : "2"; + converted["@context"] = service["@context"] || `http://iiif.io/api/search/${version}/context.json`; + if (version === "1") { + converted.profile = + service.profile || + `http://iiif.io/api/search/1/${serviceType.startsWith("Search") ? "search" : "autocomplete"}`; + } } - - // @todo support auth. - return { - "@context": "http://iiif.io/api/image/2/context.json", - "@id": service.id, - profile: `http://iiif.io/api/image/2/profiles/${service.profile}.json`, - }; + if (nested) { + converted.service = unNestArray((Array.isArray(nested) ? nested : [nested]).map(convertService)); + } + return converted; } function technicalProperties(props: Partial, type?: string) { @@ -151,10 +165,13 @@ function technicalProperties(props: Partial, type?: string) ["width", props.width], ["viewingDirection", props.viewingDirection !== "left-to-right" ? props.viewingDirection : undefined], - // Non-standard property. - ["license", (props as any).license ? (props as any).license : undefined], - // @todo Viewing hint is merged with behavior - // ['viewingHint', props.] + ["license", (props as any).rights || (props as any).license || undefined], + [ + "viewingHint", + props.behavior?.find((behavior) => + ["individuals", "paged", "continuous", "multi-part", "non-paged", "top", "facing-pages"].includes(behavior) + ), + ], ]; } @@ -177,26 +194,18 @@ function* descriptiveProperties(prop: Partial): Generator ["navDate", prop.navDate], // @todo these needs consideration if the way provider is parsed changes. ["logo", provider ? unNestArray(provider.logo) : undefined], - ["homepage", provider ? provider.homepage : undefined], ["attribution", prop.requiredStatement ? languageString2to3(prop.requiredStatement.value) : undefined], ]; } function* linkingProperties(prop: Partial) { - const startProp = - prop.start && prop.start.type && (prop.start as any).type === "SpecificResource" - ? compressSpecificResource(prop.start as any) - : prop.start; - return [ ["seeAlso", unNestArray(yield prop.seeAlso)], - // @todo support more services (like auth) ["service", unNestArray((prop.service || []).map(convertService))], ["rendering", unNestArray(yield prop.rendering)], - // @todo part of to within - // ['within', unNestArray(yield prop.partOf)], - // @todo this may not work completely. - ["startCanvas", startProp ? startProp.id : undefined], + ["related", unNestArray(yield prop.homepage)], + ["within", unNestArray(prop.partOf?.map((resource) => resource.id))], + ["startCanvas", prop.start ? specificResourceToString(prop.start as any) : undefined], ]; } @@ -235,7 +244,6 @@ export const serializeConfigPresentation2: SerializeConfig = { ...(yield* descriptiveProperties(entity)), ...(yield* linkingProperties(entity)), // Structural. - // @todo structures [ "sequences", [ @@ -250,20 +258,16 @@ export const serializeConfigPresentation2: SerializeConfig = { ]; }, - Canvas: function* (entity) { + Canvas: function* (entity, state, { isTopLevel }) { const paintingPage = yield entity.items; const resources = paintingPage.flatMap((page: any) => page.resources || []); return [ - // Items. + ...(isTopLevel ? [["@context", "http://iiif.io/api/presentation/2/context.json"]] : []), ...technicalProperties(entity, "sc:Canvas"), ...(yield* descriptiveProperties(entity)), ...(yield* linkingProperties(entity)), ["images", resources.length ? resources : undefined], - [ - // @todo use otherContent if they are inlined - "otherContent", - entity.annotations && entity.annotations.length ? yield entity.annotations : undefined, - ], + ["otherContent", entity.annotations && entity.annotations.length ? yield entity.annotations : undefined], ]; }, @@ -294,7 +298,9 @@ export const serializeConfigPresentation2: SerializeConfig = { const motivations = ( Array.isArray(entity.motivation) ? entity.motivation : entity.motivation ? [entity.motivation] : [] ).map((motivation) => - motivation.includes(":") ? motivation : `${motivation === "painting" ? "sc" : "oa"}:${motivation}` + motivation.includes(":") + ? motivation + : `${motivation === "painting" || motivation === "supplementing" ? "sc" : "oa"}:${motivation === "supplementing" ? "painting" : motivation}` ); return [ ...(isTopLevel ? [["@context", "http://iiif.io/api/presentation/2/context.json"]] : []), @@ -309,10 +315,20 @@ export const serializeConfigPresentation2: SerializeConfig = { ContentResource: function* (entity: any) { switch (entity.type) { + case "Text": + if (entity.value === undefined && entity.chars === undefined) { + return [ + ...technicalProperties(entity, "dctypes:Text"), + ...(yield* descriptiveProperties(entity)), + ...(yield* linkingProperties(entity)), + ]; + } + // Text with embedded content is also encountered in legacy transcriptions. case "TextualBody": return [ ...technicalProperties(entity, "cnt:ContentAsText"), ["chars", entity.value ?? entity.chars], + ["format", entity.format || "text/plain"], ["language", entity.language], ...(yield* descriptiveProperties(entity)), ]; @@ -327,7 +343,6 @@ export const serializeConfigPresentation2: SerializeConfig = { ...(yield* descriptiveProperties(entity)), ...(yield* linkingProperties(entity)), ]; - case "Text": case "Dataset": case "Sound": case "Video": @@ -338,11 +353,20 @@ export const serializeConfigPresentation2: SerializeConfig = { entity.type === "Sound" ? "dctypes:Sound" : entity.type === "Video" ? "dctypes:MovingImage" : undefined ), ["chars", entity.chars ?? entity.value], + ["profile", entity.profile], ...(yield* descriptiveProperties(entity)), + ...(yield* linkingProperties(entity)), ]; } }, + Agent: function* (entity) { + return [ + ["@id", entity.id], + ["logo", yield entity.logo], + ]; + }, + AnnotationCollection: function* (entity) { return [ // @todo expand properties if they are actually used. @@ -365,7 +389,7 @@ export const serializeConfigPresentation2: SerializeConfig = { ]; }, - Range: function* (entity) { + Range: function* (entity, state, { isTopLevel }) { const members = []; const canvases = []; @@ -375,6 +399,7 @@ export const serializeConfigPresentation2: SerializeConfig = { if (item) { const canvas = yield item; members.push({ + ...(item.type === "Range" ? canvas : {}), "@id": specificResourceToString(_item), "@type": `sc:${item.type}`, label: canvas ? canvas.label : undefined, @@ -388,6 +413,7 @@ export const serializeConfigPresentation2: SerializeConfig = { } return [ + ...(isTopLevel ? [["@context", "http://iiif.io/api/presentation/2/context.json"]] : []), ...technicalProperties(entity, "sc:Range"), ...(yield* descriptiveProperties(entity)), ...(yield* linkingProperties(entity)),