@ndes/iiif-images 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,127 @@
1
- # Temporary Holding Version
1
+ # @ndes/iiif-images
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Enriches SCHEMA-AP-NDE works with the dimensions and sizes of the IIIF image that
4
+ represents them, so a frontend can reserve the right aspect ratio and pick a size
5
+ before the image loads.
6
+
7
+ It is a quad transform for `@lde/search-indexer`. For each work with a IIIF
8
+ Presentation manifest in `associatedMedia`, it reads the manifest, selects an image,
9
+ reads its image service’s `info.json` if it has one and mints a nested
10
+ `representativeImage`.
11
+
12
+ ## Usage
13
+
14
+ Declare the fields in your schema module. The `/schema` entry point has no runtime
15
+ dependencies, so a bundled schema module stays self-contained:
16
+
17
+ ```ts
18
+ import {
19
+ iiifImageTypes,
20
+ representativeImageField,
21
+ } from '@ndes/iiif-images/schema';
22
+
23
+ const creativeWork = defineSearchType({
24
+ name: 'CreativeWork',
25
+ class: 'https://schema.org/CreativeWork',
26
+ fields: [representativeImageField /* … */],
27
+ });
28
+ ```
29
+
30
+ Add `iiifImageTypes` to the schema, then attach the transform after any transform
31
+ that repairs IRIs:
32
+
33
+ ```ts
34
+ import { iiifImages } from '@ndes/iiif-images';
35
+
36
+ const images = iiifImages(schema, { storePath: '/provenance/iiif.sqlite' });
37
+ await images.check();
38
+
39
+ const transform = images.transformFor(creativeWork);
40
+ const pipeline = await createSearchIndexer(config, {
41
+ transforms: {
42
+ CreativeWork: [anyUriToIri(schema), ...(transform ? [transform] : [])],
43
+ },
44
+ });
45
+ ```
46
+
47
+ `transformFor` answers `undefined` for a root type that does not declare
48
+ `representativeImage` and `associatedMedia`, so a deployment on an older schema loses
49
+ the enrichment rather than the run.
50
+
51
+ | Option | Default | Purpose |
52
+ | --------------- | --------------- | -------------------------------------------------------- |
53
+ | `storePath` | required | SQLite file holding resolved facts between runs |
54
+ | `fetchesPerRun` | 500 | Manifests, and separately `info.json` documents, per run |
55
+ | `report` | console warning | Called for each reference whose fetch failed |
56
+
57
+ `storePath` has to be on a durable volume. The store is the only place these facts
58
+ live between runs, and with an empty one every image is fetched from the publisher
59
+ again.
60
+
61
+ ## Consuming the field
62
+
63
+ ```json
64
+ {
65
+ "representativeImage": {
66
+ "url": "https://example.org/iiif/image/full/max/0/default.jpg",
67
+ "width": 1335,
68
+ "height": 2750,
69
+ "sizes": [
70
+ {
71
+ "width": 83,
72
+ "height": 172,
73
+ "url": "https://example.org/iiif/image/full/83,172/0/default.jpg"
74
+ }
75
+ ],
76
+ "service": {
77
+ "url": "https://example.org/iiif/image"
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ - Pick the smallest entry in `sizes` that is at least your target width. These are
84
+ sizes the server lists or its tiles precompute, so they are served fastest. Do not
85
+ rely on the array’s order.
86
+ - Use `service` only for deep zoom or region crops.
87
+ - `url` is the image as the manifest paints it, at full size.
88
+ - A static image, one without an image service, has only `url`, `width` and
89
+ `height`: no `sizes` and no `service`, because it is the only version there is
90
+ and cannot be resized.
91
+ - Without `representativeImage`, use `associatedMedia[].thumbnailUrl`, with no
92
+ dimensions. That is the case for a work with no manifest, no image in its manifest
93
+ with an image service or a stated size, or a fetch that has not succeeded yet.
94
+
95
+ ## Behaviour
96
+
97
+ - **Selection:** the first painted image in the manifest that has an image service
98
+ or whose width and height the manifest states, found by
99
+ [@lde/iiif-image](https://ldelements.org/reference/iiif-image). A static image’s
100
+ size comes from the manifest; an image service’s from its `info.json`. Where that
101
+ `info.json` is gone, the image counts as static, if the manifest states its size.
102
+ An image whose `id` is not an absolute `http(s)` URL is passed over.
103
+ - **Freshness:** manifest facts are refetched after 30 days, because which image
104
+ represents a work can change. `info.json` facts are refetched after 180 days: an
105
+ image’s dimensions do not change, but its server’s size URLs can.
106
+ - **Safety:** fetches go through
107
+ [@lde/public-fetch](https://ldelements.org/reference/public-fetch), which refuses a
108
+ URL, or a redirect target, that is not `http(s)` or names a private address or
109
+ cluster-internal host, and caps a body at 20 MiB. A refused URL or a larger body is
110
+ reported and counts as having no such document. It looks at URLs only, so it does not catch DNS
111
+ pointing inwards. Run the indexer under an egress policy as well.
112
+ - **Failures:** a failed fetch is reported and retried on a later run, and the work
113
+ is indexed without an image. A 401, 403, 404 or 410 counts as having no such
114
+ document and is rechecked after a week, so a brief 401 or 403 can replace a stored
115
+ fact for that long. So does a collection, which has no image of its own, and a
116
+ manifest too broken to read. A 200 that is no IIIF document at all, such as an error
117
+ page served as JSON, counts as a failure, so it is retried every run and five in a
118
+ row stop that dataset’s manifests for the run.
119
+ - **Budget:** `fetchesPerRun` covers all datasets together. What it defers is not
120
+ reported, and those works are indexed without an image until a later run.
121
+ - **Later runs:** retries, deferred works and refreshes all need the dataset to be
122
+ reprocessed. With `PROVENANCE_FILE`, an unchanged dataset waits until it changes
123
+ or `PIPELINE_VERSION` is bumped, so set `fetchesPerRun` high enough to cover it in
124
+ one run.
125
+ - **Limits:** manifest requests are grouped per dataset, because manifest URLs often
126
+ sit on a redirector such as `n2t.net`, and `info.json` requests per host. Datasets
127
+ on one server are limited separately, with only the overall limit across them.
@@ -0,0 +1,2 @@
1
+ export { iiifImages, type IiifImages, type IiifImagesContext, type IiifImagesOptions, type IiifImagesTransform, } from './lib/iiif-images.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,GACzB,MAAM,sBAAsB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { iiifImages, } from './lib/iiif-images.js';
@@ -0,0 +1,52 @@
1
+ import { type ResolutionFailure } from '@lde/resolver';
2
+ import type { RootType, SearchSchema } from '@lde/search';
3
+ import type { Quad } from '@rdfjs/types';
4
+ export interface IiifImagesOptions {
5
+ /**
6
+ * The SQLite file resolved facts live in between runs. It has to survive the
7
+ * run: the search index is rebuilt from it, and nothing else holds these facts.
8
+ */
9
+ readonly storePath: string;
10
+ /**
11
+ * Manifests, and separately `info.json` documents, this object may fetch across
12
+ * all datasets, so construct one per run. Defaults to the resolver’s 500.
13
+ *
14
+ * What the budget defers is not reported, and those works are indexed without an
15
+ * image. A cold store then warms only as datasets are reprocessed, so where
16
+ * unchanged datasets are skipped, set this high enough to cover them in one run.
17
+ */
18
+ readonly fetchesPerRun?: number;
19
+ /** Told about each reference that failed. Defaults to a console warning. */
20
+ readonly report?: (failure: ResolutionFailure) => void;
21
+ }
22
+ /** What the transform reads from the reader it is attached to. */
23
+ export interface IiifImagesContext {
24
+ readonly dataset: {
25
+ readonly iri: URL;
26
+ };
27
+ }
28
+ /** A transform attachable to a reader as an `@lde/pipeline` `QuadTransform`. */
29
+ export type IiifImagesTransform = (quads: AsyncIterable<Quad>, context: IiifImagesContext) => AsyncIterable<Quad>;
30
+ export interface IiifImages {
31
+ /**
32
+ * The transform to attach to a root type’s reader, after any transform that
33
+ * repairs IRIs, or `undefined` where that type does not declare
34
+ * `representativeImage` and `associatedMedia` – so a deployment on an older
35
+ * schema loses the enrichment rather than the run.
36
+ */
37
+ transformFor(rootType: RootType): IiifImagesTransform | undefined;
38
+ /** Throws when the store cannot persist, to fail a run before it starts. */
39
+ check(): Promise<void>;
40
+ }
41
+ /**
42
+ * Enrich works with the image that represents them: its URL and dimensions, and
43
+ * for an image with an image service, a ladder of precomputed sizes, resolved
44
+ * from the work’s IIIF Presentation manifest and the selected image’s
45
+ * `info.json`. A static image is read from the manifest alone.
46
+ *
47
+ * A work with no manifest, or whose resolution has not succeeded, gets no
48
+ * `representativeImage`; consumers then fall back to the `thumbnailUrl` already
49
+ * in `associatedMedia`, and a failed resolution is retried on a later run.
50
+ */
51
+ export declare function iiifImages(schema: SearchSchema, options: IiifImagesOptions): IiifImages;
52
+ //# sourceMappingURL=iiif-images.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iiif-images.d.ts","sourceRoot":"","sources":["../../src/lib/iiif-images.ts"],"names":[],"mappings":"AAWA,OAAO,EAKL,KAAK,iBAAiB,EAEvB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,QAAQ,EAER,YAAY,EAEb,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,IAAI,EAA6B,MAAM,cAAc,CAAC;AASpE,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,IAAI,CAAC;CACxD;AAED,kEAAkE;AAClE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAA;KAAE,CAAC;CACzC;AAED,gFAAgF;AAChF,MAAM,MAAM,mBAAmB,GAAG,CAChC,KAAK,EAAE,aAAa,CAAC,IAAI,CAAC,EAC1B,OAAO,EAAE,iBAAiB,KACvB,aAAa,CAAC,IAAI,CAAC,CAAC;AAEzB,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,YAAY,CAAC,QAAQ,EAAE,QAAQ,GAAG,mBAAmB,GAAG,SAAS,CAAC;IAClE,4EAA4E;IAC5E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,iBAAiB,GACzB,UAAU,CAoHZ"}
@@ -0,0 +1,337 @@
1
+ import { images, readImageService, } from '@lde/iiif-image';
2
+ import { fetchPublic, RefusedUrlError, ResponseTooLargeError, } from '@lde/public-fetch';
3
+ import { createResolver, sqliteFactStore, urlHost, } from '@lde/resolver';
4
+ import { fieldNamed, irAlias, referenceTypeNamed } from '@lde/search/adapter';
5
+ import { DataFactory } from 'rdf-data-factory';
6
+ import { iiifImage, iiifImageService, iiifImageSize, representativeImageField, } from '../schema.js';
7
+ /**
8
+ * Enrich works with the image that represents them: its URL and dimensions, and
9
+ * for an image with an image service, a ladder of precomputed sizes, resolved
10
+ * from the work’s IIIF Presentation manifest and the selected image’s
11
+ * `info.json`. A static image is read from the manifest alone.
12
+ *
13
+ * A work with no manifest, or whose resolution has not succeeded, gets no
14
+ * `representativeImage`; consumers then fall back to the `thumbnailUrl` already
15
+ * in `associatedMedia`, and a failed resolution is retried on a later run.
16
+ */
17
+ export function iiifImages(schema, options) {
18
+ const limits = options.fetchesPerRun === undefined
19
+ ? {}
20
+ : { fetchesPerResolver: options.fetchesPerRun };
21
+ const report = options.report ??
22
+ ((failure) => console.warn(`${failure.key}: ${failure.reason}`));
23
+ const manifests = createResolver({
24
+ key: (reference) => reference.url,
25
+ // Not the manifest’s own host: many are on n2t.net, which only redirects, so
26
+ // its limits and failure cut-off would land on the wrong server and lump
27
+ // publishers together. A dataset is the closest known stand-in for the server
28
+ // at the end of those redirects.
29
+ host: (reference) => reference.dataset,
30
+ fetch: (references, { signal }) => fetchFacts(references.map(({ url }) => url), (url) => url, (document) => {
31
+ try {
32
+ return firstUsable(images(document));
33
+ }
34
+ catch (error) {
35
+ // A document that says it is a collection, or a manifest too broken
36
+ // to read, has no image to offer: a fact about that document. Anything
37
+ // else – an error page served as JSON – is a server misbehaving, so
38
+ // no absence may overwrite the stored fact.
39
+ if (error instanceof TypeError && declaresIiifType(document)) {
40
+ return undefined;
41
+ }
42
+ throw error;
43
+ }
44
+ }, signal, report),
45
+ store: sqliteFactStore({
46
+ path: options.storePath,
47
+ table: 'manifests',
48
+ }),
49
+ // 2: the selected image rather than its service’s URL, and static images too.
50
+ version: '2',
51
+ limits: { ...limits, ttlMs: MANIFEST_TTL_MS },
52
+ report,
53
+ });
54
+ const services = createResolver({
55
+ key: (service) => service,
56
+ host: urlHost,
57
+ fetch: (services, { signal }) => fetchFacts(services, (service) => `${service}/info.json`, (info, service) => {
58
+ // Same reasoning, with more at stake: these facts are otherwise permanent.
59
+ const fact = readImageService(service, info);
60
+ if (fact === undefined) {
61
+ throw new Error(`${service}/info.json states no dimensions`);
62
+ }
63
+ return fact;
64
+ }, signal, report),
65
+ store: sqliteFactStore({
66
+ path: options.storePath,
67
+ table: 'images',
68
+ }),
69
+ limits: { ...limits, ttlMs: IMAGE_TTL_MS },
70
+ report,
71
+ });
72
+ return {
73
+ transformFor(rootType) {
74
+ const aliases = aliasesOf(rootType, schema);
75
+ return aliases === undefined
76
+ ? undefined
77
+ : async function* enrich(quads, context) {
78
+ // A batch of roots is the unit of work and what bounds the memory: a
79
+ // work’s manifest entry is only known once the batch is, and resolving
80
+ // per batch is what lets the resolver share one store read.
81
+ const batch = [];
82
+ for await (const quad of quads) {
83
+ batch.push(quad);
84
+ yield quad;
85
+ }
86
+ const manifestOfWork = manifestsIn(batch, aliases);
87
+ const dataset = context.dataset.iri.toString();
88
+ const selected = await manifests.resolveAll([...manifestOfWork.values()].map((url) => ({ url, dataset })));
89
+ const imageOfWork = new Map();
90
+ for (const [work, manifest] of manifestOfWork) {
91
+ const resolution = selected.get(manifest);
92
+ if (resolution?.outcome === 'resolved') {
93
+ imageOfWork.set(work, resolution.fact);
94
+ }
95
+ }
96
+ const resolved = await services.resolveAll([...imageOfWork.values()].flatMap(({ service }) => service === undefined ? [] : [service]));
97
+ for (const [work, image] of imageOfWork) {
98
+ const representative = representativeOf(image, resolved);
99
+ if (representative !== undefined) {
100
+ yield* mint(factory.namedNode(work), representative, aliases);
101
+ }
102
+ }
103
+ };
104
+ },
105
+ async check() {
106
+ await manifests.check();
107
+ await services.check();
108
+ },
109
+ };
110
+ }
111
+ const factory = new DataFactory();
112
+ const INTEGER = factory.namedNode('http://www.w3.org/2001/XMLSchema#integer');
113
+ /** What the profile’s `encodingFormat` for a manifest entry contains. */
114
+ const IIIF_PRESENTATION_CONTEXT = 'iiif.io/api/presentation';
115
+ /** A manifest’s selection can change, so it is reconsidered after 30 days. */
116
+ const MANIFEST_TTL_MS = 30 * 24 * 3_600_000;
117
+ /**
118
+ * An image’s dimensions do not change, but what its server offers does: an upgrade
119
+ * can change the size URLs stored with them. So these facts are reconsidered too,
120
+ * only rarely.
121
+ */
122
+ const IMAGE_TTL_MS = 180 * 24 * 3_600_000;
123
+ const IIIF_TYPES = new Set([
124
+ 'Manifest',
125
+ 'Collection',
126
+ 'sc:Manifest',
127
+ 'sc:Collection',
128
+ ]);
129
+ /** Answers meaning this document is not ours to have, now or on a retry. */
130
+ const ABSENT_STATUSES = new Set([401, 403, 404, 410]);
131
+ /**
132
+ * The first image that can represent the work: one with an image service, whose
133
+ * `info.json` states its dimensions, or a static image whose manifest states
134
+ * them. Without a size to state, a static image adds nothing to the
135
+ * `thumbnailUrl` consumers already have. Its URL is minted as an IRI, so an image
136
+ * whose `id` is not an absolute web address is passed over.
137
+ */
138
+ function firstUsable(painted) {
139
+ for (const image of painted) {
140
+ const url = webAddress(image.url);
141
+ if (url !== undefined &&
142
+ (image.service !== undefined || staticOf(image) !== undefined)) {
143
+ return { ...image, url };
144
+ }
145
+ }
146
+ return undefined;
147
+ }
148
+ /**
149
+ * The image to mint, or `undefined` while its image service is not resolved.
150
+ * An image service found to be gone leaves the size the manifest states, if any.
151
+ */
152
+ function representativeOf(image, resolved) {
153
+ const resolution = image.service === undefined ? undefined : resolved.get(image.service);
154
+ if (resolution?.outcome === 'resolved') {
155
+ return { ...resolution.fact, url: image.url, service: resolution.fact.url };
156
+ }
157
+ return image.service === undefined || resolution?.outcome === 'absent'
158
+ ? staticOf(image)
159
+ : undefined;
160
+ }
161
+ /** The image as the manifest states it, without sizes or a service. */
162
+ function staticOf(image) {
163
+ return image.width === undefined || image.height === undefined
164
+ ? undefined
165
+ : { url: image.url, width: image.width, height: image.height, sizes: [] };
166
+ }
167
+ /** The URL, normalised, if it is an absolute `http(s)` one. */
168
+ function webAddress(url) {
169
+ if (!URL.canParse(url)) {
170
+ return undefined;
171
+ }
172
+ const { protocol, href } = new URL(url);
173
+ return protocol === 'http:' || protocol === 'https:' ? href : undefined;
174
+ }
175
+ function aliasesOf(rootType, schema) {
176
+ const media = fieldNamed(rootType, 'associatedMedia');
177
+ const image = fieldNamed(rootType, representativeImageField.name);
178
+ const mediaType = referencedType(schema, media);
179
+ const encodingFormat = mediaType === undefined ? undefined : aliasOf(mediaType, 'encodingFormat');
180
+ const imageType = referencedType(schema, image);
181
+ const sizeType = imageType && referencedType(schema, fieldNamed(imageType, 'sizes'));
182
+ const serviceType = imageType && referencedType(schema, fieldNamed(imageType, 'service'));
183
+ if (media === undefined ||
184
+ image === undefined ||
185
+ encodingFormat === undefined ||
186
+ imageType === undefined ||
187
+ sizeType === undefined ||
188
+ serviceType === undefined) {
189
+ return undefined;
190
+ }
191
+ const imageAliases = fieldAliases(imageType, iiifImage);
192
+ const sizeAliases = fieldAliases(sizeType, iiifImageSize);
193
+ const serviceAliases = fieldAliases(serviceType, iiifImageService);
194
+ return imageAliases === undefined ||
195
+ sizeAliases === undefined ||
196
+ serviceAliases === undefined
197
+ ? undefined
198
+ : {
199
+ associatedMedia: irAlias(rootType, media),
200
+ encodingFormat,
201
+ representativeImage: irAlias(rootType, image),
202
+ image: imageAliases,
203
+ size: sizeAliases,
204
+ service: serviceAliases,
205
+ };
206
+ }
207
+ /**
208
+ * The type a reference field nests. Read from the declaration rather than through
209
+ * `nestedReferenceType`, which answers only for surfaced fields: a schema may read
210
+ * `associatedMedia` without surfacing it, and should still be enriched.
211
+ */
212
+ function referencedType(schema, field) {
213
+ return field?.kind === 'reference' && field.ref?.strategy === 'inline'
214
+ ? referenceTypeNamed(schema, field.ref.typeName)
215
+ : undefined;
216
+ }
217
+ /** Every field `declaration` names, as the schema’s own `searchType` aliases it,
218
+ * or `undefined` where that type lacks one. */
219
+ function fieldAliases(searchType, declaration) {
220
+ const entries = declaration.fields.map(({ name }) => [name, aliasOf(searchType, name)]);
221
+ return entries.every(([, alias]) => alias !== undefined)
222
+ ? Object.fromEntries(entries)
223
+ : undefined;
224
+ }
225
+ function aliasOf(searchType, name) {
226
+ const field = fieldNamed(searchType, name);
227
+ return field === undefined ? undefined : irAlias(searchType, field);
228
+ }
229
+ /**
230
+ * Each work’s manifest: its `associatedMedia` entry that is a named node and names
231
+ * the IIIF Presentation context in its `encodingFormat`. The profile allows one;
232
+ * where a publisher states more, the first by IRI, because quad order can change
233
+ * between runs and the representative image should not change with it. Only works
234
+ * named by an IRI, because the image is minted onto the work by its name.
235
+ */
236
+ function manifestsIn(batch, aliases) {
237
+ const manifestEntries = new Set(batch
238
+ .filter((quad) => quad.predicate.value === aliases.encodingFormat &&
239
+ quad.subject.termType === 'NamedNode' &&
240
+ quad.object.value.includes(IIIF_PRESENTATION_CONTEXT))
241
+ .map((quad) => quad.subject.value));
242
+ const manifestOfWork = new Map();
243
+ for (const quad of batch) {
244
+ if (quad.predicate.value !== aliases.associatedMedia ||
245
+ quad.subject.termType !== 'NamedNode' ||
246
+ !manifestEntries.has(quad.object.value)) {
247
+ continue;
248
+ }
249
+ const current = manifestOfWork.get(quad.subject.value);
250
+ if (current === undefined || quad.object.value < current) {
251
+ manifestOfWork.set(quad.subject.value, quad.object.value);
252
+ }
253
+ }
254
+ return manifestOfWork;
255
+ }
256
+ /**
257
+ * Fetch each key’s JSON document and derive its fact. An {@link ABSENT_STATUSES}
258
+ * answer, or a document `derive` finds no fact in, is an absence and left out;
259
+ * anything else throws, so the resolver retries it and a stored fact survives a
260
+ * passing outage. Restricted images are absences so that they cannot write off a
261
+ * host whose public images are fine. So, reported, is a URL that is not a public
262
+ * web address – publisher data decides these URLs, and this runs inside the
263
+ * cluster – and a document too large to read, which a retry would not shrink.
264
+ */
265
+ async function fetchFacts(keys, urlOf, derive, signal, report) {
266
+ const facts = [];
267
+ for (const key of keys) {
268
+ let document;
269
+ try {
270
+ document = await fetchDocument(urlOf(key), signal);
271
+ }
272
+ catch (error) {
273
+ if (!(error instanceof RefusedUrlError) &&
274
+ !(error instanceof ResponseTooLargeError)) {
275
+ throw error;
276
+ }
277
+ report({ key, reason: error.message, degradedToStore: false });
278
+ continue;
279
+ }
280
+ const fact = document === undefined ? undefined : derive(document, key);
281
+ if (fact !== undefined) {
282
+ facts.push([key, fact]);
283
+ }
284
+ }
285
+ return facts;
286
+ }
287
+ /** The JSON document at `url`, or `undefined` for an {@link ABSENT_STATUSES} answer. */
288
+ async function fetchDocument(url, signal) {
289
+ // Only `*/*`: LDmaX answers 404 to any JSON media type in `Accept`, IIIF’s own
290
+ // included, even with `*/*` as a fallback beside it.
291
+ const response = await fetchPublic(url, {
292
+ signal,
293
+ headers: { Accept: '*/*' },
294
+ });
295
+ if (!response.ok) {
296
+ // An unread body keeps its connection until garbage collection.
297
+ await response.body?.cancel();
298
+ if (ABSENT_STATUSES.has(response.status)) {
299
+ return undefined;
300
+ }
301
+ throw new Error(`${url} answered ${response.status}`);
302
+ }
303
+ return response.json();
304
+ }
305
+ /** Whether a document says it is a IIIF manifest or collection, in Presentation 3 or 2. */
306
+ function declaresIiifType(document) {
307
+ if (typeof document !== 'object' || document === null) {
308
+ return false;
309
+ }
310
+ const { type, '@type': legacyType } = document;
311
+ return IIIF_TYPES.has(String(type ?? legacyType));
312
+ }
313
+ function* mint(work, fact, aliases) {
314
+ const image = factory.blankNode();
315
+ yield triple(work, aliases.representativeImage, image);
316
+ yield triple(image, aliases.image.url, factory.namedNode(fact.url));
317
+ yield triple(image, aliases.image.width, integer(fact.width));
318
+ yield triple(image, aliases.image.height, integer(fact.height));
319
+ for (const size of fact.sizes) {
320
+ const node = factory.blankNode();
321
+ yield triple(image, aliases.image.sizes, node);
322
+ yield triple(node, aliases.size.width, integer(size.width));
323
+ yield triple(node, aliases.size.height, integer(size.height));
324
+ yield triple(node, aliases.size.url, factory.namedNode(size.url));
325
+ }
326
+ if (fact.service !== undefined) {
327
+ const service = factory.blankNode();
328
+ yield triple(image, aliases.image.service, service);
329
+ yield triple(service, aliases.service.url, factory.namedNode(fact.service));
330
+ }
331
+ }
332
+ function triple(subject, predicate, object) {
333
+ return factory.quad(subject, factory.namedNode(predicate), object);
334
+ }
335
+ function integer(value) {
336
+ return factory.literal(String(value), INTEGER);
337
+ }
@@ -0,0 +1,169 @@
1
+ /** One precomputed rendition of the image, with the URL to request it at. */
2
+ export declare const iiifImageSize: {
3
+ readonly name: "IiifImageSize";
4
+ readonly fields: readonly [{
5
+ readonly name: "width";
6
+ readonly kind: "integer";
7
+ readonly output: true;
8
+ readonly path: "<https://schema.org/width>";
9
+ }, {
10
+ readonly name: "height";
11
+ readonly kind: "integer";
12
+ readonly output: true;
13
+ readonly path: "<https://schema.org/height>";
14
+ }, {
15
+ readonly name: "url";
16
+ readonly kind: "reference";
17
+ readonly output: true;
18
+ readonly path: "<https://schema.org/contentUrl>";
19
+ readonly ref: {
20
+ readonly strategy: "idOnly";
21
+ };
22
+ }];
23
+ };
24
+ /** The IIIF image service itself, for consumers doing deep zoom or region crops. */
25
+ export declare const iiifImageService: {
26
+ readonly name: "IiifImageService";
27
+ readonly fields: readonly [{
28
+ readonly name: "url";
29
+ readonly kind: "reference";
30
+ readonly output: true;
31
+ readonly path: "<https://schema.org/url>";
32
+ readonly ref: {
33
+ readonly strategy: "idOnly";
34
+ };
35
+ }];
36
+ };
37
+ /**
38
+ * The image that represents a work: the image the manifest paints, its full
39
+ * dimensions, and the renditions a consumer can pick from without knowing Image
40
+ * API compliance levels.
41
+ */
42
+ export declare const iiifImage: {
43
+ readonly name: "IiifImage";
44
+ readonly fields: readonly [{
45
+ readonly name: "url";
46
+ readonly kind: "reference";
47
+ readonly output: true;
48
+ readonly path: "<https://schema.org/contentUrl>";
49
+ readonly ref: {
50
+ readonly strategy: "idOnly";
51
+ };
52
+ }, {
53
+ readonly name: "width";
54
+ readonly kind: "integer";
55
+ readonly output: true;
56
+ readonly path: "<https://schema.org/width>";
57
+ }, {
58
+ readonly name: "height";
59
+ readonly kind: "integer";
60
+ readonly output: true;
61
+ readonly path: "<https://schema.org/height>";
62
+ }, {
63
+ readonly name: "sizes";
64
+ readonly kind: "reference";
65
+ readonly array: true;
66
+ readonly output: true;
67
+ readonly path: "<https://def.nde.nl/iiif#size>";
68
+ readonly ref: {
69
+ readonly typeName: "IiifImageSize";
70
+ readonly strategy: "inline";
71
+ };
72
+ }, {
73
+ readonly name: "service";
74
+ readonly kind: "reference";
75
+ readonly output: true;
76
+ readonly path: "<https://def.nde.nl/iiif#service>";
77
+ readonly ref: {
78
+ readonly typeName: "IiifImageService";
79
+ readonly strategy: "inline";
80
+ };
81
+ }];
82
+ };
83
+ /** The types {@link representativeImageField} nests, to add to the schema. */
84
+ export declare const iiifImageTypes: ({
85
+ readonly name: "IiifImageSize";
86
+ readonly fields: readonly [{
87
+ readonly name: "width";
88
+ readonly kind: "integer";
89
+ readonly output: true;
90
+ readonly path: "<https://schema.org/width>";
91
+ }, {
92
+ readonly name: "height";
93
+ readonly kind: "integer";
94
+ readonly output: true;
95
+ readonly path: "<https://schema.org/height>";
96
+ }, {
97
+ readonly name: "url";
98
+ readonly kind: "reference";
99
+ readonly output: true;
100
+ readonly path: "<https://schema.org/contentUrl>";
101
+ readonly ref: {
102
+ readonly strategy: "idOnly";
103
+ };
104
+ }];
105
+ } | {
106
+ readonly name: "IiifImageService";
107
+ readonly fields: readonly [{
108
+ readonly name: "url";
109
+ readonly kind: "reference";
110
+ readonly output: true;
111
+ readonly path: "<https://schema.org/url>";
112
+ readonly ref: {
113
+ readonly strategy: "idOnly";
114
+ };
115
+ }];
116
+ } | {
117
+ readonly name: "IiifImage";
118
+ readonly fields: readonly [{
119
+ readonly name: "url";
120
+ readonly kind: "reference";
121
+ readonly output: true;
122
+ readonly path: "<https://schema.org/contentUrl>";
123
+ readonly ref: {
124
+ readonly strategy: "idOnly";
125
+ };
126
+ }, {
127
+ readonly name: "width";
128
+ readonly kind: "integer";
129
+ readonly output: true;
130
+ readonly path: "<https://schema.org/width>";
131
+ }, {
132
+ readonly name: "height";
133
+ readonly kind: "integer";
134
+ readonly output: true;
135
+ readonly path: "<https://schema.org/height>";
136
+ }, {
137
+ readonly name: "sizes";
138
+ readonly kind: "reference";
139
+ readonly array: true;
140
+ readonly output: true;
141
+ readonly path: "<https://def.nde.nl/iiif#size>";
142
+ readonly ref: {
143
+ readonly typeName: "IiifImageSize";
144
+ readonly strategy: "inline";
145
+ };
146
+ }, {
147
+ readonly name: "service";
148
+ readonly kind: "reference";
149
+ readonly output: true;
150
+ readonly path: "<https://def.nde.nl/iiif#service>";
151
+ readonly ref: {
152
+ readonly typeName: "IiifImageService";
153
+ readonly strategy: "inline";
154
+ };
155
+ }];
156
+ })[];
157
+ /** Add to a root type’s fields, and {@link iiifImageTypes} to the schema, to opt
158
+ * that type in to enrichment. */
159
+ export declare const representativeImageField: {
160
+ readonly name: "representativeImage";
161
+ readonly kind: "reference";
162
+ readonly output: true;
163
+ readonly path: "<https://def.nde.nl/iiif#representativeImage>";
164
+ readonly ref: {
165
+ readonly typeName: "IiifImage";
166
+ readonly strategy: "inline";
167
+ };
168
+ };
169
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAKA,6EAA6E;AAC7E,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;CAkBK,CAAC;AAEhC,oFAAoF;AACpF,eAAO,MAAM,gBAAgB;;;;;;;;;;;CAWE,CAAC;AAEhC;;;;GAIG;AACH,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCS,CAAC;AAEhC,8EAA8E;AAC9E,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAA+C,CAAC;AAE3E;kCACkC;AAClC,eAAO,MAAM,wBAAwB;;;;;;;;;CAML,CAAC"}
package/dist/schema.js ADDED
@@ -0,0 +1,85 @@
1
+ const SCHEMA = 'https://schema.org/';
2
+ const IIIF = 'https://def.nde.nl/iiif#';
3
+ /** One precomputed rendition of the image, with the URL to request it at. */
4
+ export const iiifImageSize = {
5
+ name: 'IiifImageSize',
6
+ fields: [
7
+ { name: 'width', kind: 'integer', output: true, path: `<${SCHEMA}width>` },
8
+ {
9
+ name: 'height',
10
+ kind: 'integer',
11
+ output: true,
12
+ path: `<${SCHEMA}height>`,
13
+ },
14
+ {
15
+ name: 'url',
16
+ kind: 'reference',
17
+ output: true,
18
+ path: `<${SCHEMA}contentUrl>`,
19
+ ref: { strategy: 'idOnly' },
20
+ },
21
+ ],
22
+ };
23
+ /** The IIIF image service itself, for consumers doing deep zoom or region crops. */
24
+ export const iiifImageService = {
25
+ name: 'IiifImageService',
26
+ fields: [
27
+ {
28
+ name: 'url',
29
+ kind: 'reference',
30
+ output: true,
31
+ path: `<${SCHEMA}url>`,
32
+ ref: { strategy: 'idOnly' },
33
+ },
34
+ ],
35
+ };
36
+ /**
37
+ * The image that represents a work: the image the manifest paints, its full
38
+ * dimensions, and the renditions a consumer can pick from without knowing Image
39
+ * API compliance levels.
40
+ */
41
+ export const iiifImage = {
42
+ name: 'IiifImage',
43
+ fields: [
44
+ {
45
+ name: 'url',
46
+ kind: 'reference',
47
+ output: true,
48
+ path: `<${SCHEMA}contentUrl>`,
49
+ ref: { strategy: 'idOnly' },
50
+ },
51
+ { name: 'width', kind: 'integer', output: true, path: `<${SCHEMA}width>` },
52
+ {
53
+ name: 'height',
54
+ kind: 'integer',
55
+ output: true,
56
+ path: `<${SCHEMA}height>`,
57
+ },
58
+ {
59
+ name: 'sizes',
60
+ kind: 'reference',
61
+ array: true,
62
+ output: true,
63
+ path: `<${IIIF}size>`,
64
+ ref: { typeName: iiifImageSize.name, strategy: 'inline' },
65
+ },
66
+ {
67
+ name: 'service',
68
+ kind: 'reference',
69
+ output: true,
70
+ path: `<${IIIF}service>`,
71
+ ref: { typeName: iiifImageService.name, strategy: 'inline' },
72
+ },
73
+ ],
74
+ };
75
+ /** The types {@link representativeImageField} nests, to add to the schema. */
76
+ export const iiifImageTypes = [iiifImage, iiifImageSize, iiifImageService];
77
+ /** Add to a root type’s fields, and {@link iiifImageTypes} to the schema, to opt
78
+ * that type in to enrichment. */
79
+ export const representativeImageField = {
80
+ name: 'representativeImage',
81
+ kind: 'reference',
82
+ output: true,
83
+ path: `<${IIIF}representativeImage>`,
84
+ ref: { typeName: iiifImage.name, strategy: 'inline' },
85
+ };
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@ndes/iiif-images",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Enriches SCHEMA-AP-NDE works with the dimensions and sizes of their representative IIIF image, as a quad transform for the search indexer.",
5
+ "repository": {
6
+ "url": "git+https://github.com/netwerk-digitaal-erfgoed/stack.git",
7
+ "directory": "packages/iiif-images"
8
+ },
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "exports": {
12
+ "./package.json": "./package.json",
13
+ ".": {
14
+ "@ndes/source": "./src/index.ts",
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./schema": {
20
+ "@ndes/source": "./src/schema.ts",
21
+ "types": "./dist/schema.d.ts",
22
+ "import": "./dist/schema.js",
23
+ "default": "./dist/schema.js"
24
+ }
25
+ },
26
+ "main": "./dist/index.js",
27
+ "module": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "files": [
30
+ "dist",
31
+ "!**/*.tsbuildinfo"
32
+ ],
33
+ "dependencies": {
34
+ "@lde/iiif-image": "^0.2.0",
35
+ "@lde/public-fetch": "^0.1.0",
36
+ "@lde/resolver": "^0.2.1",
37
+ "@rdfjs/types": "^2.0.1",
38
+ "rdf-data-factory": "^2.1.0",
39
+ "tslib": "^2.3.0"
40
+ },
41
+ "devDependencies": {
42
+ "@lde/pipeline": "^0.36.7",
43
+ "@lde/search": "^0.34.0"
44
+ },
45
+ "peerDependencies": {
46
+ "@lde/search": "^0.34.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=24"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "nx": {
55
+ "name": "iiif-images"
56
+ }
6
57
  }