Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for IIIF Presentation API 3.0 #224

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
99 changes: 93 additions & 6 deletions src/state/sagas.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,24 @@ import { getPageColors } from '../lib/color';

const charFragmentPattern = /^(.+)#char=(\d+),(\d+)$/;

/** Check if an annotation has external resources that need to be loaded */
function hasExternalResource(anno) {
/** Check if an annotation v2 has external resources that need to be loaded */
function hasExternalResourceV2(anno) {
return (
anno.resource?.chars === undefined &&
anno.body?.value === undefined &&
Object.keys(anno.resource).length === 1 &&
anno.resource['@id'] !== undefined
);
}

/** Check if an annotation v3 has external resources that need to be loaded */
function hasExternalResourceV3(anno) {
return (
anno.body?.value === undefined &&
Object.keys(anno.body).length === 1 &&
anno.body.id !== undefined
);
}

/** Checks if a given resource points to an ALTO OCR document */
const isAlto = (resource) =>
resource &&
Expand All @@ -54,6 +62,12 @@ const isHocr = (resource) =>
resource.profile.startsWith('http://kba.cloud/hocr-spec/') ||
resource.profile.startsWith('http://kba.github.io/hocr-spec/'))));

/** Checks if a given annotationJson has the type "AnnotationPage", introduced
* in IIIF v3 (and therefore assumes IIIF 3.0).
* @param annotationJson Annotation-like json sturct
*/
const naiveIIIFv3Check = (annotationJson) => annotationJson?.type === 'AnnotationPage';

/** Wrapper around fetch() that returns the content as text */
export async function fetchOcrMarkup(url) {
const resp = await fetch(url);
Expand All @@ -80,7 +94,7 @@ export function* discoverExternalOcr({ visibleCanvases: visibleCanvasIds, window
: [canvas.__jsonld.seeAlso]
).filter((res) => isAlto(res) || isHocr(res))[0];
if (seeAlso !== undefined) {
const ocrSource = seeAlso['@id'];
const ocrSource = seeAlso.id ?? seeAlso['@id']; // IIIF 3.0 compat (id vs @id)
const alreadyHasText = texts[canvas.id]?.source === ocrSource;
if (alreadyHasText) {
// eslint-disable-next-line no-continue
Expand Down Expand Up @@ -124,7 +138,22 @@ export async function fetchAnnotationResource(url) {

/** Saga for fetching external annotation resources */
export function* fetchExternalAnnotationResources({ targetId, annotationId, annotationJson }) {
if (!annotationJson.resources.some(hasExternalResource)) {
if (naiveIIIFv3Check(annotationJson)) {
// We treat this as IIIF 3.0
yield fetchExternalAnnotationResourceIIIFv3({ targetId, annotationId, annotationJson });
Copy link
Member

@jbaiter jbaiter Dec 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you verify that this works? I think delegating to other sagas should either use the call effect or yield* to yield all of the effects from the delegated saga?

} else {
// We treat this as IIIF 2.x
yield fetchExternalAnnotationResourcesIIIFv2({ targetId, annotationId, annotationJson });
}
}

/** Fetching external annotation resources IIIF 2.x style */
export function* fetchExternalAnnotationResourcesIIIFv2({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this needs to be exported, since it's an internal API, non?

targetId,
annotationId,
annotationJson,
}) {
if (!annotationJson.resources.some(hasExternalResourceV2)) {
return;
}
const resourceUris = uniq(
Expand All @@ -133,7 +162,7 @@ export function* fetchExternalAnnotationResources({ targetId, annotationId, anno
const contents = yield all(resourceUris.map((uri) => call(fetchAnnotationResource, uri)));
const contentMap = Object.fromEntries(contents.map((c) => [c.id ?? c['@id'], c]));
const completedAnnos = annotationJson.resources.map((anno) => {
if (!hasExternalResource(anno)) {
if (!hasExternalResourceV2(anno)) {
return anno;
}
const match = anno.resource['@id'].match(charFragmentPattern);
Expand All @@ -151,8 +180,48 @@ export function* fetchExternalAnnotationResources({ targetId, annotationId, anno
);
}

/** Fetching external annotation resources IIIF 3.0 style */
export function* fetchExternalAnnotationResourceIIIFv3({ targetId, annotationId, annotationJson }) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as for v2, I don't the export is needed.

if (!annotationJson.items.some(hasExternalResourceV3)) {
return;
}
const resourceUris = uniq(annotationJson.items.map((anno) => anno.body.id.split('#')[0]));

console.log(resourceUris);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that should be in there :-)

const contents = yield all(resourceUris.map((uri) => call(fetchAnnotationResource, uri)));
const contentMap = Object.fromEntries(contents.map((c) => [c.id ?? c['@id'], c]));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the c.id ?? c['@id'] check neccessary given that we know we're operating in a IIIFv3 context?

const completedAnnos = annotationJson.items.map((anno) => {
if (!hasExternalResourceV3(anno)) {
return anno;
}
const match = anno.body.id.match(charFragmentPattern);
if (!match) {
return { ...anno, resource: contentMap[anno.body.id] ?? anno.resource };
}
const wholeResource = contentMap[match[1]];
const startIdx = Number.parseInt(match[2], 10);
const endIdx = Number.parseInt(match[3], 10);
const partialContent = wholeResource.value.substring(startIdx, endIdx);
return { ...anno, resource: { ...anno.resource, value: partialContent } };
});
yield put(
receiveAnnotation(targetId, annotationId, { ...annotationJson, resources: completedAnnos })
);
}

/** Saga for processing texts from IIIF annotations */
export function* processTextsFromAnnotations({ targetId, annotationId, annotationJson }) {
if (naiveIIIFv3Check(annotationJson)) {
// IIIF v3
yield processTextsFromAnnotationsIIIFv3({ targetId, annotationId, annotationJson });
} else {
// IIIF v2 and Europeana IIIF 2.0
yield processTextsFromAnnotationsIIIFv2({ targetId, annotationId, annotationJson });
}
}

/** Saga for processing texts from IIIF annotations IIIF 2.x */
export function* processTextsFromAnnotationsIIIFv2({ targetId, annotationId, annotationJson }) {
// Check if the annotation contains "content as text" resources that
// we can extract text with coordinates from
const contentAsTextAnnos = annotationJson.resources.filter(
Expand All @@ -168,6 +237,24 @@ export function* processTextsFromAnnotations({ targetId, annotationId, annotatio
}
}

/** Saga for processing texts from IIIF annotations IIIF 3.0 */
export function* processTextsFromAnnotationsIIIFv3({ targetId, annotationId, annotationJson }) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as for the other v2/v3 sagas, I don't think the export is needed.

// Check if the annotation contains "TextualBody" resources that
// we can extract text with coordinates from
const contentAsTextAnnos = annotationJson.items.filter(
(anno) =>
anno.motivation === 'supplementing' &&
anno.type === 'Annotation' &&
anno.body &&
anno.body.type === 'TextualBody' // See https://www.w3.org/TR/annotation-model/#embedded-textual-body
Copy link
Member

@jbaiter jbaiter Dec 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think those two can be simplified to anno.body?.type === 'TextualBody'

);

if (contentAsTextAnnos.length > 0) {
const parsed = yield call(parseIiifAnnotations, contentAsTextAnnos);
yield put(receiveText(targetId, annotationId, 'annos', parsed));
}
}

/** Saga for requesting texts when display or selection is newly enabled */
export function* onConfigChange({ payload, id: windowId }) {
const { enabled, selectable, visible } = payload.textOverlay ?? {};
Expand Down