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

samvera / node-iiif / d39f16e4-e232-4ce9-b01c-5a842b40d0f0

17 Dec 2025 07:07PM UTC coverage: 96.435% (-1.0%) from 97.445%
d39f16e4-e232-4ce9-b01c-5a842b40d0f0

Pull #50

circleci

mbklein
Add type as a discriminant property on ProcessorResult
Pull Request #50: Bring node-iiif into compliance with the IIIF Validator

130 of 144 branches covered (90.28%)

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%)

89.73 hits per line

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

94.02
/src/processor.ts
1
import Debug from 'debug';
5✔
2
import mime from 'mime-types';
5✔
3
import path from 'path';
5✔
4
import sharp from 'sharp';
5✔
5
import { Operations } from './transform';
5✔
6
import { IIIFError } from './error';
5✔
7
import Versions from './versions';
5✔
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');
5✔
20
const debugv = Debug('verbose:iiif-processor');
5✔
21

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

24
function getIiifVersion(url: string, template: string) {
25
  const { origin, pathname } = new URL(url);
112✔
26
  const templateMatcher = template.replace(
112✔
27
    /\{\{version\}\}/,
28
    '(?<iiifVersion>2|3)'
29
  );
30
  const pathMatcher = `^(?<prefix>${templateMatcher})(?<request>.+)$`;
112✔
31
  const re = new RegExp(pathMatcher);
112✔
32
  const parsed = re.exec(pathname);
112✔
33
  if (parsed) {
112✔
34
    parsed.groups.prefix = origin + parsed.groups.prefix;
110✔
35
    return { ...parsed.groups } as {
110✔
36
      prefix: string;
37
      iiifVersion: string;
38
      request: string;
39
    };
40
  } else {
41
    throw new IIIFError('Invalid IIIF path');
2✔
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 {
5✔
71
  private errorClass = IIIFError;
112✔
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;
112✔
95
  density?: number | null;
96
  debugBorder = false;
112✔
97
  pageThreshold?: number;
98

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

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

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

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

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

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

145
  initialize(streamResolver: StreamResolver | StreamResolverWithCallback) {
146
    this.Implementation = Versions[this.version] as VersionModule;
108✔
147
    if (!this.Implementation) {
108!
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);
108✔
154
    debug('Parsed URL: %j', params);
106✔
155
    Object.assign(this, params);
106✔
156
    this.streamResolver = streamResolver;
106✔
157

158
    if (this.quality && this.format) {
106✔
159
      this.filename = [this.quality, this.format].join('.');
101✔
160
    } else if (this.info) {
5✔
161
      this.filename = 'info.json';
5✔
162
    }
163
    return this;
106✔
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);
148✔
171
    if (this.streamResolver.length === 2) {
148✔
172
      return await (this.streamResolver as StreamResolverWithCallback)(
4✔
173
        { id, baseUrl },
174
        callback
175
      );
176
    } else {
177
      const stream = await (this.streamResolver as StreamResolver)({
144✔
178
        id,
179
        baseUrl
180
      });
181
      return await callback(stream);
144✔
182
    }
183
  }
184

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

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

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

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

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

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

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

274
  operations(dim: Dimensions[]) {
275
    const sharpOpt = this.sharpOptions;
95✔
276
    const { max, pageThreshold } = this;
95✔
277
    debug('pageThreshold: %d', pageThreshold);
95✔
278
    return new Operations(this.version, dim, {
95✔
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();
2✔
293
    const borderPipe = sharp(buf, { limitInputPixels: false });
2✔
294
    const { width, height } = await borderPipe.metadata();
2✔
295
    const background = { r: 255, g: 0, b: 0, alpha: 1 };
2✔
296

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

310
    return borderPipe.composite([
2✔
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);
75✔
320
    const dim = await this.dimensions();
75✔
321
    const operations = this.operations(dim);
73✔
322
    debugv('Operations: %j', operations);
67✔
323
    const pipeline = await operations.pipeline();
67✔
324

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

340
    const canonicalUrl = new URL(
65✔
341
      path.join(this.id, operations.canonicalPath()),
342
      this.baseUrl
343
    );
344
    return {
65✔
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 {
79✔
355
      if (this.format === undefined && this.info === undefined) {
79!
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') {
79✔
367
        return await this.infoJson();
4✔
368
      }
369

370
      return await this.iiifImage();
75✔
371
    } catch (err) {
372
      if (err instanceof IIIFError) {
6!
373
        debug('IIIFError caught: %j', err);
6✔
374
        return {
6✔
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;
5✔
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