iiif-processor
Version:
IIIF 2.1 & 3.0 Image API modules for NodeJS
525 lines (516 loc) • 15.3 kB
JavaScript
import {
Base,
Calculator,
Calculator2,
Formats,
IIIFError,
Qualities,
__export
} from "./chunk-F4YR7VB6.mjs";
// src/processor.ts
import Debug2 from "debug";
import mime from "mime-types";
import path from "path";
import sharp from "sharp";
// src/transform.ts
import Sharp from "sharp";
import Debug from "debug";
// src/v2/index.ts
var v2_exports = {};
__export(v2_exports, {
Base: () => Base,
Calculator: () => Calculator,
Formats: () => Formats,
Qualities: () => Qualities,
infoDoc: () => infoDoc,
profileLink: () => profileLink
});
// src/v2/info.ts
var profileLink = "http://iiif.io/api/image/2/level2.json";
var IIIFProfile = {
formats: new Set(Formats),
qualities: new Set(Qualities),
supports: /* @__PURE__ */ new Set([
"baseUriRedirect",
"canonicalLinkHeader",
"cors",
"jsonldMediaType",
"mirroring",
"profileLinkHeader",
"regionByPct",
"regionByPx",
"regionSquare",
"rotationArbitrary",
"rotationBy90s",
"sizeAboveFull",
"sizeByConfinedWh",
"sizeByDistortedWh",
"sizeByForcedWh",
"sizeByH",
"sizeByPct",
"sizeByW",
"sizeByWh",
"sizeByWhListed"
])
};
function infoDoc({ id, width, height, sizes, max }) {
const maxAttrs = {
maxWidth: max?.width,
maxHeight: max?.height,
maxArea: max?.area
};
return {
"@context": "http://iiif.io/api/image/2/context.json",
"@id": id,
protocol: "http://iiif.io/api/image",
width,
height,
sizes,
tiles: [
{ width: 512, height: 512, scaleFactors: sizes.map((_v, i) => 2 ** i) }
],
profile: [profileLink, { ...IIIFProfile, ...maxAttrs }]
};
}
// src/v3/index.ts
var v3_exports = {};
__export(v3_exports, {
Base: () => Base,
Calculator: () => Calculator2,
Formats: () => Formats,
Qualities: () => Qualities,
infoDoc: () => infoDoc2,
profileLink: () => profileLink2
});
// src/v3/info.ts
var profileLink2 = "https://iiif.io/api/image/3/level2.json";
var defaultFormats = /* @__PURE__ */ new Set(["jpg", "png"]);
var defaultQualities = /* @__PURE__ */ new Set(["default"]);
var IIIFExtras = {
extraFeatures: [
"canonicalLinkHeader",
"mirroring",
"profileLinkHeader",
"rotationArbitrary",
"sizeByDistortedWh",
"sizeByForcedWh",
"sizeByWhListed",
"sizeUpscaling"
],
extraFormats: new Set(Formats.filter((f) => !defaultFormats.has(f))),
extraQualities: new Set(Qualities.filter((q) => !defaultQualities.has(q)))
};
function infoDoc2({
id,
width,
height,
sizes,
max
}) {
const maxAttrs = {
maxWidth: max?.width,
maxHeight: max?.height,
maxArea: max?.area
};
return {
"@context": "http://iiif.io/api/image/3/context.json",
id,
type: "ImageService3",
protocol: "http://iiif.io/api/image",
profile: "level2",
width,
height,
sizes,
tiles: [
{
width: 512,
height: 512,
scaleFactors: sizes.map((_v, i) => 2 ** i)
}
],
...IIIFExtras,
...maxAttrs
};
}
// src/versions.ts
var Versions = {
2: v2_exports,
3: v3_exports
};
var versions_default = Versions;
// src/transform.ts
var debug = Debug("iiif-processor:transform");
var DEFAULT_PAGE_THRESHOLD = 1;
var SCALE_PRECISION = 1e7;
var Operations = class {
constructor(version, dims, opts) {
const { sharp: sharp2, pageThreshold, ...rest } = { ...opts };
const Implementation = Versions[version];
this.calculator = new Implementation.Calculator(dims[0], rest);
this.pageThreshold = typeof pageThreshold === "number" ? pageThreshold : DEFAULT_PAGE_THRESHOLD;
this.pages = dims.map((dim, page) => ({ ...dim, page })).sort((a, b) => b.width * b.height - a.width * a.height);
this.sharpOptions = sharp2;
}
region(v) {
this.calculator.region(v);
return this;
}
size(v) {
this.calculator.size(v);
return this;
}
rotation(v) {
this.calculator.rotation(v);
return this;
}
quality(v) {
this.calculator.quality(v);
return this;
}
format(v, density) {
this.calculator.format(v, density);
return this;
}
info() {
return this.calculator.info();
}
canonicalPath() {
return this.calculator.canonicalPath();
}
withMetadata(v) {
this.keepMetadata = v;
return this;
}
computePage() {
const { fullSize } = this.info();
const { page } = this.pages.find((_candidate, index) => {
const next = this.pages[index + 1];
debug("comparing candidate %j to target %j with a %d-pixel buffer", next, fullSize, this.pageThreshold);
return !next || next.width + this.pageThreshold < fullSize.width && next.height + this.pageThreshold < fullSize.height;
});
const resolution = this.pages[page];
const scale = page === 0 ? 1 : Math.round(resolution.width / this.pages[0].width * SCALE_PRECISION) / SCALE_PRECISION;
debug("Using page %d (%j) as source and scaling by %f", page, resolution, scale);
return { page, scale };
}
pipeline() {
const pipeline = Sharp({
limitInputPixels: false,
...{ ...this.sharpOptions }
});
const { page, scale } = this.computePage();
pipeline.options.input.page = page;
const { format, quality, region, rotation: { flop, degree }, size } = this.info();
scaleRegion(region, scale, this.pages[page]);
pipeline.autoOrient().extract(region).resize(size);
if (flop) pipeline.flop();
pipeline.rotate(degree);
if (quality === "gray") pipeline.grayscale();
if (quality === "bitonal") pipeline.threshold();
setFormat(pipeline, format);
if (this.keepMetadata) pipeline.keepMetadata();
debug("Pipeline: %j", { page, region, size, rotation: { flop, degree }, quality, format });
return pipeline;
}
};
function setFormat(pipeline, format) {
let pipelineFormat;
const pipelineOptions = {};
switch (format.type) {
case "jpeg":
pipelineFormat = "jpg";
break;
case "tif":
pipelineFormat = "tiff";
if (format.density) {
pipelineOptions.xres = format.density / 25.4;
pipelineOptions.yres = format.density / 25.4;
}
break;
default:
pipelineFormat = format.type;
}
pipeline.toFormat(pipelineFormat, pipelineOptions);
if (format.density) {
pipeline.withMetadata({ density: format.density });
}
}
function scaleRegion(region, scale, page) {
region.left = Math.floor(region.left * scale);
region.top = Math.floor(region.top * scale);
region.width = Math.floor(region.width * scale);
region.height = Math.floor(region.height * scale);
region.left = Math.max(region.left, 0);
region.top = Math.max(region.top, 0);
region.width = Math.min(region.width, page.width);
region.height = Math.min(region.height, page.height);
return region;
}
// src/processor.ts
var debug2 = Debug2("iiif-processor:main");
var debugv = Debug2("verbose:iiif-processor");
var defaultpathPrefix = "/iiif/{{version}}/";
function getIiifVersion(url, template) {
const { origin, pathname } = new URL(url);
const templateMatcher = template.replace(
/\{\{version\}\}/,
"(?<iiifVersion>\\d+)"
);
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");
}
}
var Processor = class {
constructor(url, streamResolver, opts = {}) {
this.errorClass = IIIFError;
this.includeMetadata = false;
this.debugBorder = false;
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.bind(this),
density: null
};
this.setOpts({
...defaults,
iiifVersion,
...opts,
prefix,
request
}).initialize(streamResolver);
}
setOpts(opts) {
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 = Number(opts.iiifVersion);
this.request = opts.request;
return this;
}
initialize(streamResolver) {
this.Implementation = versions_default[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);
debug2("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) {
debug2("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 { autoOrient, ...metadata } = await target.metadata();
const { width, height, pages } = { ...metadata, ...autoOrient };
if (!width || !height) return result;
result.push({ width, height });
if (!isNaN(pages)) {
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.bind(this);
if (!this.sizeInfo) {
debug2(
"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().";
debug2(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] === void 0) delete doc[prop];
}
const body = JSON.stringify(
doc,
(_key, value) => value?.constructor === Set ? [...value] : value
);
return {
type: "content",
contentType: "application/ld+json",
body
};
}
operations(dim) {
const sharpOpt = this.sharpOptions;
const { max, pageThreshold } = this;
debug2("pageThreshold: %d", pageThreshold);
return new Operations(this.version, dim, {
sharp: sharpOpt,
max,
pageThreshold
}).region(this.region).size(this.size).rotation(this.rotation).quality(this.quality).format(this.format, this.density ?? void 0).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 };
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) => {
debug2("piping stream to pipeline");
let transformed = await stream.pipe(pipeline);
if (this.debugBorder) {
transformed = await this.applyBorder(transformed);
}
debug2("converting to buffer");
return await transformed.toBuffer();
}
);
debug2("returning %d bytes", result.length);
debug2("baseUrl", this.baseUrl);
const canonicalUrl = new URL(
path.join(this.id, operations.canonicalPath()),
this.baseUrl
);
return {
type: "content",
canonicalLink: canonicalUrl.toString(),
profileLink: this.Implementation.profileLink,
contentType: mime.lookup(this.format),
body: result
};
}
async execute() {
try {
if (this.format === void 0 && this.info === void 0) {
debug2("No format or info.json requested; redirecting to info.json");
return {
location: new URL(
path.join(this.id, "info.json"),
this.baseUrl
).toString(),
type: "redirect"
};
}
if (this.filename === "info.json") {
return await this.infoJson();
}
return await this.iiifImage();
} catch (err) {
if (err instanceof IIIFError) {
debug2("IIIFError caught: %j", err);
return {
type: "error",
message: err.message,
statusCode: err.statusCode || 500
};
} else {
throw err;
}
}
}
};
export {
IIIFError,
Processor,
Versions
};
//# sourceMappingURL=index.mjs.map