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

samvera / node-iiif / 501c5700-5eee-4575-a667-0153f8e75aac

25 Aug 2025 10:09PM UTC coverage: 97.398% (-0.4%) from 97.765%
501c5700-5eee-4575-a667-0153f8e75aac

push

circleci

mbklein
Bump version to 6.0.1

122 of 132 branches covered (92.42%)

Branch coverage included in aggregate %.

402 of 406 relevant lines covered (99.01%)

106.79 hits per line

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

96.95
/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 { Dimensions, MaxDimensions } from './types';
9
import type { VersionModule } from './contracts';
10

11
const debug = Debug('iiif-processor:main');
5✔
12
const debugv = Debug('verbose:iiif-processor');
5✔
13

14
const defaultpathPrefix = '/iiif/{{version}}/';
5✔
15

16
function getIiifVersion (url: string, template: string) {
17
  const { origin, pathname } = new URL(url);
112✔
18
  const templateMatcher = template.replace(/\{\{version\}\}/, '(?<iiifVersion>2|3)');
112✔
19
  const pathMatcher = `^(?<prefix>${templateMatcher})(?<request>.+)$`;
112✔
20
  const re = new RegExp(pathMatcher);
112✔
21
  const parsed = re.exec(pathname) as any;
112✔
22
  if (parsed) {
112✔
23
    parsed.groups.prefix = origin + parsed.groups.prefix;
110✔
24
    return { ...parsed.groups } as { prefix: string; iiifVersion: string; request: string };
110✔
25
  } else {
26
    throw new IIIFError('Invalid IIIF path');
2✔
27
  }
28
}
29

30
export type DimensionFunction = (input: { id: string; baseUrl: string }) => Promise<Dimensions | Dimensions[]>;
31
export type StreamResolver = (input: { id: string; baseUrl: string }) => Promise<NodeJS.ReadableStream>;
32
export type StreamResolverWithCallback = (
33
  input: { id: string; baseUrl: string },
34
  callback: (stream: NodeJS.ReadableStream) => Promise<unknown>
35
) => Promise<unknown>;
36
export type ProcessorOptions = {
37
  dimensionFunction?: DimensionFunction;
38
  max?: { width: number; height?: number, area?: number };
39
  includeMetadata?: boolean;
40
  density?: number;
41
  debugBorder?: boolean;
42
  iiifVersion?: 2 | 3;
43
  pageThreshold?: number;
44
  pathPrefix?: string;
45
  sharpOptions?: Record<string, unknown>;
46
  request?: string;
47
};
48

49
export class Processor {
5✔
50
  private errorClass = IIIFError;
112✔
51
  private Implementation!: VersionModule;
52
  private sizeInfo?: Dimensions[];
53
  private sharpOptions?: Record<string, unknown>;
54

55
  id!: string;
56
  baseUrl!: string;
57
  version!: 2 | 3;
58
  request!: string;
59
  streamResolver!: StreamResolver | StreamResolverWithCallback;
60
  filename?: string;
61

62
  // parsed params from Calculator.parsePath
63
  info?: string;
64
  region!: string;
65
  size!: string;
66
  rotation!: string;
67
  quality!: string;
68
  format!: string;
69

70
  // options
71
  dimensionFunction!: DimensionFunction;
72
  max?: MaxDimensions;
73
  includeMetadata = false;
112✔
74
  density?: number | null;
75
  debugBorder = false;
112✔
76
  pageThreshold?: number;
77

78
  constructor (url: string, streamResolver: StreamResolver | StreamResolverWithCallback, opts: ProcessorOptions = {}) {
84✔
79
    const { prefix, iiifVersion, request } = getIiifVersion(url, opts.pathPrefix || defaultpathPrefix);
112✔
80

81
    if (typeof streamResolver !== 'function') {
110✔
82
      throw new IIIFError('streamResolver option must be specified');
2✔
83
    }
84

85
    if (opts.max?.height && !opts.max?.width) {
108!
86
      throw new IIIFError('maxHeight cannot be specified without maxWidth');
×
87
    }
88

89
    const defaults = {
108✔
90
      dimensionFunction: this.defaultDimensionFunction.bind(this),
91
      density: null
92
    };
93

94
    this
108✔
95
      .setOpts({ ...defaults, iiifVersion, ...opts, prefix, request })
96
      .initialize(streamResolver);
97
  }
98

99
  setOpts (opts: any) {
100
    this.dimensionFunction = opts.dimensionFunction;
108✔
101
    this.max = { ...opts.max };
108✔
102
    this.includeMetadata = !!opts.includeMetadata;
108✔
103
    this.density = opts.density;
108✔
104
    this.baseUrl = opts.prefix;
108✔
105
    this.debugBorder = !!opts.debugBorder;
108✔
106
    this.pageThreshold = opts.pageThreshold;
108✔
107
    this.sharpOptions = { ...opts.sharpOptions };
108✔
108
    this.version = Number(opts.iiifVersion) as 2 | 3;
108✔
109
    this.request = opts.request;
108✔
110
    return this;
108✔
111
  }
112

113
  initialize (streamResolver: any) {
114
    this.Implementation = (Versions as any)[this.version] as VersionModule;
108✔
115
    if (!this.Implementation) {
108!
116
      throw new IIIFError(`No implementation found for IIIF Image API v${this.version}`);
×
117
    }
118

119
    const params = this.Implementation.Calculator.parsePath(this.request);
108✔
120
    debug('Parsed URL: %j', params);
106✔
121
    Object.assign(this, params);
106✔
122
    this.streamResolver = streamResolver;
106✔
123

124
    if ((this as any).quality && (this as any).format) {
106✔
125
      this.filename = [this.quality, this.format].join('.');
101✔
126
    } else if ((this as any).info) {
5✔
127
      this.filename = 'info.json';
5✔
128
    }
129
    return this;
106✔
130
  }
131

132
  async withStream ({ id, baseUrl }: { id: string; baseUrl: string }, callback: (s: NodeJS.ReadableStream) => Promise<any>) {
133
    debug('Requesting stream for %s', id);
148✔
134
    if ((this.streamResolver as any).length === 2) {
148✔
135
      return await (this.streamResolver as StreamResolverWithCallback)({ id, baseUrl }, callback);
4✔
136
    } else {
137
      const stream = await (this.streamResolver as StreamResolver)({ id, baseUrl });
144✔
138
      return await callback(stream);
144✔
139
    }
140
  }
141

142
  async defaultDimensionFunction ({ id, baseUrl }: { id: string; baseUrl: string }): Promise<Dimensions[]> {
143
    const result: Dimensions[] = [];
81✔
144
    let page = 0;
81✔
145
    const target = sharp({ limitInputPixels: false, page });
81✔
146

147
    return await this.withStream({ id, baseUrl }, async (stream) => {
81✔
148
      stream.pipe(target);
81✔
149
      const { width, height, pages } = await target.metadata();
81✔
150
      if (!width || !height || !pages) return result;
79!
151
      result.push({ width, height });
79✔
152
      for (page += 1; page < pages; page++) {
79✔
153
        const scale = 1 / 2 ** page;
237✔
154
        result.push({ width: Math.floor(width * scale), height: Math.floor(height * scale) });
237✔
155
      }
156
      return result;
79✔
157
    });
158
  }
159

160
  async dimensions (): Promise<Dimensions[]> {
161
    const fallback = this.dimensionFunction !== this.defaultDimensionFunction.bind(this);
83✔
162

163
    if (!this.sizeInfo) {
83✔
164
      debug('Attempting to use dimensionFunction to retrieve dimensions for %j', (this as any).id);
83✔
165
      const params = { id: (this as any).id, baseUrl: this.baseUrl };
83✔
166
      let dims: any = await this.dimensionFunction(params);
83✔
167
      if (fallback && !dims) {
81✔
168
        const warning = 'Unable to get dimensions for %s using custom function. Falling back to sharp.metadata().';
2✔
169
        debug(warning, (this as any).id);
2✔
170
        console.warn(warning, (this as any).id);
2✔
171
        dims = await this.defaultDimensionFunction(params);
2✔
172
      }
173
      if (!Array.isArray(dims)) dims = [dims];
81✔
174
      this.sizeInfo = dims as Dimensions[];
81✔
175
    }
176
    return this.sizeInfo;
81✔
177
  }
178

179
  async infoJson () {
180
    const [dim] = await this.dimensions();
4✔
181
    const sizes: Array<{ width: number; height: number }> = [];
4✔
182
    for (let size = [dim.width, dim.height]; size.every((x) => x >= 64); size = size.map((x) => Math.floor(x / 2))) {
32✔
183
      sizes.push({ width: size[0], height: size[1] });
12✔
184
    }
185

186
    const uri = new URL(this.baseUrl);
4✔
187
    // Node's URL has readonly pathname in types; construct via join on new URL
188
    uri.pathname = path.join(uri.pathname, this.id);
4✔
189
    const id = uri.toString();
4✔
190
    const doc = this.Implementation.infoDoc({ id, ...dim, sizes, max: this.max });
4✔
191
    for (const prop in doc) {
4✔
192
      if (doc[prop] === null || doc[prop] === undefined) delete doc[prop];
46✔
193
    }
194

195
    const body = JSON.stringify(doc, (_key, value) => (value?.constructor === Set ? [...value] : value));
229✔
196
    return { contentType: 'application/json', body } as const;
4✔
197
  }
198

199
  operations (dim: Dimensions[]) {
200
    const sharpOpt = this.sharpOptions;
95✔
201
    const { max, pageThreshold } = this;
95✔
202
    debug('pageThreshold: %d', pageThreshold);
95✔
203
    return new Operations(this.version, dim, { sharp: sharpOpt, max, pageThreshold })
95✔
204
      .region(this.region)
205
      .size(this.size)
206
      .rotation(this.rotation)
207
      .quality(this.quality)
208
      .format(this.format, this.density ?? undefined)
172✔
209
      .withMetadata(this.includeMetadata);
210
  }
211

212
  async applyBorder (transformed: any) {
213
    const buf = await transformed.toBuffer();
2✔
214
    const borderPipe = sharp(buf, { limitInputPixels: false });
2✔
215
    const { width, height } = await borderPipe.metadata();
2✔
216
    const background = { r: 255, g: 0, b: 0, alpha: 1 } as any;
2✔
217

218
    const topBorder = { create: { width, height: 1, channels: 4, background } } as any;
2✔
219
    const bottomBorder = { create: { width, height: 1, channels: 4, background } } as any;
2✔
220
    const leftBorder = { create: { width: 1, height, channels: 4, background } } as any;
2✔
221
    const rightBorder = { create: { width: 1, height, channels: 4, background } } as any;
2✔
222

223
    return borderPipe.composite([
2✔
224
      { input: topBorder, left: 0, top: 0 },
225
      { input: bottomBorder, left: 0, top: (height as number) - 1 },
226
      { input: leftBorder, left: 0, top: 0 },
227
      { input: rightBorder, left: (width as number) - 1, top: 0 }
228
    ]);
229
  }
230

231
  async iiifImage () {
232
    debugv('Request %s', this.request);
75✔
233
    const dim = await this.dimensions();
75✔
234
    const operations = this.operations(dim);
73✔
235
    debugv('Operations: %j', operations);
67✔
236
    const pipeline = await operations.pipeline();
67✔
237

238
    const result = await this.withStream({ id: this.id, baseUrl: this.baseUrl }, async (stream) => {
67✔
239
      debug('piping stream to pipeline');
67✔
240
      let transformed = await stream.pipe(pipeline);
67✔
241
      if (this.debugBorder) {
67✔
242
        transformed = await this.applyBorder(transformed);
2✔
243
      }
244
      debug('converting to buffer');
67✔
245
      return await transformed.toBuffer();
67✔
246
    });
247
    debug('returning %d bytes', (result as Buffer).length);
65✔
248
    debug('baseUrl', this.baseUrl);
65✔
249

250
    const canonicalUrl = new URL(path.join(this.id, operations.canonicalPath()), this.baseUrl);
65✔
251
    return {
65✔
252
      canonicalLink: canonicalUrl.toString(),
253
      profileLink: this.Implementation.profileLink,
254
      contentType: mime.lookup(this.format) as string,
255
      body: result as Buffer
256
    };
257
  }
258

259
  async execute () {
260
    if (this.filename === 'info.json') {
79✔
261
      return await this.infoJson();
4✔
262
    } else {
263
      return await this.iiifImage();
75✔
264
    }
265
  }
266
}
267

268
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

© 2026 Coveralls, Inc