Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
196 changes: 196 additions & 0 deletions __tests__/presentation-3-parser/downgrade.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<p>Second line</p>", 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: "<p>Second line</p>", 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`,
});
});
});
31 changes: 27 additions & 4 deletions __tests__/presentation-3-parser/serializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
{
Expand All @@ -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": [
Expand All @@ -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/",
}
`);
});
Expand Down
41 changes: 41 additions & 0 deletions src/presentation-2/downgrade.ts
Original file line number Diff line number Diff line change
@@ -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> = 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<T>(resource: T): DowngradeResult<T> {
const normalized = normalize(structuredClone(resource));
return serialize({ ...normalized, requests: {} }, normalized.resource, serializeConfigPresentation2);
}
2 changes: 2 additions & 0 deletions src/presentation-2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export * from "./traverse";
export * from "./upgrader";
export * from "./types";
export { infer } from "./types";

export { presentation3to2downgrade } from "./downgrade";
Loading
Loading