Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6565152
Upgrade isolated Presentation 2 canvases
stephenwf Oct 20, 2026
e6e5686
Protect mixed Presentation 2 language values
stephenwf Oct 20, 2026
fbebefe
Retain invalid Presentation 2 label spellings
stephenwf Oct 20, 2026
005cd77
Preserve string URIs when upgrading Presentation 2 links
stephenwf Oct 20, 2026
cef079e
Retain all Image API selector parameters during upgrade
stephenwf Oct 20, 2026
7472e57
Upgrade related links to resource homepages
stephenwf Oct 20, 2026
2ae609e
Preserve collection pagination across upgrades and serializers
stephenwf Oct 20, 2026
f9a85bc
Preserve linked metadata in Presentation 3 serialization
stephenwf Oct 20, 2026
a384b23
Normalize range selectors against canonical canvas identifiers
stephenwf Oct 20, 2026
93b9358
Upgrade selected annotation bodies without losing their source
stephenwf Oct 20, 2026
1d057ad
Convert legacy audio and video manifests to temporal canvases
stephenwf Oct 20, 2026
f37c2fb
Serialize Presentation 2 annotations without dropping content
stephenwf Oct 20, 2026
668febf
Preserve source type hints when selecting annotation targets
stephenwf Oct 20, 2026
da7e33d
Retain merged links and handle null values safely
stephenwf Oct 20, 2026
e078c5a
Preserve annotation collection fields and authored empty pages
stephenwf Oct 20, 2026
268789c
Retain identifiers when parsing base image service URLs
stephenwf Oct 20, 2026
af25ef8
Cover annotation and image service compatibility paths
stephenwf Oct 20, 2026
a010b07
Updated git repository URL to match for NPM publishing
stephenwf Oct 20, 2026
59ed9cc
Update package.json
stephenwf Oct 20, 2026
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
55 changes: 55 additions & 0 deletions __tests__/image-3-parser/backlog-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
extractFixedSizeScales,
fixedSizesFromScales,
parseImageServiceRequest,
imageServiceRequestToString,
parseRotationParameter,
rotationParameterToString,
parseImageServiceUrl,
parseRegionParameter,
} from "../../src/image-3";

describe("Image API coverage gaps (#29)", () => {
test.each(["!90", "45.5", "0"])("parses and serializes rotation %s", (value) => {
expect(rotationParameterToString(parseRotationParameter(value))).toBe(value);
});

test("normalizes angles outside one revolution and rejects invalid rotation", () => {
expect(parseRotationParameter("!450")).toEqual({ mirror: true, angle: 90 });
expect(() => parseRotationParameter("invalid")).toThrow("Invalid rotation");
});

test("round-trips prefixed info and image requests from the existing Munch examples", () => {
const prefix = "apis/iiif/image/v2";
for (const url of [
"https://munch.emuseum.com/apis/iiif/image/v2/17261/info.json",
"https://munch.emuseum.com/apis/iiif/image/v2/17261/full/max/!90/default.jpg",
]) {
expect(imageServiceRequestToString(parseImageServiceRequest(url, prefix))).toBe(url);
}
expect(() => parseImageServiceUrl("https://example.org/image", "other")).toThrow("Path does not start with prefix");
expect(() => parseImageServiceRequest("https://example.org/image/invalid")).toThrow("Invalid image service URL");
expect(() => parseRegionParameter(null as any)).toThrow("Expected");
});

test("extracts and regenerates advertised fixed sizes", () => {
const sizes = [
{ width: 2000, height: 1000 },
{ width: 1000, height: 500 },
{ width: 500, height: 250 },
];
expect(extractFixedSizeScales(2000, 1000, sizes)).toEqual([1, 2, 4]);
expect(fixedSizesFromScales(2000, 1000, [1, 2, 4])).toEqual(sizes);
expect(fixedSizesFromScales(2000, 1000, [0, 2])).toEqual([sizes[1]]);
});
test("keeps the identifier on base-service URLs with and without prefixes", () => {
for (const [url, prefix] of [
["https://example.org/identifier", ""],
["https://example.org/iiif/identifier", "iiif"],
]) {
const parsed = parseImageServiceRequest(url!, prefix);
expect(parsed).toMatchObject({ type: "base", identifier: "identifier" });
expect(imageServiceRequestToString(parsed)).toBe(url);
}
});
});
25 changes: 12 additions & 13 deletions __tests__/presentation-2-parser/__snapshots__/upgrade.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ exports[`Presentation 2 to 3 > goettingen manifest 1`] = `
"paged",
"paged",
],
"homepage": [
{
"format": "text/html",
"id": "http://kalliope-verbund.info/DE-611-HS-3216958",
"label": {
"none": [
"Kalliope",
],
},
"type": "Text",
},
],
"id": "https://manifests.sub.uni-goettingen.de/id/DE-611-HS-3216958",
"items": [
{
Expand Down Expand Up @@ -2960,18 +2972,6 @@ exports[`Presentation 2 to 3 > goettingen manifest 1`] = `
"navDate": "1902-01-01T00:00:00+01:00",
"provider": [
{
"homepage": [
{
"format": "text/html",
"id": "http://kalliope-verbund.info/DE-611-HS-3216958",
"label": {
"none": [
"Kalliope",
],
},
"type": "Text",
},
],
"id": "http://example.org/provider",
"label": {
"none": [
Expand Down Expand Up @@ -40346,7 +40346,6 @@ exports[`Presentation 2 to 3 > nested ranges 1`] = `
"navDate": "1623-01-01T00:00:00Z",
"provider": [
{
"homepage": undefined,
"id": "http://example.org/provider",
"label": {
"none": [
Expand Down
70 changes: 70 additions & 0 deletions __tests__/presentation-2-parser/image-api-selector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { presentation2to3 } from "../../src/presentation-2";
import type { ContentResourceSelector } from "../../src/presentation-2/types";
import { normalize, serialize, serializeConfigPresentation3 } from "../../src";

const selector = {
"@context": "http://iiif.io/api/annex/openannotation/context.json",
"@type": "iiif:ImageApiSelector",
region: "50,50,100,100",
size: "200,",
rotation: "!90",
quality: "gray",
format: "png",
} satisfies ContentResourceSelector;

describe("Image API selector fields (#27)", () => {
test("preserves every image parameter through upgrade, normalization and serialization", () => {
const annotation = presentation2to3.traverseAnnotation({
"@id": "https://example.org/annotation",
"@type": "oa:Annotation",
motivation: "sc:painting",
on: {
"@type": "oa:SpecificResource",
full: "https://example.org/canvas",
selector,
},
resource: { "@id": "https://example.org/image.png", "@type": "dctypes:Image" },
} as any);
const expected = {
type: "ImageApiSelector",
region: selector.region,
size: selector.size,
rotation: selector.rotation,
quality: selector.quality,
format: selector.format,
};
expect((annotation.target as any).selector).toEqual(expected);
const result = normalize(annotation);
const output = serialize<any>({ ...result, requests: {} }, result.resource, serializeConfigPresentation3);
expect(output.target.selector).toEqual(expected);
});

test("allows selectors that rely on default parameters", () => {
const defaults = { "@context": selector["@context"], "@type": selector["@type"] } satisfies ContentResourceSelector;
const annotation = presentation2to3.traverseAnnotation({
"@id": "https://example.org/defaults",
"@type": "oa:Annotation",
on: { "@type": "oa:SpecificResource", full: "https://example.org/canvas", selector: defaults },
resource: { "@id": "https://example.org/image", "@type": "dctypes:Image" },
} as any);
expect(JSON.parse(JSON.stringify((annotation.target as any).selector))).toEqual({ type: "ImageApiSelector" });
});
});

// Existing consumers can still narrow required region/rotation values with `in`.
function readParameter(input: ContentResourceSelector): string | undefined {
if (input["@type"] === "iiif:ImageApiSelector") {
if ("region" in input) {
const region: string = input.region;
return region;
}
if ("rotation" in input) {
const rotation: string = input.rotation;
return rotation;
}
}
}

test("preserves existing selector parameter narrowing", () => {
expect(readParameter(selector)).toBe(selector.region);
});
32 changes: 32 additions & 0 deletions __tests__/presentation-2-parser/invalid-label.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import collection from "../../fixtures/presentation-2/leipzig-invalid-label-collection.json";
import { convertPresentation2, presentation2to3 } from "../../src/presentation-2";

describe("Presentation 2 @label compatibility (#43)", () => {
test("retains the Leipzig collection's manifest labels", () => {
// Source: https://iiif.ub.uni-leipzig.de/static/collections/misc/cdvost2018.json
const result = convertPresentation2(structuredClone(collection)) as any;
expect(result.items).toHaveLength(collection.manifests.length);
result.items.forEach((item: any, index: number) => {
expect(item.label).toEqual({ none: [collection.manifests[index]!["@label"]] });
});
});

test("preserves empty-label behavior and falls back to @label", () => {
const input = { "@id": "https://example.org/canvas", "@type": "sc:Canvas", label: "" };
expect(presentation2to3.traverseCanvas({ ...input } as any).label).toBeUndefined();
expect(presentation2to3.traverseCanvas({ ...input, "@label": "Fallback" } as any).label).toEqual({
none: ["Fallback"],
});
});

test("prefers label when both spellings are supplied", () => {
expect(
presentation2to3.traverseCanvas({
"@id": "https://example.org/canvas",
"@type": "sc:Canvas",
label: "Correct",
"@label": "Fallback",
} as any).label
).toEqual({ none: ["Correct"] });
});
});
27 changes: 27 additions & 0 deletions __tests__/presentation-2-parser/isolated-canvas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import canvas from "../../fixtures/presentation-2/tpen-isolated-canvas.json";
import { normalize } from "../../src";
import { upgrade } from "../../src/upgrader";
import { convertPresentation2, presentation2to3 } from "../../src/presentation-2";

describe("isolated Presentation 2 canvases (#53)", () => {
test("upgrades the context-free TPEN canvas through the public entrypoints", () => {
// Source: https://t-pen.org/TPEN/canvas/13250383
for (const convert of [convertPresentation2, upgrade]) {
const result = convert(structuredClone(canvas)) as any;
expect(result).toMatchObject({
id: canvas["@id"],
type: "Canvas",
label: { none: [canvas.label] },
width: 666,
height: 1000,
});
expect(result.annotations[0].type).toBe("AnnotationPage");
expect(normalize(result).resource).toEqual({ id: canvas["@id"], type: "Canvas" });
}
});

test("preserves already upgraded resources", () => {
const result = presentation2to3.traverseCanvas(structuredClone(canvas) as any);
expect(convertPresentation2(result)).toBe(result);
});
});
24 changes: 24 additions & 0 deletions __tests__/presentation-2-parser/language-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import manifest from "../../fixtures/presentation-2/iiif-multiple-descriptions.json";
import { convertPresentation2, convertLanguageMapping } from "../../src/presentation-2";

describe("mixed Presentation 2 language values (#39)", () => {
test("retains both descriptions from the official Presentation 2 fixture", () => {
// Source: https://iiif.io/api/presentation/2.1/example/fixtures/6/manifest.json
expect(convertPresentation2(structuredClone(manifest)).summary).toEqual({
none: ["This is one description"],
en: ["This is another"],
});
});

test("retains repeated languages and untagged values in their original order", () => {
expect(
convertLanguageMapping([
"First",
{ "@value": "English", "@language": "en" },
"Second",
{ "@value": "Another English", "@language": "en" },
{ "@value": "Untagged" } as any,
])
).toEqual({ none: ["First", "Second", "Untagged"], en: ["English", "Another English"] });
});
});
54 changes: 54 additions & 0 deletions __tests__/presentation-2-parser/legacy-av.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import audio from "../../fixtures/presentation-2/wellcome-ixif-audio.json";
import video from "../../fixtures/presentation-2/wellcome-ixif-video.json";
import { convertPresentation2 } from "../../src/presentation-2";
import { normalize, serialize, serializeConfigPresentation3 } from "../../src";

describe("legacy IxIF audio and video (#14)", () => {
test.each([
[audio, "Sound", 74.057144],
[video, "Video", 1180],
] as const)("upgrades the real Wellcome $1 manifest", (input, type, duration) => {
const upgraded = convertPresentation2(structuredClone(input)) as any;
expect(upgraded.items).toHaveLength(1);
const canvas = upgraded.items[0];
expect(canvas).toMatchObject({ type: "Canvas", duration });
const annotation = canvas.items[0].items[0];
expect(annotation).toMatchObject({ type: "Annotation", motivation: "painting", target: canvas.id });
const bodies = annotation.body.type === "Choice" ? annotation.body.items : [annotation.body];
expect(bodies.every((body: any) => body.type === type && body.duration === duration)).toBe(true);
expect(bodies.map((body: any) => body.id)).toEqual(
[input.mediaSequences[0]!.elements[0]!.rendering].flat().map((body) => body["@id"])
);
expect(bodies.every((body: any) => body.id !== canvas.id)).toBe(true);
expect(canvas.thumbnail[0].id).toBe(input.mediaSequences[0]!.elements[0]!.thumbnail);
if (type === "Video") expect(canvas).toMatchObject({ width: 720, height: 720 });
const result = normalize(structuredClone(upgraded));
const output = serialize<any>({ ...result, requests: {} }, result.resource, serializeConfigPresentation3);
expect(output.items[0].duration).toBe(duration);
expect(output.items[0].items[0].items[0].body).toMatchObject({ type: annotation.body.type });
});

test("retains real image sequences and keeps the placeholder for unsupported media", () => {
const input = structuredClone(audio) as any;
input.sequences.push({
"@type": "sc:Sequence",
canvases: [{ "@id": "https://example.org/cover", "@type": "sc:Canvas", label: "Cover", width: 1, height: 1 }],
});
expect((convertPresentation2(structuredClone(input)) as any).items.map((item: any) => item.id)).toContain(
"https://example.org/cover"
);
input.mediaSequences[0].elements[0]["@type"] = "foaf:Document";
input.mediaSequences[0].elements[0].format = "application/pdf";
const unsupported = convertPresentation2(input) as any;
expect(unsupported.items).toHaveLength(2);
expect(unsupported.items[0].id).toContain("ixif-message");
});

test("does not invent a duration when the media length is unavailable", () => {
const input = structuredClone(audio) as any;
input.mediaSequences[0].elements[0].metadata = [];
const output = convertPresentation2(input) as any;
expect(output.items[0].id).toContain("ixif-message");
expect(output.items[0].duration).toBeUndefined();
});
});
32 changes: 32 additions & 0 deletions __tests__/presentation-2-parser/linking-string.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import manifest from "../../fixtures/presentation-2/iiif-string-seealso.json";
import { convertPresentation2, presentation2to3 } from "../../src/presentation-2";

describe("Presentation 2 linking strings (#40)", () => {
test("retains the seeAlso URI from the official Presentation 2 fixture", () => {
// Source: https://iiif.io/api/presentation/2.1/example/fixtures/8/manifest.json
expect(convertPresentation2(structuredClone(manifest)).seeAlso).toEqual([
{ id: manifest.seeAlso, type: "unknown" },
]);
});

test("handles mixed links without losing object metadata", () => {
const result = presentation2to3.traverseManifest({
"@id": "https://example.org/manifest",
"@type": "sc:Manifest",
seeAlso: [
"https://example.org/metadata",
{
"@id": "https://example.org/data.json",
format: "application/json",
label: "Data",
},
],
rendering: "https://example.org/book.pdf",
} as any);
expect(result.seeAlso).toEqual([
{ id: "https://example.org/metadata", type: "unknown" },
{ id: "https://example.org/data.json", type: "Dataset", format: "application/json", label: { none: ["Data"] } },
]);
expect(result.rendering).toEqual([{ id: "https://example.org/book.pdf", type: "unknown" }]);
});
});
46 changes: 46 additions & 0 deletions __tests__/presentation-2-parser/related-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import manifest from "../../fixtures/presentation-2/wellcome-ixif-audio.json";
import { convertPresentation2, presentation2to3 } from "../../src/presentation-2";
import { normalize, serialize, serializeConfigPresentation3 } from "../../src";

describe("Presentation 2 related links (#52)", () => {
test("puts the Wellcome catalogue link on the manifest homepage", () => {
// Source: https://wellcomelibrary.org/iiif/b17307922/manifest
const upgraded = convertPresentation2(structuredClone(manifest));
const expected = [
{
id: manifest.related["@id"],
type: "Text",
format: "text/html",
label: { none: [manifest.related.label] },
},
];
expect(upgraded.homepage).toEqual(expected);
expect(upgraded.provider?.[0]?.logo?.[0]).toMatchObject({ id: manifest.logo, type: "Image" });
expect(upgraded.provider?.[0]?.homepage).toBeUndefined();
const result = normalize(upgraded);
expect(serialize<any>({ ...result, requests: {} }, result.resource, serializeConfigPresentation3).homepage).toEqual(
expected
);
});

test("retains every related link and avoids a provider when no logo exists", () => {
const upgraded = presentation2to3.traverseManifest({
"@id": "https://example.org/manifest",
"@type": "sc:Manifest",
label: "Manifest",
related: [
"https://example.org/one",
{
"@id": "https://example.org/two",
format: "text/html",
label: "Two",
},
],
} as any);
expect(upgraded.homepage).toEqual([
{ id: "https://example.org/one", type: "Text" },
{ id: "https://example.org/two", type: "Text", format: "text/html", label: { none: ["Two"] } },
]);
expect(upgraded.provider).toBeUndefined();
});
});
Loading
Loading