Skip to content

Commit

Permalink
♻️ Read meta data from PNG and JPEG files
Browse files Browse the repository at this point in the history
In order to decouple the image loader from the pdf-lib library, this
commit introduces functions to read meta data (particularly width and
height) from PNG and JPEG files.
  • Loading branch information
ralfstx committed Dec 10, 2023
1 parent a5f131d commit 6b41e14
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 6 deletions.
6 changes: 3 additions & 3 deletions src/image-loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import crypto from 'node:crypto';
import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

import { describe, expect, it } from '@jest/globals';
Expand Down Expand Up @@ -43,7 +43,7 @@ describe('image-loader', () => {
});

it('reads width and height from JPEG image', async () => {
const data = readFileSync(join(__dirname, './test/resources/liberty.jpg'));
const data = await readFile(join(__dirname, './test/resources/liberty.jpg'));
const loader = createImageLoader([{ name: 'liberty', data, format: 'jpeg' }]);

const store = createImageStore(loader);
Expand All @@ -53,7 +53,7 @@ describe('image-loader', () => {
});

it('reads width and height from PNG image', async () => {
const data = readFileSync(join(__dirname, './test/resources/torus.png'));
const data = await readFile(join(__dirname, './test/resources/torus.png'));
const loader = createImageLoader([{ name: 'torus', data, format: 'png' }]);

const store = createImageStore(loader);
Expand Down
7 changes: 4 additions & 3 deletions src/image-loader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { JpegEmbedder, PngEmbedder, toUint8Array } from 'pdf-lib';
import { toUint8Array } from 'pdf-lib';

import { Image, ImageDef, ImageFormat, ImageSelector } from './images.js';
import { readJpegInfo } from './images/jpeg.js';
import { readPngInfo } from './images/png.js';

export type LoadedImage = {
format: ImageFormat;
Expand Down Expand Up @@ -51,8 +53,7 @@ export function createImageStore(imageLoader: ImageLoader): ImageStore {
}

const { format, data } = loadedImage;
const embedder = await (format === 'png' ? PngEmbedder.for(data) : JpegEmbedder.for(data));
const { width, height } = embedder;
const { width, height } = format === 'png' ? readPngInfo(data) : readJpegInfo(data);
return { name: selector.name, format, data, width, height };
}
}
36 changes: 36 additions & 0 deletions src/images/jpeg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

import { describe, expect, it } from '@jest/globals';

import { isJpeg, readJpegInfo } from './jpeg.js';

describe('jpeg', () => {
describe('isJpeg', () => {
it('returns true for JPEG header', async () => {
const data = new Uint8Array([0xff, 0xd8, 0xff]);

expect(isJpeg(data)).toBe(true);
});

it('returns false for other data', async () => {
expect(isJpeg(new Uint8Array())).toBe(false);
expect(isJpeg(new Uint8Array([1, 2, 3]))).toBe(false);
});
});

describe('readJpegInfo', () => {
it('returns info', async () => {
const libertyJpg = await readFile(join(__dirname, '../test/resources/liberty.jpg'));

const info = readJpegInfo(libertyJpg);

expect(info).toEqual({
width: 160,
height: 240,
bitDepth: 8,
colorSpace: 'rgb',
});
});
});
});
81 changes: 81 additions & 0 deletions src/images/jpeg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export type JpegInfo = {
/**
* Image width in pixel.
*/
width: number;
/**
* Image height in pixel.
*/
height: number;
/**
* Bit depth per channel.
*/
bitDepth: number;
/**
* Color space.
*/
colorSpace: 'grayscale' | 'rgb' | 'cmyk';
};

/**
* Determines if the given data is the beginning of a JPEG file.
*/
export function isJpeg(data: Uint8Array) {
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
}

/**
* Analyzes JPEG data and returns info on the file.
*/
export function readJpegInfo(data: Uint8Array): JpegInfo {
if (!isJpeg(data)) {
throw new Error('Invalid JPEG data');
}
let pos = 0;
const len = data.length;
let info: JpegInfo | undefined;
while (pos < len - 1) {
if (data[pos++] !== 0xff) {
continue;
}
const type = data[pos++];
if (type === 0x00) {
// padding byte
continue;
}
if (type >= 0xd0 && type <= 0xd9) {
// these types have no body
continue;
}
const length = readUint16BE(data, pos);
pos += 2;

// Frame header types: 0xc0 .. 0xcf except 0xc4, 0xc8, 0xcc
if (type >= 0xc0 && type <= 0xcf && type !== 0xc4 && type !== 0xc8 && type !== 0xcc) {
const bitDepth = data[pos];
const height = readUint16BE(data, pos + 1);
const width = readUint16BE(data, pos + 3);
const colorSpace = getColorSpace(data[pos + 5]);
info = { width, height, bitDepth, colorSpace };
}

pos += length - 2;
}

if (!info) {
throw new Error('Invalid JPEG data');
}

return info;
}

function getColorSpace(colorSpace: number): 'rgb' | 'grayscale' | 'cmyk' {
if (colorSpace === 1) return 'grayscale';
if (colorSpace === 3) return 'rgb';
if (colorSpace === 4) return 'cmyk'; // Adobe extension
throw new Error('Invalid color space');
}

function readUint16BE(buffer: Uint8Array, offset: number) {
return (buffer[offset] << 8) | buffer[offset + 1];
}
39 changes: 39 additions & 0 deletions src/images/png.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

import { describe, expect, it } from '@jest/globals';

import { isPng, readPngInfo } from './png.js';

describe('png', () => {
describe('isPng', () => {
it('returns true if PNG header found', async () => {
const info = isPng(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));

expect(info).toBe(true);
});

it('returns false for other data', async () => {
expect(isPng(new Uint8Array())).toBe(false);
expect(isPng(new Uint8Array([1, 2, 3, 4, 5]))).toBe(false);
});
});

describe('readPngInfo', () => {
it('returns info', async () => {
const torusPng = await readFile(join(__dirname, '../test/resources/torus.png'));

const info = readPngInfo(torusPng);

expect(info).toEqual({
width: 256,
height: 192,
bitDepth: 8,
colorSpace: 'rgb',
hasAlpha: true,
isIndexed: false,
isInterlaced: false,
});
});
});
});
88 changes: 88 additions & 0 deletions src/images/png.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
export type PngInfo = {
/**
* Image width in pixel.
*/
width: number;
/**
* Image height in pixel.
*/
height: number;
/**
* Bit depth per channel.
*/
bitDepth: number;
/**
* Color space.
*/
colorSpace: 'grayscale' | 'rgb';
/**
* True if the image has an alpha channel.
*/
hasAlpha: boolean;
/**
* True if the image has indexed colors.
*/
isIndexed: boolean;
/**
* True if the image is interlaced.
*/
isInterlaced: boolean;
};

export function isPng(data: Uint8Array) {
// check PNG signature
return hasBytes(data, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
}

/**
* Analyzes PNG data. Requires only the first 32 bytes of the file.
* @param data PNG data
* @returns PNG info
*/
export function readPngInfo(data: Uint8Array): PngInfo {
if (!isPng(data)) {
throw new Error('Invalid PNG data');
}
// read IHDR chunk
if (data[12] !== 0x49 || data[13] !== 0x48 || data[14] !== 0x44 || data[15] !== 0x52) {
throw new Error('Invalid PNG data');
}
const width = readUint32BE(data, 16);
const height = readUint32BE(data, 20);
const bitDepth = data[24];
const colorType = data[25];
const interlacing = data[28];
return {
width,
height,
bitDepth,
colorSpace: getColorSpace(colorType),
hasAlpha: colorType === 4 || colorType === 6,
isIndexed: colorType === 3,
isInterlaced: interlacing === 1,
};
}

// 0: grayscale
// 2: RGB
// 3: RGB indexed
// 4: grayscale with alpha channel
// 6: RGB with alpha channel
function getColorSpace(value: number): 'rgb' | 'grayscale' {
if (value === 0 || value === 4) return 'grayscale';
if (value === 2 || value === 3 || value === 6) return 'rgb';
throw new Error('Invalid color space');
}

function readUint32BE(data: Uint8Array, offset: number) {
return (
(data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]
);
}

function hasBytes(data: Uint8Array, offset: number, bytes: number[]) {
for (let i = 0; i < bytes.length; i++) {
if (data[offset + i] !== bytes[i]) return false;
}
return true;
}

0 comments on commit 6b41e14

Please sign in to comment.