samvera/node-iiif

View on GitHub
src/processor.js

Summary

Maintainability
A
35 mins
Test Coverage
const debug = require('debug')('iiif-processor:main');
const mime = require('mime-types');
const path = require('path');
const sharp = require('sharp');
const { Operations } = require('./transform');
const IIIFError = require('./error');
const IIIFVersions = require('./versions');

const fixupSlashes = (path, leaveOne) => {
  const replacement = leaveOne ? '/' : '';
  return path?.replace(/^\/*/, replacement).replace(/\/*$/, replacement);
};

const getIIIFVersion = (url, opts = {}) => {
  const uri = new URL(url);
  try {
    let { iiifVersion, pathPrefix } = opts;
    if (!iiifVersion) {
      const match = /^\/iiif\/(?<v>\d)\//.exec(uri.pathname);
      iiifVersion = match.groups.v;
    }
    if (!pathPrefix) pathPrefix = `iiif/${iiifVersion}/`;
    return { iiifVersion, pathPrefix };
  } catch {
    throw new IIIFError(`Cannot determine IIIF version from path ${uri.path}`);
  }
};

class Processor {
  constructor (url, streamResolver, opts = {}) {
    const { iiifVersion, pathPrefix } = getIIIFVersion(url, opts);

    if (typeof streamResolver !== 'function') {
      throw new IIIFError('streamResolver option must be specified');
    }

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

    const defaults = {
      dimensionFunction: this.defaultDimensionFunction,
      density: null
    };

    this
      .setOpts({ ...defaults, ...opts, pathPrefix, iiifVersion })
      .initialize(url, streamResolver);
  }

  setOpts (opts) {
    this.errorClass = IIIFError;
    this.dimensionFunction = opts.dimensionFunction;
    this.max = { ...opts.max };
    this.includeMetadata = !!opts.includeMetadata;
    this.density = opts.density;
    this.pathPrefix = fixupSlashes(opts.pathPrefix, true);
    this.sharpOptions = { ...opts.sharpOptions };
    this.version = opts.iiifVersion;

    return this;
  }

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

    return result;
  }

  initialize (url, streamResolver) {
    this.Implementation = IIIFVersions[this.version];
    if (!this.Implementation) {
      throw new IIIFError(`No implementation found for IIIF Image API v${this.version}`);
    }

    const params = this.parseUrl(url);
    debug('Parsed URL: %j', params);
    Object.assign(this, params);
    this.streamResolver = streamResolver;

    if (this.quality && this.format) {
      this.filename = [this.quality, this.format].join('.');
    } else if (this.info) {
      this.filename = 'info.json';
    }
    return this;
  }

  async withStream ({ id, baseUrl }, callback) {
    debug('Requesting stream for %s', id);
    if (this.streamResolver.length === 2) {
      return await this.streamResolver({ id, baseUrl }, callback);
    } else {
      const stream = await this.streamResolver({ id, baseUrl });
      return await callback(stream);
    }
  }

  async defaultDimensionFunction ({ id, baseUrl }) {
    const result = [];
    let page = 0;
    const target = sharp({ limitInputPixels: false, page });

    return await this.withStream({ id, baseUrl }, async (stream) => {
      stream.pipe(target);
      const { width, height, pages } = await target.metadata();
      result.push({ width, height });
      for (page += 1; page < pages; page++) {
        const scale = 1 / 2 ** page;
        result.push({ width: Math.floor(width * scale), height: Math.floor(height * scale) });
      }
      return result;
    });
  }

  async dimensions () {
    const fallback = this.dimensionFunction !== this.defaultDimensionFunction;

    if (!this.sizeInfo) {
      debug('Attempting to use dimensionFunction to retrieve dimensions for %j', this.id);
      const params = { id: this.id, baseUrl: this.baseUrl };
      let dims = await this.dimensionFunction(params);
      if (fallback && !dims) {
        const warning =
          'Unable to get dimensions for %s using custom function. Falling back to sharp.metadata().';
        debug(warning, this.id);
        console.warn(warning, this.id);
        dims = await this.defaultDimensionFunction(params);
      }
      if (!Array.isArray(dims)) dims = [dims];
      this.sizeInfo = dims;
    }
    return this.sizeInfo;
  }

  async infoJson () {
    const [dim] = await this.dimensions();
    const sizes = [];
    for (let size = [dim.width, dim.height]; size.every((x) => x >= 64); size = size.map((x) => Math.floor(x / 2))) {
      sizes.push({ width: size[0], height: size[1] });
    }

    const id = [fixupSlashes(this.baseUrl), fixupSlashes(this.id)].join('/');
    const doc = this.Implementation.infoDoc({ id, ...dim, sizes, max: this.max });
    for (const prop in doc) {
      if (doc[prop] === null || doc[prop] === undefined) delete doc[prop];
    }

    // Serialize sets as arrays
    const body = JSON.stringify(doc, (_key, value) =>
      value?.constructor === Set ? [...value] : value
    );
    return { contentType: 'application/json', body };
  }

  operations (dim) {
    const { sharpOptions: sharp, max } = this;
    return new Operations(this.version, dim, { sharp, max })
      .region(this.region)
      .size(this.size)
      .rotation(this.rotation)
      .quality(this.quality)
      .format(this.format, this.density)
      .withMetadata(this.includeMetadata);
  }

  async iiifImage () {
    const dim = await this.dimensions();
    const operations = this.operations(dim);
    const pipeline = await operations.pipeline();

    const result = await this.withStream({ id: this.id, baseUrl: this.baseUrl }, async (stream) => {
      debug('piping stream to pipeline');
      const transformed = await stream.pipe(pipeline);
      debug('converting to buffer');
      return await transformed.toBuffer();
    });
    debug('returning %d bytes', result.length);
    debug('baseUrl', this.baseUrl);

    const canonicalUrl = new URL(path.join(this.id, operations.canonicalPath()), this.baseUrl);
    return {
      canonicalLink: canonicalUrl.toString(),
      profileLink: this.Implementation.profileLink,
      contentType: mime.lookup(this.format),
      body: result
    };
  }

  async execute () {
    if (this.filename === 'info.json') {
      return await this.infoJson();
    } else {
      return await this.iiifImage();
    }
  }
}

module.exports = Processor;