iiif-processor
Version:
IIIF 2.1 & 3.0 Image API modules for NodeJS
222 lines (192 loc) • 7.54 kB
JavaScript
const debug = require('debug')('iiif-processor:main');
const debugv = require('debug')('verbose:iiif-processor');
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 defaultpathPrefix = '/iiif/{{version}}/';
function getIiifVersion (url, template) {
const { origin, pathname } = new URL(url);
const templateMatcher = template.replace(/\{\{version\}\}/, '(?<iiifVersion>2|3)');
const pathMatcher = `^(?<prefix>${templateMatcher})(?<request>.+)$`;
const re = new RegExp(pathMatcher);
const parsed = re.exec(pathname);
if (parsed) {
parsed.groups.prefix = origin + parsed.groups.prefix;
return { ...parsed.groups };
} else {
throw new IIIFError('Invalid IIIF path');
}
};
class Processor {
constructor (url, streamResolver, opts = {}) {
const { prefix, iiifVersion, request } = getIiifVersion(url, opts.pathPrefix || defaultpathPrefix);
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, iiifVersion, ...opts, prefix, request })
.initialize(streamResolver);
}
setOpts (opts) {
this.errorClass = IIIFError;
this.dimensionFunction = opts.dimensionFunction;
this.max = { ...opts.max };
this.includeMetadata = !!opts.includeMetadata;
this.density = opts.density;
this.baseUrl = opts.prefix;
this.debugBorder = !!opts.debugBorder;
this.pageThreshold = opts.pageThreshold;
this.sharpOptions = { ...opts.sharpOptions };
this.version = opts.iiifVersion;
this.request = opts.request;
return this;
}
initialize (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.Implementation.Calculator.parsePath(this.request);
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 uri = new URL(this.baseUrl);
uri.pathname = path.join(uri.pathname, this.id);
const id = uri.toString();
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, pageThreshold } = this;
debug('pageThreshold: %d', pageThreshold);
return new Operations(this.version, dim, { sharp, max, pageThreshold })
.region(this.region)
.size(this.size)
.rotation(this.rotation)
.quality(this.quality)
.format(this.format, this.density)
.withMetadata(this.includeMetadata);
}
async applyBorder (transformed) {
const buf = await transformed.toBuffer();
const borderPipe = sharp(buf, { limitInputPixels: false })
const { width, height } = await borderPipe.metadata();
const background = { r: 255, g: 0, b: 0, alpha: 1 };
// Create small images for each border “strip”
const topBorder = { create: { width, height: 1, channels: 4, background } };
const bottomBorder = { create: { width, height: 1, channels: 4, background } };
const leftBorder = { create: { width: 1, height, channels: 4, background } };
const rightBorder = { create: { width: 1, height, channels: 4, background } };
return borderPipe.composite([
{ input: topBorder, left: 0, top: 0 },
{ input: bottomBorder, left: 0, top: height - 1 },
{ input: leftBorder, left: 0, top: 0 },
{ input: rightBorder, left: width - 1, top: 0 }
]);
}
async iiifImage () {
debugv('Request %s', this.request);
const dim = await this.dimensions();
const operations = this.operations(dim);
debugv('Operations: %j', operations);
const pipeline = await operations.pipeline();
const result = await this.withStream({ id: this.id, baseUrl: this.baseUrl }, async (stream) => {
debug('piping stream to pipeline');
let transformed = await stream.pipe(pipeline);
if (this.debugBorder) {
transformed = await this.applyBorder(transformed);
}
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;