• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

samvera / node-iiif / 20342579449

18 Dec 2025 03:44PM UTC coverage: 96.472% (-1.0%) from 97.445%
20342579449

Pull #50

github

mbklein
Document new return types
Pull Request #50: Bring node-iiif into compliance with the IIIF Validator

191 of 207 branches covered (92.27%)

Branch coverage included in aggregate %.

70 of 74 new or added lines in 4 files covered. (94.59%)

1 existing line in 1 file now uncovered.

438 of 445 relevant lines covered (98.43%)

179.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.06
/src/processor.ts
1
import Debug from 'debug';
10✔
2
import mime from 'mime-types';
10✔
3
import path from 'path';
10✔
4
import sharp from 'sharp';
10✔
5
import { Operations } from './transform';
10✔
6
import { IIIFError } from './error';
10✔
7
import Versions from './versions';
10✔
8
import type {
9
  Dimensions,
10
  MaxDimensions,
11
  ProcessorResult,
12
  ResolvedDimensions,
13
  ContentResult,
14
  ErrorResult,
15
  RedirectResult
16
} from './types';
17
import type { VersionModule } from './contracts';
18

19
const debug = Debug('iiif-processor:main');
10✔
20
const debugv = Debug('verbose:iiif-processor');
10✔
21

22
const defaultpathPrefix = '/iiif/{{version}}/';
10✔
23

24
function getIiifVersion(url: string, template: string) {
25
  const { origin, pathname } = new URL(url);
224✔
26
  const templateMatcher = template.replace(
224✔
27
    /\{\{version\}\}/,
28
    '(?<iiifVersion>2|3)'
29
  );
30
  const pathMatcher = `^(?<prefix>${templateMatcher})(?<request>.+)$`;
224✔
31
  const re = new RegExp(pathMatcher);
224✔
32
  const parsed = re.exec(pathname);
224✔
33
  if (parsed) {
224✔
34
    parsed.groups.prefix = origin + parsed.groups.prefix;
220✔
35
    return { ...parsed.groups } as {
220✔
36
      prefix: string;
37
      iiifVersion: string;
38
      request: string;
39
    };
40
  } else {
41
    throw new IIIFError('Invalid IIIF path');
4✔
42
  }
43
}
44

45
export type DimensionFunction = (input: {
46
  id: string;
47
  baseUrl: string;
48
}) => Promise<Dimensions | Dimensions[]>;
49
export type StreamResolver = (input: {
50
  id: string;
51
  baseUrl: string;
52
}) => Promise<NodeJS.ReadableStream>;
53
export type StreamResolverWithCallback = (
54
  input: { id: string; baseUrl: string },
55
  callback: (stream: NodeJS.ReadableStream) => Promise<unknown>
56
) => Promise<unknown>;
57
export type ProcessorOptions = {
58
  dimensionFunction?: DimensionFunction;
59
  max?: { width: number; height?: number; area?: number };
60
  includeMetadata?: boolean;
61
  density?: number;
62
  debugBorder?: boolean;
63
  iiifVersion?: 2 | 3;
64
  pageThreshold?: number;
65
  pathPrefix?: string;
66
  sharpOptions?: Record<string, unknown>;
67
  request?: string;
68
};
69

70
export class Processor {
10✔
71
  private errorClass = IIIFError;
224✔
72
  private Implementation!: VersionModule;
73
  private sizeInfo?: Dimensions[];
74
  private sharpOptions?: Record<string, unknown>;
75

76
  id!: string;
77
  baseUrl!: string;
78
  version!: 2 | 3;
79
  request!: string;
80
  streamResolver!: StreamResolver | StreamResolverWithCallback;
81
  filename?: string;
82

83
  // parsed params from Calculator.parsePath
84
  info?: string;
85
  region!: string;
86
  size!: string;
87
  rotation!: string;
88
  quality!: string;
89
  format!: string;
90

91
  // options
92
  dimensionFunction!: DimensionFunction;
93
  max?: MaxDimensions;
94
  includeMetadata = false;
224✔
95
  density?: number | null;
96
  debugBorder = false;
224✔
97
  pageThreshold?: number;
98

99
  constructor(
100
    url: string,
101
    streamResolver: StreamResolver | StreamResolverWithCallback,
102
    opts: ProcessorOptions = {}
84✔
103
  ) {
104
    const { prefix, iiifVersion, request } = getIiifVersion(
224✔
105
      url,
106
      opts.pathPrefix || defaultpathPrefix
215✔
107
    );
108

109
    if (typeof streamResolver !== 'function') {
220✔
110
      throw new IIIFError('streamResolver option must be specified');
4✔
111
    }
112

113
    if (opts.max?.height && !opts.max?.width) {
216!
114
      throw new IIIFError('maxHeight cannot be specified without maxWidth');
×
115
    }
116

117
    const defaults = {
216✔
118
      dimensionFunction: this.defaultDimensionFunction.bind(this),
119
      density: null
120
    };
121

122
    this.setOpts({
216✔
123
      ...defaults,
124
      iiifVersion,
125
      ...opts,
126
      prefix,
127
      request
128
    }).initialize(streamResolver);
129
  }
130

131
  setOpts(opts) {
132
    this.dimensionFunction = opts.dimensionFunction;
216✔
133
    this.max = { ...opts.max };
216✔
134
    this.includeMetadata = !!opts.includeMetadata;
216✔
135
    this.density = opts.density;
216✔
136
    this.baseUrl = opts.prefix;
216✔
137
    this.debugBorder = !!opts.debugBorder;
216✔
138
    this.pageThreshold = opts.pageThreshold;
216✔
139
    this.sharpOptions = { ...opts.sharpOptions };
216✔
140
    this.version = Number(opts.iiifVersion) as 2 | 3;
216✔
141
    this.request = opts.request;
216✔
142
    return this;
216✔
143
  }
144

145
  initialize(streamResolver: StreamResolver | StreamResolverWithCallback) {
146
    this.Implementation = Versions[this.version] as VersionModule;
216✔
147
    if (!this.Implementation) {
216!
NEW
148
      throw new IIIFError(
×
149
        `No implementation found for IIIF Image API v${this.version}`
150
      );
151
    }
152

153
    const params = this.Implementation.Calculator.parsePath(this.request);
216✔
154
    debug('Parsed URL: %j', params);
212✔
155
    Object.assign(this, params);
212✔
156
    this.streamResolver = streamResolver;
212✔
157

158
    if (this.quality && this.format) {
212✔
159
      this.filename = [this.quality, this.format].join('.');
202✔
160
    } else if (this.info) {
10✔
161
      this.filename = 'info.json';
10✔
162
    }
163
    return this;
212✔
164
  }
165

166
  async withStream(
167
    { id, baseUrl }: { id: string; baseUrl: string },
168
    callback: (s: NodeJS.ReadableStream) => Promise<unknown>
169
  ) {
170
    debug('Requesting stream for %s', id);
296✔
171
    if (this.streamResolver.length === 2) {
296✔
172
      return await (this.streamResolver as StreamResolverWithCallback)(
8✔
173
        { id, baseUrl },
174
        callback
175
      );
176
    } else {
177
      const stream = await (this.streamResolver as StreamResolver)({
288✔
178
        id,
179
        baseUrl
180
      });
181
      return await callback(stream);
288✔
182
    }
183
  }
184

185
  async defaultDimensionFunction({
186
    id,
187
    baseUrl
188
  }: {
189
    id: string;
190
    baseUrl: string;
191
  }): Promise<Dimensions[]> {
192
    const result: Dimensions[] = [];
162✔
193
    let page = 0;
162✔
194
    const target = sharp({ limitInputPixels: false, page });
162✔
195

196
    return (await this.withStream({ id, baseUrl }, async (stream) => {
162✔
197
      stream.pipe(target);
162✔
198
      const { autoOrient, ...metadata } = await target.metadata();
162✔
199
      const { width, height, pages } = { ...metadata, ...autoOrient };
158✔
200
      if (!width || !height) return result;
158!
201
      result.push({ width, height });
158✔
202
      if (!isNaN(pages)) {
158✔
203
        for (page += 1; page < pages; page++) {
158✔
204
          const scale = 1 / 2 ** page;
474✔
205
          result.push({
474✔
206
            width: Math.floor(width * scale),
207
            height: Math.floor(height * scale)
208
          });
209
        }
210
      }
211
      return result;
158✔
212
    })) as Dimensions[];
213
  }
214

215
  async dimensions(): Promise<Dimensions[]> {
216
    const fallback =
217
      this.dimensionFunction !== this.defaultDimensionFunction.bind(this);
166✔
218

219
    if (!this.sizeInfo) {
166✔
220
      debug(
166✔
221
        'Attempting to use dimensionFunction to retrieve dimensions for %j',
222
        this.id
223
      );
224
      const params = { id: this.id, baseUrl: this.baseUrl };
166✔
225
      let dims: ResolvedDimensions = await this.dimensionFunction(params);
166✔
226
      if (fallback && !dims) {
162✔
227
        const warning =
228
          'Unable to get dimensions for %s using custom function. Falling back to sharp.metadata().';
4✔
229
        debug(warning, this.id);
4✔
230
        console.warn(warning, this.id);
4✔
231
        dims = await this.defaultDimensionFunction(params);
4✔
232
      }
233
      if (!Array.isArray(dims)) dims = [dims];
162✔
234
      this.sizeInfo = dims as Dimensions[];
162✔
235
    }
236
    return this.sizeInfo;
162✔
237
  }
238

239
  async infoJson() {
240
    const [dim] = await this.dimensions();
8✔
241
    const sizes: Array<{ width: number; height: number }> = [];
8✔
242
    for (
8✔
243
      let size = [dim.width, dim.height];
8✔
244
      size.every((x) => x >= 64);
64✔
245
      size = size.map((x) => Math.floor(x / 2))
48✔
246
    ) {
247
      sizes.push({ width: size[0], height: size[1] });
24✔
248
    }
249

250
    const uri = new URL(this.baseUrl);
8✔
251
    // Node's URL has readonly pathname in types; construct via join on new URL
252
    uri.pathname = path.join(uri.pathname, this.id);
8✔
253
    const id = uri.toString();
8✔
254
    const doc = this.Implementation.infoDoc({
8✔
255
      id,
256
      ...dim,
257
      sizes,
258
      max: this.max
259
    });
260
    for (const prop in doc) {
8✔
261
      if (doc[prop] === null || doc[prop] === undefined) delete doc[prop];
92✔
262
    }
263

264
    const body = JSON.stringify(doc, (_key, value) =>
8✔
265
      value?.constructor === Set ? [...value] : value
422✔
266
    );
267
    return {
8✔
268
      type: 'content',
269
      contentType: 'application/ld+json',
270
      body
271
    } as ContentResult;
272
  }
273

274
  operations(dim: Dimensions[]) {
275
    const sharpOpt = this.sharpOptions;
190✔
276
    const { max, pageThreshold } = this;
190✔
277
    debug('pageThreshold: %d', pageThreshold);
190✔
278
    return new Operations(this.version, dim, {
190✔
279
      sharp: sharpOpt,
280
      max,
281
      pageThreshold
282
    })
283
      .region(this.region)
284
      .size(this.size)
285
      .rotation(this.rotation)
286
      .quality(this.quality)
287
      .format(this.format, this.density ?? undefined)
172✔
288
      .withMetadata(this.includeMetadata);
289
  }
290

291
  async applyBorder(transformed: sharp.Sharp) {
292
    const buf = await transformed.toBuffer();
4✔
293
    const borderPipe = sharp(buf, { limitInputPixels: false });
4✔
294
    const { width, height } = await borderPipe.metadata();
4✔
295
    const background = { r: 255, g: 0, b: 0, alpha: 1 };
4✔
296

297
    const topBorder = {
4✔
298
      create: { width, height: 1, channels: 4, background } as sharp.Create
299
    };
300
    const bottomBorder = {
4✔
301
      create: { width, height: 1, channels: 4, background } as sharp.Create
302
    };
303
    const leftBorder = {
4✔
304
      create: { width: 1, height, channels: 4, background } as sharp.Create
305
    };
306
    const rightBorder = {
4✔
307
      create: { width: 1, height, channels: 4, background } as sharp.Create
308
    };
309

310
    return borderPipe.composite([
4✔
311
      { input: topBorder, left: 0, top: 0 },
312
      { input: bottomBorder, left: 0, top: (height as number) - 1 },
313
      { input: leftBorder, left: 0, top: 0 },
314
      { input: rightBorder, left: (width as number) - 1, top: 0 }
315
    ]);
316
  }
317

318
  async iiifImage() {
319
    debugv('Request %s', this.request);
150✔
320
    const dim = await this.dimensions();
150✔
321
    const operations = this.operations(dim);
146✔
322
    debugv('Operations: %j', operations);
134✔
323
    const pipeline = await operations.pipeline();
134✔
324

325
    const result = await this.withStream(
134✔
326
      { id: this.id, baseUrl: this.baseUrl },
327
      async (stream) => {
328
        debug('piping stream to pipeline');
134✔
329
        let transformed = await stream.pipe(pipeline);
134✔
330
        if (this.debugBorder) {
134✔
331
          transformed = await this.applyBorder(transformed);
4✔
332
        }
333
        debug('converting to buffer');
134✔
334
        return await transformed.toBuffer();
134✔
335
      }
336
    );
337
    debug('returning %d bytes', (result as Buffer).length);
130✔
338
    debug('baseUrl', this.baseUrl);
130✔
339

340
    const canonicalUrl = new URL(
130✔
341
      path.join(this.id, operations.canonicalPath()),
342
      this.baseUrl
343
    );
344
    return {
130✔
345
      type: 'content',
346
      canonicalLink: canonicalUrl.toString(),
347
      profileLink: this.Implementation.profileLink,
348
      contentType: mime.lookup(this.format) as string,
349
      body: result as Buffer
350
    } as ContentResult;
351
  }
352

353
  async execute(): Promise<ProcessorResult> {
354
    try {
158✔
355
      if (this.format === undefined && this.info === undefined) {
158!
NEW
356
        debug('No format or info.json requested; redirecting to info.json');
×
NEW
357
        return {
×
358
          location: new URL(
359
            path.join(this.id, 'info.json'),
360
            this.baseUrl
361
          ).toString(),
362
          type: 'redirect'
363
        } as RedirectResult;
364
      }
365

366
      if (this.filename === 'info.json') {
158✔
367
        return await this.infoJson();
8✔
368
      }
369

370
      return await this.iiifImage();
150✔
371
    } catch (err) {
372
      if (err instanceof IIIFError) {
12!
373
        debug('IIIFError caught: %j', err);
12✔
374
        return {
12✔
375
          type: 'error',
376
          message: err.message,
377
          statusCode: err.statusCode || 500
6!
378
        } as ErrorResult;
379
      } else {
NEW
380
        throw err;
×
381
      }
382
    }
383
  }
384
}
385

386
export default Processor;
10✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc