iiif-processor
Version:
IIIF 2.1 & 3.0 Image API modules for NodeJS
703 lines (691 loc) • 21 kB
JavaScript
import {
Base,
Calculator,
Calculator2,
Formats,
IIIFError,
Qualities,
__export
} from "./chunk-F4YR7VB6.mjs";
// src/processor.ts
import Debug3 from "debug";
import mime from "mime-types";
import path from "path";
import sharp2 from "sharp";
// src/geometry.ts
import Debug from "debug";
import sharp from "sharp";
// src/tile-size.ts
var CHUNK_SIZE = 5 * 1024;
var StreamBuffer = class {
constructor(stream) {
this.chunks = [];
this._length = 0;
this.done = false;
stream.pause();
this.iterator = stream[Symbol.asyncIterator]();
}
get length() {
return this._length;
}
get buf() {
return Buffer.concat(this.chunks);
}
/** Buffer at least `needed` bytes, or until stream is exhausted. */
async ensure(needed) {
while (this._length < needed && !this.done) {
const { value, done } = await this.iterator.next();
if (done) {
this.done = true;
} else {
this.chunks.push(value);
this._length += value.length;
}
}
}
/** Read `count` bytes starting at `offset`, fetching more chunks if needed. */
async read(offset, count) {
await this.ensure(offset + count);
return this.buf.subarray(offset, offset + count);
}
};
var magicNumbers = [
{ type: "tiff-le", magic: Buffer.from([73, 73, 42, 0]) },
{ type: "tiff-be", magic: Buffer.from([77, 77, 0, 42]) },
{ type: "jp2", magic: Buffer.from([0, 0, 0, 12, 106, 80]) },
{ type: "jp2", magic: Buffer.from([255, 79]) }
];
function detectFormat(buf) {
if (buf.length < 8) return "unknown";
for (const { type, magic } of magicNumbers) {
if (buf.subarray(0, magic.length).equals(magic)) return type;
}
return "unknown";
}
async function getTiffTileSize(sb, littleEndian) {
const readUInt16 = (buf, offset) => littleEndian ? buf.readUInt16LE(offset) : buf.readUInt16BE(offset);
const readUInt32 = (buf, offset) => littleEndian ? buf.readUInt32LE(offset) : buf.readUInt32BE(offset);
const header = await sb.read(0, 8);
const ifdOffset = readUInt32(header, 4);
const ifdHeader = await sb.read(ifdOffset, 2);
const entryCount = readUInt16(ifdHeader, 0);
const ifdData = await sb.read(ifdOffset + 2, entryCount * 12);
let width;
let height;
for (let i = 0; i < entryCount; i++) {
const entryOffset = i * 12;
const tag = readUInt16(ifdData, entryOffset);
const value = readUInt32(ifdData, entryOffset + 8);
if (tag === 322) width = value;
if (tag === 323) height = value;
if (width !== void 0 && height !== void 0) break;
}
return { width, height };
}
async function getJP2TileSize(sb) {
const magic = await sb.read(0, 2);
const isRawCodestream = magic[0] === 255 && magic[1] === 79;
let offset = 0;
if (!isRawCodestream) {
let foundCodestream = false;
while (true) {
const boxHeader = await sb.read(offset, 8);
if (boxHeader.length < 8) break;
const boxLength = boxHeader.readUInt32BE(0);
const boxType = boxHeader.readUInt32BE(4);
if (boxType === 1785737827) {
offset += 8;
foundCodestream = true;
break;
}
if (boxLength < 8) break;
offset += boxLength;
}
if (!foundCodestream) return { width: null, height: null };
}
while (true) {
const chunk = await sb.read(offset, CHUNK_SIZE);
if (chunk.length < 2) break;
for (let i = 0; i < chunk.length - 1; i++) {
if (chunk[i] === 255 && chunk[i + 1] === 81) {
const sizData = await sb.read(offset + i + 22, 8);
if (sizData.length < 8) return { width: null, height: null };
return {
width: sizData.readUInt32BE(0),
// XTsiz
height: sizData.readUInt32BE(4)
// YTsiz
};
}
}
if (chunk.length < CHUNK_SIZE) break;
offset += CHUNK_SIZE - 1;
}
return { width: null, height: null };
}
async function getTileSize(stream) {
const sb = new StreamBuffer(stream);
await sb.ensure(8);
const format = detectFormat(sb.buf);
if (format === "tiff-le" || format === "tiff-be") {
return getTiffTileSize(sb, format === "tiff-le");
}
if (format === "jp2") {
return getJP2TileSize(sb);
}
return { width: null, height: null };
}
// src/geometry.ts
var debug = Debug("iiif:geometry");
async function readGeometry(withStream, geometry) {
let metadata = {};
let tileSize = {};
const result = { ...geometry };
debug("Initial geometry: %O", geometry);
if (!geometry.width || !geometry.height || !(geometry.pages || geometry.sizes)) {
await withStream(async (metadataStream) => {
metadata = await readMetadata(metadataStream);
});
if (!metadata.pages) metadata.pages = 1;
debug("Read metadata: %O", metadata);
}
if (geometry.tileWidth === void 0) {
await withStream(async (sizeStream) => {
const size = await getTileSize(sizeStream);
tileSize = { tileWidth: size.width, tileHeight: size.height };
});
debug("Read tile size: %O", tileSize);
}
const final = { ...result, ...metadata, ...tileSize };
debug("Final geometry: %O", final);
return final;
}
function calculateGeometry(geometry) {
if (geometry.sizes) {
const result = { ...geometry };
if (!geometry.pages) {
result.pages = geometry.sizes.length;
}
if (!geometry.width || !geometry.height) {
result.width = geometry.sizes[0].width;
result.height = geometry.sizes[0].height;
}
return result;
}
if (geometry.width && geometry.height) {
if (geometry.pages) {
if (geometry.pages === 1) {
return {
...geometry,
sizes: [{ width: geometry.width, height: geometry.height }]
};
}
}
if (geometry.pages > 1) {
return calculateSizesFromPages(geometry);
}
if (geometry.tileWidth && geometry.tileHeight) {
return calculateSizesFromTiles(geometry);
}
}
return geometry;
}
async function readMetadata(stream) {
const target = sharp({ limitInputPixels: false, page: 0 });
stream.pipe(target);
const { autoOrient, ...metadata } = await target.metadata();
const { width, height, pages } = { ...metadata, ...autoOrient };
return { width, height, pages };
}
function calculateSizesFromTiles(geometry) {
const pages = Math.max(
Math.ceil(Math.log2(geometry.width / geometry.tileWidth)),
Math.ceil(Math.log2(geometry.height / geometry.tileHeight))
) + 1;
return calculateSizesFromPages({ ...geometry, pages });
}
function calculateSizesFromPages(geometry) {
const result = { ...geometry };
result.sizes = [{ width: geometry.width, height: geometry.height }];
let page = 0;
for (page += 1; page < geometry.pages; page++) {
const scale = 1 / 2 ** page;
result.sizes.push({
width: Math.floor(geometry.width * scale),
height: Math.floor(geometry.height * scale)
});
}
return result;
}
// src/transform.ts
import Sharp from "sharp";
import Debug2 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, geometry, max }) {
const maxAttrs = {
maxWidth: max?.width,
maxHeight: max?.height,
maxArea: max?.area
};
const { width, height, sizes } = geometry;
const tiles = geometry.tileWidth ? [
{
width: geometry.tileWidth,
height: geometry.tileHeight || geometry.tileWidth,
scaleFactors: sizes.map((_v, i) => 2 ** i)
}
] : void 0;
return {
"@context": "http://iiif.io/api/image/2/context.json",
"@id": id,
protocol: "http://iiif.io/api/image",
width,
height,
sizes,
tiles,
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, geometry, max }) {
const maxAttrs = {
maxWidth: max?.width,
maxHeight: max?.height,
maxArea: max?.area
};
const { width, height, sizes } = geometry;
const tiles = geometry.tileWidth ? [
{
width: geometry.tileWidth,
height: geometry.tileHeight || geometry.tileWidth,
scaleFactors: sizes.map((_v, i) => 2 ** i)
}
] : void 0;
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,
...IIIFExtras,
...maxAttrs
};
}
// src/versions.ts
var Versions = {
2: v2_exports,
3: v3_exports
};
var versions_default = Versions;
// src/transform.ts
var debug2 = Debug2("iiif-processor:transform");
var DEFAULT_PAGE_THRESHOLD = 1;
var SCALE_PRECISION = 1e7;
var Operations = class {
constructor(version, dims, opts) {
const { sharp: sharp3, 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 = sharp3;
}
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];
debug2("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;
debug2("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();
debug2("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 debug3 = Debug3("iiif-processor:main");
var debugv = Debug3("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 = {
geometryFunction: null,
density: null
};
this.setOpts({
...defaults,
iiifVersion,
...opts,
prefix,
request
}).initialize(streamResolver);
}
setOpts(opts) {
this.geometryFunction = opts.geometryFunction;
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);
debug3("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(callback) {
const { id, baseUrl } = this;
debug3("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 geometry(includeTile = false) {
if (!this.imageGeometry) {
debug3(
"Attempting to use geometryFunction to retrieve dimensions for %j",
this.id
);
const params = { id: this.id, baseUrl: this.baseUrl };
let geometry = {};
if (this.geometryFunction) {
geometry = await this.geometryFunction(params);
}
if (!(geometry.tileWidth && geometry.tileHeight) && !includeTile) {
geometry.tileWidth = null;
geometry.tileHeight = null;
}
geometry = await readGeometry(this.withStream.bind(this), geometry);
this.imageGeometry = calculateGeometry(geometry);
}
return this.imageGeometry;
}
async infoJson() {
const geometry = await this.geometry(true);
const uri = new URL(this.baseUrl);
uri.pathname = path.join(uri.pathname, this.id);
const id = uri.toString();
const doc = this.Implementation.infoDoc({
id,
geometry,
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({ sizes }) {
const sharpOpt = this.sharpOptions;
const { max, pageThreshold } = this;
debug3("pageThreshold: %d", pageThreshold);
return new Operations(this.version, sizes, {
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 = sharp2(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 geometry = await this.geometry();
const operations = this.operations(geometry);
debugv("Operations: %j", operations);
const pipeline = await operations.pipeline();
const result = await this.withStream(async (stream) => {
debug3("piping stream to pipeline");
let transformed = await stream.pipe(pipeline);
if (this.debugBorder) {
transformed = await this.applyBorder(transformed);
}
debug3("converting to buffer");
return await transformed.toBuffer();
});
debug3("returning %d bytes", result.length);
debug3("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) {
debug3("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) {
debug3("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