-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
♻️ Read meta data from PNG and JPEG files
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
Showing
6 changed files
with
251 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |