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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)

## Unreleased

### Added
- Support for IIIFv3 annotations and OCR references in `rendering` and `seeAlso`

## [1.0.4](https://github.com/dbmdz/mirador-textoverlay/releases/tag/1.0.4) - 2026-06-25

### Fixed
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@
## Requirements for supported IIIF manifests

- Line-level annotations with either one of:
- a `motivation` that is `supplementing` (IIIF v3)
- a `motivation` containing `supplementing` and an embedded `TextualBody`
or external `Text` body (IIIF v3); body arrays and `Choice` bodies are
supported
- a resource that has a `@type` that is `cnt:contentAsText` (IIIF v2)
- a `dcType` that is equal to `Line` (Europeana)
- A per-canvas `seeAlso` entry pointing to the ALTO or hOCR OCR markup for
the page with either:
- Annotation targets expressed as a pixel `xywh` URI fragment or as a
`SpecificResource` with a `FragmentSelector`
- A per-canvas `seeAlso` or `rendering` entry pointing to the ALTO or hOCR OCR
markup for the page with either:
- A `format` that is `application/xml+alto` or `text/vnd.hocr+html`
- A `profile` starting with `http://www.loc.gov/standards/alto/`,
`http://kba.cloud/hocr-spec`, `http://kba.github.io/hocr-spec/` or
Expand Down
65 changes: 65 additions & 0 deletions __fixtures__/iiifv3.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export const canvasId = 'http://example.com/canvas/v3';

export const textualBody = (value, properties = {}) => ({
type: 'TextualBody',
value,
...properties,
});

export const externalTextBody = (id, properties = {}) => ({
format: 'text/plain',
id,
type: 'Text',
...properties,
});

export const annotation = ({
body = textualBody('Transcription'),
motivation = 'supplementing',
target = `${canvasId}#xywh=10,20,100,50`,
...properties
} = {}) => ({ body, motivation, target, type: 'Annotation', ...properties });

export const annotationPage = (items, properties = {}) => ({
items,
type: 'AnnotationPage',
...properties,
});

export const imageCanvas = ({
height = 2000,
id = canvasId,
imageServiceId = 'http://example.com/iiif/image/v3',
ocr = {
seeAlso: [
{
format: 'application/xml+alto',
id: 'http://example.com/ocr/v3',
type: 'Dataset',
},
],
},
width = 1000,
} = {}) => ({
height,
id,
items: [
annotationPage([
annotation({
body: {
format: 'image/jpeg',
height,
id: `${id}/image`,
service: [{ id: imageServiceId, profile: 'level1', type: 'ImageService3' }],
type: 'Image',
width,
},
motivation: 'painting',
target: id,
}),
]),
],
type: 'Canvas',
width,
...ocr,
});
43 changes: 38 additions & 5 deletions __tests__/components/MiradorTextOverlay.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class MockOpenSeadragon {
viewport = {
getBounds: vi.fn(() => ({ x: -10, y: -20 })),
getFlip: vi.fn(() => false),
getZoom: vi.fn(() => 1.33),
getZoom: vi.fn(() => 1.995 / 444),
getRotation: vi.fn(() => 0),
};

Expand All @@ -74,8 +74,20 @@ class MockOpenSeadragon {
const mockCanvasWorld = {
canvasIds: ['canvasA', 'canvasB'],
canvasDimensions: [
{ x: 0, y: 0, width: 200, height: 200 },
{ x: 200, y: 0, width: 200, height: 200 },
{
x: 0,
y: 0,
width: 200,
height: 200,
canvas: { getWidth: () => 300, getHeight: () => 300 },
},
{
x: 200,
y: 0,
width: 200,
height: 200,
canvas: { getWidth: () => 300, getHeight: () => 300 },
},
],
};

Expand Down Expand Up @@ -158,6 +170,27 @@ describe('MiradorTextOverlay', () => {
expect(overlays[0].parentElement.style.transform).toHaveLength(0);
});

it('should not depend on the order of OpenSeadragon world items', () => {
const viewer = new MockOpenSeadragon();
viewer.world.getItemAt.mockImplementation((idx) =>
idx === 0
? { ...viewer.image, source: { dimensions: { x: 600, y: 300 } } }
: viewer.image,
);
renderOverlay({ pageTexts, viewer });

viewer.handlers['update-viewport']();
const overlays = Array.of(...viewer.canvas.querySelectorAll('div > svg:first-of-type')).map(
(element) => element.parentElement,
);
expect(overlays[0]).toHaveStyle({
transform: 'translate(52.95000000000001px, 72.9px) scale(1.33)',
});
expect(overlays[1]).toHaveStyle({
transform: 'translate(451.95000000000005px, 72.9px) scale(1.33)',
});
});

it("should correctly patch the annotation overlay's style if present", () => {
// eslint-disable-next-line prefer-const
const { rerender, props } = renderOverlay({ pageTexts });
Expand Down Expand Up @@ -247,7 +280,7 @@ describe('MiradorTextOverlay', () => {
viewer.world.getItemCount.mockReturnValue(1);
const singleCanvasWorld = {
canvasIds: ['canvasA'],
canvasDimensions: [{ x: 0, y: 0, width: 200, height: 200 }],
canvasDimensions: [mockCanvasWorld.canvasDimensions[0]],
};
const singlePageTexts = [pageTexts[0]];
const { rerender } = renderOverlay({
Expand Down Expand Up @@ -281,7 +314,7 @@ describe('MiradorTextOverlay', () => {
viewer.world.getItemCount.mockReturnValue(1);
const singleCanvasWorld = {
canvasIds: ['canvasA'],
canvasDimensions: [{ x: 0, y: 0, width: 200, height: 200 }],
canvasDimensions: [mockCanvasWorld.canvasDimensions[0]],
};
const { rerender } = renderOverlay({
canvasWorld: singleCanvasWorld,
Expand Down
53 changes: 53 additions & 0 deletions __tests__/lib/iiifAnnotations.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';

import {
asArray,
flattenBodies,
getAnnotationModel,
getBodyId,
getBodyValue,
hasMotivation,
mapBodies,
} from '../../src/lib/iiifAnnotations';

describe('IIIF annotation normalization', () => {
it.each([
[undefined, []],
['supplementing', ['supplementing']],
[['supplementing'], ['supplementing']],
])('normalizes array-valued properties', (value, expected) => {
expect(asArray(value)).toEqual(expected);
});

it.each([
[{ resources: ['v2'] }, { annotations: ['v2'], bodyKey: 'resource', isV3: false }],
[{ items: ['v3'] }, { annotations: ['v3'], bodyKey: 'body', isV3: true }],
])('normalizes annotation page structure', (page, expected) => {
expect(getAnnotationModel(page)).toEqual(expected);
});

it('handles scalar and array motivations', () => {
expect(hasMotivation({ motivation: 'supplementing' }, 'supplementing')).toBe(true);
expect(hasMotivation({ motivation: ['reviewing', 'supplementing'] }, 'supplementing')).toBe(true);
});

it('reads identifiers and text from v2 and v3 bodies', () => {
expect(getBodyId('https://example.org/text')).toBe('https://example.org/text');
expect(getBodyId({ '@id': 'v2' })).toBe('v2');
expect(getBodyId({ id: 'v3' })).toBe('v3');
expect(getBodyValue({ chars: 'v2 text' })).toBe('v2 text');
expect(getBodyValue({ value: 'v3 text' })).toBe('v3 text');
});

it('flattens and transforms arrays and Choice bodies', () => {
const body = {
items: ['external', { type: 'TextualBody', value: 'embedded' }],
type: 'Choice',
};
expect(flattenBodies([body])).toEqual(body.items);
expect(mapBodies(body, (item) => (item === 'external' ? 'resolved' : item))).toEqual({
...body,
items: ['resolved', body.items[1]],
});
});
});
71 changes: 71 additions & 0 deletions __tests__/lib/ocrFormats.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
import { parseOcr, parseAlto, parseHocr, parseIiifAnnotations } from '../../src/lib/ocrFormats';

import contentAsTextAnnos from '../../__fixtures__/anno_iifv2.json';
import { annotation as v3Annotation, textualBody } from '../../__fixtures__/iiifv3';

/** Helper for comparing floats.
*
Expand Down Expand Up @@ -145,4 +146,74 @@ describe('parsing text from IIIF annotations', () => {
expect(parsed.width).toEqual(2261);
expect(parsed.height).toEqual(3309);
});

it('should parse IIIF v3 SpecificResource targets with FragmentSelectors', () => {
const parsed = parseIiifAnnotations([
v3Annotation({
body: textualBody('A transcription line'),
target: {
selector: {
type: 'FragmentSelector',
value: 'xywh=500,1100,3500,100',
},
source: 'https://example.org/canvas/1r',
type: 'SpecificResource',
},
textGranularity: 'line',
}),
]);

expect(parsed).toEqual({
height: 1200,
lines: [
{
height: 100,
text: 'A transcription line',
width: 3500,
x: 500,
y: 1100,
},
],
width: 4000,
});
});

it('should parse object targets, decimal coordinates, and Choice bodies', () => {
const parsed = parseIiifAnnotations([
v3Annotation({
body: {
items: [
textualBody('Preferred text', { language: 'en' }),
textualBody('Alternativer Text', { language: 'de' }),
],
type: 'Choice',
},
target: {
id: 'https://example.org/canvas/1r#xywh=pixel:10.5,20.25,100.5,50.75',
type: 'Canvas',
},
}),
]);

expect(parsed.lines).toEqual([
{
height: 50.75,
text: 'Preferred text',
width: 100.5,
x: 10.5,
y: 20.25,
},
]);
});

it('should ignore annotations without a supported spatial target', () => {
expect(
parseIiifAnnotations([
v3Annotation({
body: textualBody('Whole canvas transcription'),
target: 'https://example.org/canvas/1r',
}),
]),
).toEqual({ height: 0, lines: [], width: 0 });
});
});
Loading