Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

Only features and major fixes are listed. Everything else can be considered a minor bugfix or maintenance release.

##### v8.0.0
- **BREAKING CHANGE**: `DimensionFunction` has been replaced with `GeometryFunction`, changing
both the option name and the shape of the expected return value
- Can now probe the image stream for tile size information
- Information document (`info.json`) is now rendered using tile size information from the image
(or from the `GeometryFunction`), falling back to a default of 256

##### v7.0.0
- Made entire suite able to pass the [IIIF Image API Validator](https://iiif.io/api/image/validator/)
- Added automatic redirect for requests that don't specify a transformation or `info.json`
Expand Down
73 changes: 51 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const processor = new Processor(url, streamResolver, opts);
* `url` (string, required) - the URL of the IIIF resource to process
* `streamResolver` (async function, required) – returns a Promise of a readable image stream for a given request ([see below](#stream-resolver)); the legacy two-argument callback form is deprecated
* `opts`:
* `dimensionFunction` (function) – a callback function that returns the image dimensions for a given request ([see below](#dimension-function))
* `geometryFunction` (function) – a callback function that returns the image geometry for a given request ([see below](#geometry-function))
* `max` (object) – optional maximum size constraints of an image that can be returned
* `width` (integer) - the maximum pixel width of the returned image
* `height` (integer) - the maximum pixel height of the returned image
Expand Down Expand Up @@ -90,32 +90,61 @@ Note: The two-argument callback form is still supported but deprecated; prefer t
promise-based resolver shown above. If you currently return a stream synchronously,
wrap it with `Promise.resolve()` or mark your function `async`.

### Dimension Function
### Geometry Function

The calling function can also supply the processor with an optional Dimension callback that takes information about the request [(`id` and `baseUrl`)](#id--baseurl) and returns the dimensions of the source image. This allows for caching dimensions and avoiding an expensive image request.
The calling function can also supply the processor with an optional Geometry callback that takes information about the request [(`id` and `baseUrl`)](#id--baseurl) and returns information about the geometry of the source image. This allows for caching dimensions and other information, and avoiding an expensive image request.

The function should return either:
The function should return an object conforming to the `ImageGeometry` type, for example:

* a `{width: w, height: h}` object indicating the dimensions of the source image
* an array of `{width: w, height: h}` objects indicating the dimensions of all of the pages available within the source image, if it is a multi-resolution image (e.g., a pyramidal TIFF), e.g.:
```
[
{ width: 14499, height: 12069 },
{ width: 7249, height: 6034 },
{ width: 3624, height: 3017 },
{ width: 1812, height: 1508 },
{ width: 906, height: 754 },
{ width: 453, height: 377 },
{ width: 226, height: 188 }
]
```
```typescript
{
width: 4096,
height: 3072,
pages: 6,
sizes: [
{width: 4096, height: 3072},
{width: 2048, height: 1536},
{width: 1024, height: 768},
{width: 512, height: 384},
{width: 256, height: 192},
{width: 128, height: 96}
],
tileWidth: 128,
tileHeight: 128
}
```

Any information not included will be calculated or probed for, if possible. For example:

| Fields Provided | Fields Calculated | Fields Probed |
| -------------------------- | ----------------- | -------------------------- |
| none | `sizes` | `width`, `height`, `pages` |
| `width`, `height`, `sizes` | `pages` | |
| `width`, `height`, `pages` | `sizes` | |

#### Tile Size

Tile size information is independent of dimension and page information, and is only checked
when rendering the image information document (`info.json`). If either `tileWidth` or `tileHeight`
is left `undefined` by the Geometry Function, the image stream will be probed for them, which
can be an expensive operation. If both are provided – even if they are `null` – the given values
will be used. (`null` values will be replaced by a default value of `256` when rendering the
information document).

Providing the dimensions of all available pages allows the processor to choose the most efficient starting image for the size requested.
The following example shows a Geometry Function that looks up the width, height, and number of
pages in the target image in a database and returns them along with hardcoded tile sizes. The
`sizes` array will be automatically calculated by the processor.

```typescript
async function dimensionFunction({ id: string, baseUrl: string }): Promise<Dimensions | Dimensions[]> {
async function geometryFunction({ id: string, baseUrl: string }): Promise<ImageGeometry> {
let dimensions = lookDimensionsUpInDatabase(id);
return { width: dimensions.width, height: dimensions.height };
return {
width: dimensions.width,
height: dimensions.height,
pages: dimensions.pages,
tileWidth: 128,
tileHeight: 128
};
}
```

Expand All @@ -142,7 +171,7 @@ In addition, certain error conditions may result in the throwing of an `IIIFErro
import { Processor } from "iiif-processor";

let url = "http://iiif.example.com/iiif/2/abcdefgh/full/400,/0/default.jpg"
let processor = new Processor(url, streamResolver, { dimensionFunction });
let processor = new Processor(url, streamResolver, { geometryFunction });
processor.execute()
.then(result => handleResult(result))
.catch(err => handleError(err));
Expand All @@ -153,7 +182,7 @@ processor.execute()
import { Processor } from "iiif-processor";

let url = "http://iiif.example.com/iiif/2/abcdefgh/full/400,/0/default.jpg"
let processor = new Processor(url, streamResolver, { dimensionFunction });
let processor = new Processor(url, streamResolver, { geometryFunction });
try {
return await processor.execute();
} catch (err) {
Expand Down
20 changes: 9 additions & 11 deletions examples/tiny-iiif/iiif.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import { App } from '@tinyhttp/app';
import {
Processor,
IIIFError,
ContentResult,
ErrorResult,
ProcessorResult,
RedirectResult
} from 'iiif-processor';
import { Processor, IIIFError, ProcessorResult } from 'iiif-processor';
import fs from 'fs';
import path from 'path';
import { iiifImagePath, iiifpathPrefix, fileTemplate } from './config';
Expand Down Expand Up @@ -54,20 +47,25 @@ const render = async (req: any, res: any) => {
}
};

function createRouter (version: number) {
function createRouter(version: number) {
const router = new App();

router.use((_req, res, next) => {
res.set('Access-Control-Allow-Headers', '*');
res.set('Access-Control-Allow-Methods', 'OPTIONS, HEAD, GET, POST, PUT, DELETE');
res.set(
'Access-Control-Allow-Methods',
'OPTIONS, HEAD, GET, POST, PUT, DELETE'
);
res.set('Access-Control-Allow-Origin', '*');
next();
});

router.options('*', (_req, res) => {
res.status(204).send('');
});
router.get('/', (_req, res) => res.status(200).send(`IIIF v${version}.x endpoint OK`));
router.get('/', (_req, res) =>
res.status(200).send(`IIIF v${version}.x endpoint OK`)
);
router.get('/:id', render);
router.get('/:id/info.json', render);
router.get('/:id/:region/:size/:rotation/:filename', render);
Expand Down
2 changes: 1 addition & 1 deletion examples/tiny-iiif/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"dev": "IIIF_IMAGE_PATH=${IIIF_IMAGE_PATH:-./tiff} nodemon",
"lint": "eslint *.ts",
"lint-fix": "eslint --fix *.ts",
"tiny-iiif": "IIIF_IMAGE_PATH=./tiff tsx index.ts",
"tiny-iiif": "IIIF_IMAGE_PATH=${IIIF_IMAGE_PATH:-./tiff} tsx index.ts",
"dev:all": "concurrently -n build,server -c blue,green \"npm run --prefix ../.. build:watch\" \"npm run dev\"",
"validator": "IIIF_IMAGE_PATH=../../validator/fixtures nodemon"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/tiny-iiif/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2020",
"types": ["node"],
Expand Down
15 changes: 9 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "iiif-processor",
"version": "7.0.0",
"version": "8.0.0-alpha.2",
"description": "IIIF 2.1 & 3.0 Image API modules for NodeJS",
"main": "dist/index.js",
"module": "dist/index.mjs",
Expand Down Expand Up @@ -106,7 +106,8 @@
"src/**/*.{js,ts}"
],
"coveragePathIgnorePatterns": [
"<rootDir>/tsup.config.ts"
"<rootDir>/tsup.config.ts",
"src/tile-size.ts"
],
"testPathIgnorePatterns": [
"<rootDir>[/\\\\](build|docs|node_modules|scripts)[/\\\\]"
Expand Down
14 changes: 10 additions & 4 deletions src/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import type { BoundingBox, Dimensions, Format, IIIFSpec, MaxDimensions, Quality } from './types';
import type {
BoundingBox,
Dimensions,
Format,
IIIFSpec,
ImageGeometry,
MaxDimensions,
Quality
} from './types';

export interface Calculated {
region: BoundingBox;
Expand Down Expand Up @@ -28,9 +36,7 @@ export type CalculatorCtor = {

export interface InfoDocInput {
id: string;
width: number;
height: number;
sizes: Dimensions[];
geometry: ImageGeometry;
max?: MaxDimensions;
}

Expand Down
107 changes: 107 additions & 0 deletions src/geometry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Debug from 'debug';
import type { ImageGeometry } from './types';
import sharp from 'sharp';
import { Readable } from 'stream';
import { getTileSize } from './tile-size';

const debug = Debug('iiif:geometry');

type StreamCallback = (stream: Readable) => Promise<unknown>;

export async function readGeometry(
withStream: (callback: StreamCallback) => Promise<unknown>,
geometry: ImageGeometry
): Promise<ImageGeometry> {
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);
});
debug('Read metadata: %O', metadata);
}

if (geometry.tileWidth === undefined || geometry.tileHeight === undefined) {
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;
}

export function calculateGeometry(geometry: ImageGeometry): ImageGeometry {
if (geometry.sizes) {
const result: ImageGeometry = { ...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: Readable): Promise<ImageGeometry> {
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: ImageGeometry): ImageGeometry {
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: ImageGeometry): ImageGeometry {
const result: ImageGeometry = { ...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;
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
export { IIIFError } from './error';
export {
DimensionFunction,
GeometryFunction,
StreamResolver,
StreamResolverWithCallback,
Processor,
ProcessorOptions
} from './processor';
export {
ImageGeometry,
ContentResult,
RedirectResult,
ErrorResult,
Expand Down
Loading
Loading