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

samvera / node-iiif / 24807002294

22 Apr 2026 10:58PM UTC coverage: 98.864% (-0.5%) from 99.368%
24807002294

Pull #62

github

mbklein
8.0.0-alpha.0
Pull Request #62: Replace Dimensions with Geometry

217 of 223 branches covered (97.31%)

Branch coverage included in aggregate %.

75 of 76 new or added lines in 4 files covered. (98.68%)

1 existing line in 1 file now uncovered.

479 of 481 relevant lines covered (99.58%)

175.95 hits per line

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

97.78
/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 { calculateGeometry, readGeometry } from './geometry';
10✔
6
import { Operations } from './transform';
10✔
7
import { IIIFError } from './error';
10✔
8
import Versions from './versions';
10✔
9
import type {
10
  MaxDimensions,
11
  ImageGeometry,
12
  ProcessorResult,
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);
236✔
26
  const templateMatcher = template.replace(
236✔
27
    /\{\{version\}\}/,
28
    '(?<iiifVersion>\\d+)'
29
  );
30
  const pathMatcher = `^(?<prefix>${templateMatcher})(?<request>.+)$`;
236✔
31
  const re = new RegExp(pathMatcher);
236✔
32
  const parsed = re.exec(pathname);
236✔
33
  if (parsed) {
236✔
34
    parsed.groups.prefix = origin + parsed.groups.prefix;
234✔
35
    return { ...parsed.groups } as {
234✔
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 GeometryFunction = (input: {
46
  id: string;
47
  baseUrl: string;
48
}) => Promise<ImageGeometry>;
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
  geometryFunction?: GeometryFunction;
59
  max?: { width: number; height?: number; area?: number };
60
  includeMetadata?: boolean;
61
  density?: number;
62
  debugBorder?: boolean;
63
  iiifVersion?: number;
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;
236✔
72
  private Implementation!: VersionModule;
73
  private imageGeometry?: ImageGeometry;
74
  private sharpOptions?: Record<string, unknown>;
75

76
  id!: string;
77
  baseUrl!: string;
78
  version!: number;
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
  geometryFunction!: GeometryFunction;
93
  max?: MaxDimensions;
94
  includeMetadata = false;
236✔
95
  density?: number | null;
96
  debugBorder = false;
236✔
97
  pageThreshold?: number;
98

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

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

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

117
    const defaults = {
226✔
118
      geometryFunction: null,
119
      density: null
120
    };
121

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

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

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

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

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

185
  async geometry(includeTile = false): Promise<ImageGeometry> {
79✔
186
    if (!this.imageGeometry) {
170✔
187
      debug(
170✔
188
        'Attempting to use geometryFunction to retrieve dimensions for %j',
189
        this.id
190
      );
191
      const params = { id: this.id, baseUrl: this.baseUrl };
170✔
192
      let geometry: ImageGeometry = {};
170✔
193
      if (this.geometryFunction) {
170✔
194
        geometry = await this.geometryFunction(params);
8✔
195
      }
196
      if (!(geometry.tileWidth && geometry.tileHeight) && !includeTile) {
170!
197
        geometry.tileWidth = null;
158✔
198
        geometry.tileHeight = null;
158✔
199
      }
200
      geometry = await readGeometry(
170✔
201
        (callback) => this.withStream(params, callback),
178✔
202
        geometry
203
      );
204
      this.imageGeometry = calculateGeometry(geometry);
166✔
205
    }
206
    return this.imageGeometry;
166✔
207
  }
208

209
  async infoJson() {
210
    const geometry = await this.geometry(true);
12✔
211
    const uri = new URL(this.baseUrl);
12✔
212
    // Node's URL has readonly pathname in types; construct via join on new URL
213
    uri.pathname = path.join(uri.pathname, this.id);
12✔
214
    const id = uri.toString();
12✔
215
    const doc = this.Implementation.infoDoc({
12✔
216
      id,
217
      geometry,
218
      max: this.max
219
    });
220
    for (const prop in doc) {
12✔
221
      if (doc[prop] === null || doc[prop] === undefined) delete doc[prop];
124✔
222
    }
223

224
    const body = JSON.stringify(doc, (_key, value) =>
12✔
225
      value?.constructor === Set ? [...value] : value
718✔
226
    );
227
    return {
12✔
228
      type: 'content',
229
      contentType: 'application/ld+json',
230
      body
231
    } as ContentResult;
232
  }
233

234
  operations({ sizes }: ImageGeometry) {
235
    const sharpOpt = this.sharpOptions;
190✔
236
    const { max, pageThreshold } = this;
190✔
237
    debug('pageThreshold: %d', pageThreshold);
190✔
238
    return new Operations(this.version, sizes, {
190✔
239
      sharp: sharpOpt,
240
      max,
241
      pageThreshold
242
    })
243
      .region(this.region)
244
      .size(this.size)
245
      .rotation(this.rotation)
246
      .quality(this.quality)
247
      .format(this.format, this.density ?? undefined)
172✔
248
      .withMetadata(this.includeMetadata);
249
  }
250

251
  async applyBorder(transformed: sharp.Sharp) {
252
    const buf = await transformed.toBuffer();
4✔
253
    const borderPipe = sharp(buf, { limitInputPixels: false });
4✔
254
    const { width, height } = await borderPipe.metadata();
4✔
255
    const background = { r: 255, g: 0, b: 0, alpha: 1 };
4✔
256

257
    const topBorder = {
4✔
258
      create: { width, height: 1, channels: 4, background } as sharp.Create
259
    };
260
    const bottomBorder = {
4✔
261
      create: { width, height: 1, channels: 4, background } as sharp.Create
262
    };
263
    const leftBorder = {
4✔
264
      create: { width: 1, height, channels: 4, background } as sharp.Create
265
    };
266
    const rightBorder = {
4✔
267
      create: { width: 1, height, channels: 4, background } as sharp.Create
268
    };
269

270
    return borderPipe.composite([
4✔
271
      { input: topBorder, left: 0, top: 0 },
272
      { input: bottomBorder, left: 0, top: (height as number) - 1 },
273
      { input: leftBorder, left: 0, top: 0 },
274
      { input: rightBorder, left: (width as number) - 1, top: 0 }
275
    ]);
276
  }
277

278
  async iiifImage() {
279
    debugv('Request %s', this.request);
150✔
280
    const geometry = await this.geometry();
150✔
281
    const operations = this.operations(geometry);
146✔
282
    debugv('Operations: %j', operations);
134✔
283
    const pipeline = await operations.pipeline();
134✔
284

285
    const result = await this.withStream(
134✔
286
      { id: this.id, baseUrl: this.baseUrl },
287
      async (stream) => {
288
        debug('piping stream to pipeline');
134✔
289
        let transformed = await stream.pipe(pipeline);
134✔
290
        if (this.debugBorder) {
134✔
291
          transformed = await this.applyBorder(transformed);
4✔
292
        }
293
        debug('converting to buffer');
134✔
294
        return await transformed.toBuffer();
134✔
295
      }
296
    );
297
    debug('returning %d bytes', (result as Buffer).length);
130✔
298
    debug('baseUrl', this.baseUrl);
130✔
299

300
    const canonicalUrl = new URL(
130✔
301
      path.join(this.id, operations.canonicalPath()),
302
      this.baseUrl
303
    );
304
    return {
130✔
305
      type: 'content',
306
      canonicalLink: canonicalUrl.toString(),
307
      profileLink: this.Implementation.profileLink,
308
      contentType: mime.lookup(this.format) as string,
309
      body: result as Buffer
310
    } as ContentResult;
311
  }
312

313
  async execute(): Promise<ProcessorResult> {
314
    try {
166✔
315
      if (this.format === undefined && this.info === undefined) {
166✔
316
        debug('No format or info.json requested; redirecting to info.json');
4✔
317
        return {
4✔
318
          location: new URL(
319
            path.join(this.id, 'info.json'),
320
            this.baseUrl
321
          ).toString(),
322
          type: 'redirect'
323
        } as RedirectResult;
324
      }
325

326
      if (this.filename === 'info.json') {
162✔
327
        return await this.infoJson();
12✔
328
      }
329

330
      return await this.iiifImage();
150✔
331
    } catch (err) {
332
      if (err instanceof IIIFError) {
12!
333
        debug('IIIFError caught: %j', err);
12✔
334
        return {
12✔
335
          type: 'error',
336
          message: err.message,
337
          statusCode: err.statusCode || 500
6!
338
        } as ErrorResult;
339
      } else {
UNCOV
340
        throw err;
×
341
      }
342
    }
343
  }
344
}
345

346
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

© 2026 Coveralls, Inc