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

samvera / node-iiif / 5693138c-5708-4bba-9145-a92d5954432f

14 Aug 2025 09:20PM UTC coverage: 98.279% (+0.5%) from 97.744%
5693138c-5708-4bba-9145-a92d5954432f

push

circleci

mbklein
Refactor to TypeScript; add build/test tooling; update tiny-iiif dev harness

- Convert core library in src/ to TypeScript and update exports (error, processor, transform, versions, calculators, v2/v3)
- Port tests to TypeScript; configure Jest with ts-jest; add explicit jest globals imports; stabilize CI watch behavior
- Add tsconfig.build.json; build to dist/ (no dist/src); set main to dist/index.js
- Add @types/node; narrow build types to Node; keep jest types only for tests
- Expand ESLint config with TypeScript overrides; limit root lint scope; ignore examples/tests in root lint
- Example tiny-iiif: migrate to TypeScript (index.ts, iiif.ts, config.ts); run via tsx; add local tsconfig.json
- Add nodemon.json to watch ../../dist and restart server; add dev:all with concurrently; adjust dev scripts
- Add build:watch in root to support fast iteration with hot restarts in example
- Ensure npm package is slim (files whitelist to dist/** and types/**, .npmignore excludes src/tests/examples)
- Fix duplicate debug import; minor typing and formatting cleanups

115 of 122 branches covered (94.26%)

Branch coverage included in aggregate %.

275 of 276 new or added lines in 12 files covered. (99.64%)

399 of 401 relevant lines covered (99.5%)

108.06 hits per line

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

98.14
/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

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

12
const defaultpathPrefix = '/iiif/{{version}}/';
5✔
13

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

28
type Dimensions = { width: number; height: number };
29

30
type StreamResolver = (input: { id: string; baseUrl: string }) => any;
31
type StreamResolverWithCallback = (
32
  input: { id: string; baseUrl: string },
33
  callback: (stream: any) => Promise<any>
34
) => Promise<any>;
35

36
export class Processor {
5✔
37
  private errorClass = IIIFError;
113✔
38
  private Implementation: any;
39
  private sizeInfo?: Dimensions[];
40
  private sharpOptions?: Record<string, unknown>;
41

42
  id!: string;
43
  baseUrl!: string;
44
  version!: number;
45
  request!: string;
46
  streamResolver!: StreamResolver | StreamResolverWithCallback;
47
  filename?: string;
48

49
  // parsed params
50
  info?: string;
51
  region!: string;
52
  size!: string;
53
  rotation!: string;
54
  quality!: string;
55
  format!: string;
56

57
  // options
58
  dimensionFunction!: (input: { id: string; baseUrl: string }) => Promise<Dimensions | Dimensions[]>;
59
  max?: { width?: number; height?: number; area?: number };
60
  includeMetadata = false;
113✔
61
  density?: number | null;
62
  debugBorder = false;
113✔
63
  pageThreshold?: number;
64

65
  constructor(url: string, streamResolver: any, opts: any = {}) {
84✔
66
    const { prefix, iiifVersion, request } = getIiifVersion(url, opts.pathPrefix || defaultpathPrefix);
113✔
67

68
    if (typeof streamResolver !== 'function') {
111✔
69
      throw new IIIFError('streamResolver option must be specified');
2✔
70
    }
71

72
    if (opts.max?.height && !opts.max?.width) {
109✔
73
      throw new IIIFError('maxHeight cannot be specified without maxWidth');
1✔
74
    }
75

76
    const defaults = {
108✔
77
      dimensionFunction: this.defaultDimensionFunction.bind(this),
78
      density: null
79
    };
80

81
    this
108✔
82
      .setOpts({ ...defaults, iiifVersion, ...opts, prefix, request })
83
      .initialize(streamResolver);
84
  }
85

86
  setOpts(opts: any) {
87
    this.dimensionFunction = opts.dimensionFunction;
108✔
88
    this.max = { ...opts.max };
108✔
89
    this.includeMetadata = !!opts.includeMetadata;
108✔
90
    this.density = opts.density;
108✔
91
    this.baseUrl = opts.prefix;
108✔
92
    this.debugBorder = !!opts.debugBorder;
108✔
93
    this.pageThreshold = opts.pageThreshold;
108✔
94
    this.sharpOptions = { ...opts.sharpOptions };
108✔
95
    this.version = Number(opts.iiifVersion);
108✔
96
    this.request = opts.request;
108✔
97
    return this;
108✔
98
  }
99

100
  initialize(streamResolver: any) {
101
    this.Implementation = (Versions as any)[this.version];
108✔
102
    if (!this.Implementation) {
108!
NEW
103
      throw new IIIFError(`No implementation found for IIIF Image API v${this.version}`);
×
104
    }
105

106
    const params = this.Implementation.Calculator.parsePath(this.request);
108✔
107
    debug('Parsed URL: %j', params);
106✔
108
    Object.assign(this, params);
106✔
109
    this.streamResolver = streamResolver;
106✔
110

111
    if ((this as any).quality && (this as any).format) {
106✔
112
      this.filename = [this.quality, this.format].join('.');
101✔
113
    } else if ((this as any).info) {
5✔
114
      this.filename = 'info.json';
5✔
115
    }
116
    return this;
106✔
117
  }
118

119
  async withStream({ id, baseUrl }: { id: string; baseUrl: string }, callback: (s: any) => Promise<any>) {
120
    debug('Requesting stream for %s', id);
148✔
121
    if ((this.streamResolver as any).length === 2) {
148✔
122
      return await (this.streamResolver as StreamResolverWithCallback)({ id, baseUrl }, callback);
4✔
123
    } else {
124
      const stream = await (this.streamResolver as StreamResolver)({ id, baseUrl });
144✔
125
      return await callback(stream);
144✔
126
    }
127
  }
128

129
  async defaultDimensionFunction({ id, baseUrl }: { id: string; baseUrl: string }): Promise<Dimensions[]> {
130
    const result: Dimensions[] = [];
81✔
131
    let page = 0;
81✔
132
    const target = sharp({ limitInputPixels: false, page });
81✔
133

134
    return await this.withStream({ id, baseUrl }, async (stream) => {
81✔
135
      stream.pipe(target);
81✔
136
      const { width, height, pages } = await target.metadata();
81✔
137
      if (!width || !height || !pages) return result;
79!
138
      result.push({ width, height });
79✔
139
      for (page += 1; page < pages; page++) {
79✔
140
        const scale = 1 / 2 ** page;
237✔
141
        result.push({ width: Math.floor(width * scale), height: Math.floor(height * scale) });
237✔
142
      }
143
      return result;
79✔
144
    });
145
  }
146

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

150
    if (!this.sizeInfo) {
83✔
151
      debug('Attempting to use dimensionFunction to retrieve dimensions for %j', (this as any).id);
83✔
152
      const params = { id: (this as any).id, baseUrl: this.baseUrl };
83✔
153
      let dims: any = await this.dimensionFunction(params);
83✔
154
      if (fallback && !dims) {
81✔
155
        const warning = 'Unable to get dimensions for %s using custom function. Falling back to sharp.metadata().';
2✔
156
        debug(warning, (this as any).id);
2✔
157
        console.warn(warning, (this as any).id);
2✔
158
        dims = await this.defaultDimensionFunction(params);
2✔
159
      }
160
      if (!Array.isArray(dims)) dims = [dims];
81✔
161
      this.sizeInfo = dims as Dimensions[];
81✔
162
    }
163
    return this.sizeInfo;
81✔
164
  }
165

166
  async infoJson() {
167
    const [dim] = await this.dimensions();
4✔
168
    const sizes: Array<{ width: number; height: number }> = [];
4✔
169
    for (let size = [dim.width, dim.height]; size.every((x) => x >= 64); size = size.map((x) => Math.floor(x / 2))) {
32✔
170
      sizes.push({ width: size[0], height: size[1] });
12✔
171
    }
172

173
    const uri = new URL(this.baseUrl);
4✔
174
    (uri as any).pathname = path.join((uri as any).pathname, (this as any).id);
4✔
175
    const id = uri.toString();
4✔
176
    const doc = this.Implementation.infoDoc({ id, ...dim, sizes, max: this.max });
4✔
177
    for (const prop in doc) {
4✔
178
      if (doc[prop] === null || doc[prop] === undefined) delete doc[prop];
46✔
179
    }
180

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

185
  operations(dim: Dimensions[]) {
186
    const { sharpOptions: sharpOpt, max, pageThreshold } = this as any;
95✔
187
    debug('pageThreshold: %d', pageThreshold);
95✔
188
    return new Operations(this.version, dim, { sharp: sharpOpt, max, pageThreshold })
95✔
189
      .region((this as any).region)
190
      .size((this as any).size)
191
      .rotation((this as any).rotation)
192
      .quality((this as any).quality)
193
      .format((this as any).format, this.density)
194
      .withMetadata(this.includeMetadata);
195
  }
196

197
  async applyBorder(transformed: any) {
198
    const buf = await transformed.toBuffer();
2✔
199
    const borderPipe = sharp(buf, { limitInputPixels: false });
2✔
200
    const { width, height } = await borderPipe.metadata();
2✔
201
    const background = { r: 255, g: 0, b: 0, alpha: 1 } as any;
2✔
202

203
    const topBorder = { create: { width, height: 1, channels: 4, background } } as any;
2✔
204
    const bottomBorder = { create: { width, height: 1, channels: 4, background } } as any;
2✔
205
    const leftBorder = { create: { width: 1, height, channels: 4, background } } as any;
2✔
206
    const rightBorder = { create: { width: 1, height, channels: 4, background } } as any;
2✔
207

208
    return borderPipe.composite([
2✔
209
      { input: topBorder, left: 0, top: 0 },
210
      { input: bottomBorder, left: 0, top: (height as number) - 1 },
211
      { input: leftBorder, left: 0, top: 0 },
212
      { input: rightBorder, left: (width as number) - 1, top: 0 }
213
    ]);
214
  }
215

216
  async iiifImage() {
217
    debugv('Request %s', (this as any).request);
75✔
218
    const dim = await this.dimensions();
75✔
219
    const operations = this.operations(dim);
73✔
220
    debugv('Operations: %j', operations);
67✔
221
    const pipeline = await (operations as any).pipeline();
67✔
222

223
    const result = await this.withStream({ id: (this as any).id, baseUrl: this.baseUrl }, async (stream) => {
67✔
224
      debug('piping stream to pipeline');
67✔
225
      let transformed = await stream.pipe(pipeline);
67✔
226
      if (this.debugBorder) {
67✔
227
        transformed = await this.applyBorder(transformed);
2✔
228
      }
229
      debug('converting to buffer');
67✔
230
      return await transformed.toBuffer();
67✔
231
    });
232
    debug('returning %d bytes', (result as Buffer).length);
65✔
233
    debug('baseUrl', this.baseUrl);
65✔
234

235
    const canonicalUrl = new URL(path.join((this as any).id, (operations as any).canonicalPath()), this.baseUrl);
65✔
236
    return {
65✔
237
      canonicalLink: canonicalUrl.toString(),
238
      profileLink: this.Implementation.profileLink,
239
      contentType: mime.lookup((this as any).format) as string,
240
      body: result as Buffer
241
    };
242
  }
243

244
  async execute() {
245
    if (this.filename === 'info.json') {
79✔
246
      return await this.infoJson();
4✔
247
    } else {
248
      return await this.iiifImage();
75✔
249
    }
250
  }
251
}
252

253
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