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

samvera / node-iiif / 73186541-d469-42ba-b802-aa2ff6ec30e7

pending completion
73186541-d469-42ba-b802-aa2ff6ec30e7

Pull #29

circleci

mbklein
Infer IIIF version number from path
Pull Request #29: Implementation of IIIF Image API v3.0.0

135 of 147 branches covered (91.84%)

Branch coverage included in aggregate %.

237 of 237 new or added lines in 11 files covered. (100.0%)

366 of 368 relevant lines covered (99.46%)

104.22 hits per line

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

95.36
/src/processor.js
1
const debug = require('debug')('iiif-processor:main');
5✔
2
const mime = require('mime-types');
5✔
3
const path = require('path');
5✔
4
const sharp = require('sharp');
5✔
5
const { Operations } = require('./transform');
5✔
6
const IIIFError = require('./error');
5✔
7
const IIIFVersions = require('./versions');
5✔
8

9
const fixupSlashes = (path, leaveOne) => {
5✔
10
  const replacement = leaveOne ? '/' : '';
103✔
11
  return path?.replace(/^\/*/, replacement).replace(/\/*$/, replacement);
103✔
12
};
13

14
const getIIIFVersion = (url, opts = {}) => {
5!
15
  const uri = new URL(url);
98✔
16
  try {
98✔
17
    let { iiifVersion, pathPrefix } = opts;
98✔
18
    if (!iiifVersion) {
98!
19
      const match = /^\/iiif\/(?<v>\d)\//.exec(uri.pathname);
98✔
20
      iiifVersion = match.groups.v;
98✔
21
    }
22
    if (!pathPrefix) pathPrefix = `iiif/${iiifVersion}/`;
98✔
23
    return { iiifVersion, pathPrefix };
98✔
24
  } catch {
25
    throw new IIIFError(`Cannot determine IIIF version from path ${uri.path}`);
×
26
  }
27
};
28

29
class Processor {
30
  constructor (url, streamResolver, opts = {}) {
73✔
31
    const { iiifVersion, pathPrefix } = getIIIFVersion(url, opts);
98✔
32

33
    if (typeof streamResolver !== 'function') {
98✔
34
      throw new IIIFError('streamResolver option must be specified');
2✔
35
    }
36

37
    if (opts.max?.height && !opts.max?.width) {
96✔
38
      throw new IIIFError('maxHeight cannot be specified without maxWidth');
1✔
39
    };
40

41
    const defaults = {
95✔
42
      dimensionFunction: this.defaultDimensionFunction,
43
      density: null
44
    };
45

46
    this
95✔
47
      .setOpts({ ...defaults, ...opts, pathPrefix, iiifVersion })
48
      .initialize(url, streamResolver);
49
  }
50

51
  setOpts (opts) {
52
    this.errorClass = IIIFError;
95✔
53
    this.dimensionFunction = opts.dimensionFunction;
95✔
54
    this.max = { ...opts.max };
95✔
55
    this.includeMetadata = !!opts.includeMetadata;
95✔
56
    this.density = opts.density;
95✔
57
    this.pathPrefix = fixupSlashes(opts.pathPrefix, true);
95✔
58
    this.sharpOptions = { ...opts.sharpOptions };
95✔
59
    this.version = opts.iiifVersion;
95✔
60

61
    return this;
95✔
62
  }
63

64
  parseUrl (url) {
65
    const parser = new RegExp(`(?<baseUrl>https?://[^/]+${this.pathPrefix})(?<path>.+)$`);
95✔
66
    const { baseUrl, path } = parser.exec(url).groups;
95✔
67
    const result = this.Implementation.Calculator.parsePath(path);
95✔
68
    result.baseUrl = baseUrl;
93✔
69

70
    return result;
93✔
71
  }
72

73
  initialize (url, streamResolver) {
74
    this.Implementation = IIIFVersions[this.version];
95✔
75
    if (!this.Implementation) {
95!
76
      throw new IIIFError(`No implementation found for IIIF Image API v${this.version}`);
×
77
    }
78

79
    const params = this.parseUrl(url);
95✔
80
    debug('Parsed URL: %j', params);
93✔
81
    Object.assign(this, params);
93✔
82
    this.streamResolver = streamResolver;
93✔
83

84
    if (this.quality && this.format) {
93✔
85
      this.filename = [this.quality, this.format].join('.');
89✔
86
    } else if (this.info) {
4!
87
      this.filename = 'info.json';
4✔
88
    }
89
    return this;
93✔
90
  }
91

92
  async withStream ({ id, baseUrl }, callback) {
93
    debug('Requesting stream for %s', id);
132✔
94
    if (this.streamResolver.length === 2) {
132✔
95
      return await this.streamResolver({ id, baseUrl }, callback);
4✔
96
    } else {
97
      const stream = await this.streamResolver({ id, baseUrl });
128✔
98
      return await callback(stream);
128✔
99
    }
100
  }
101

102
  async defaultDimensionFunction ({ id, baseUrl }) {
103
    const result = [];
71✔
104
    let page = 0;
71✔
105
    const target = sharp({ page });
71✔
106

107
    return await this.withStream({ id, baseUrl }, async (stream) => {
71✔
108
      stream.pipe(target);
71✔
109
      const { width, height, pages } = await target.metadata();
71✔
110
      result.push({ width, height });
69✔
111
      for (page += 1; page < pages; page++) {
69✔
112
        const scale = 1 / 2 ** page;
207✔
113
        result.push({ width: Math.floor(width * scale), height: Math.floor(height * scale) });
207✔
114
      }
115
      return result;
69✔
116
    });
117
  }
118

119
  async dimensions () {
120
    const fallback = this.dimensionFunction !== this.defaultDimensionFunction;
73✔
121

122
    if (!this.sizeInfo) {
73!
123
      debug('Attempting to use dimensionFunction to retrieve dimensions for %j', this.id);
73✔
124
      const params = { id: this.id, baseUrl: this.baseUrl };
73✔
125
      let dims = await this.dimensionFunction(params);
73✔
126
      if (fallback && !dims) {
71✔
127
        const warning =
128
          'Unable to get dimensions for %s using custom function. Falling back to sharp.metadata().';
2✔
129
        debug(warning, this.id);
2✔
130
        console.warn(warning, this.id);
2✔
131
        dims = await this.defaultDimensionFunction(params);
2✔
132
      }
133
      if (!Array.isArray(dims)) dims = [dims];
71✔
134
      this.sizeInfo = dims;
71✔
135
    }
136
    return this.sizeInfo;
71✔
137
  }
138

139
  async infoJson () {
140
    const [dim] = await this.dimensions();
4✔
141
    const sizes = [];
4✔
142
    for (let size = [dim.width, dim.height]; size.every((x) => x >= 64); size = size.map((x) => Math.floor(x / 2))) {
32✔
143
      sizes.push({ width: size[0], height: size[1] });
12✔
144
    }
145

146
    const id = [fixupSlashes(this.baseUrl), fixupSlashes(this.id)].join('/');
4✔
147
    const doc = this.Implementation.infoDoc({ id, ...dim, sizes, max: this.max });
4✔
148
    for (const prop in doc) {
4✔
149
      if (doc[prop] === null || doc[prop] === undefined) delete doc[prop];
46✔
150
    }
151

152
    // Serialize sets as arrays
153
    const body = JSON.stringify(doc, (_key, value) =>
4✔
154
      value?.constructor === Set ? [...value] : value
215✔
155
    );
156
    return { contentType: 'application/json', body };
4✔
157
  }
158

159
  operations (dim) {
160
    const { sharpOptions: sharp, max } = this;
83✔
161
    return new Operations(this.version, dim, { sharp, max })
83✔
162
      .region(this.region)
163
      .size(this.size)
164
      .rotation(this.rotation)
165
      .quality(this.quality)
166
      .format(this.format, this.density)
167
      .withMetadata(this.includeMetadata);
168
  }
169

170
  async iiifImage () {
171
    const dim = await this.dimensions();
67✔
172
    const operations = this.operations(dim);
65✔
173
    const pipeline = await operations.pipeline();
61✔
174

175
    const result = await this.withStream({ id: this.id, baseUrl: this.baseUrl }, async (stream) => {
61✔
176
      debug('piping stream to pipeline');
61✔
177
      const transformed = await stream.pipe(pipeline);
61✔
178
      debug('converting to buffer');
61✔
179
      return await transformed.toBuffer();
61✔
180
    });
181
    debug('returning %d bytes', result.length);
59✔
182
    debug('baseUrl', this.baseUrl);
59✔
183

184
    const canonicalUrl = new URL(path.join(this.id, operations.canonicalPath()), this.baseUrl);
59✔
185
    return {
59✔
186
      canonicalLink: canonicalUrl.toString(),
187
      profileLink: this.Implementation.profileLink,
188
      contentType: mime.lookup(this.format),
189
      body: result
190
    };
191
  }
192

193
  async execute () {
194
    if (this.filename === 'info.json') {
71✔
195
      return await this.infoJson();
4✔
196
    } else {
197
      return await this.iiifImage();
67✔
198
    }
199
  }
200
}
201

202
module.exports = 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