UNPKG

iiif-processor

Version:

IIIF 2.1 & 3.0 Image API modules for NodeJS

703 lines (691 loc) 21 kB
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