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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- PGM format support (Netpbm Portable GrayMap): decode P2 (ASCII) and P5 (binary), encode as P5
binary with luminance-preserving grayscale conversion
- PBM format support (Netpbm Portable BitMap): decode P1 (ASCII) and P4 (binary packed-bit), encode
as P4 binary with luminance threshold
- QOI format support (Quite OK Image): full pure-JS encode and decode including all chunk types
(INDEX, DIFF, LUMA, RUN, RGB, RGBA)

## [0.4.3] - 2025-12-28

### Fixed
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ JPEG, WebP, GIF, and more—all without native dependencies.
- 🚀 **Pure JavaScript** - No native dependencies
- 🔌 **Pluggable formats** - Easy to extend with custom formats
- 📦 **Cross-runtime** - Works on Deno, Node.js (18+), Bun and Browsers.
- 🎨 **Multiple formats** - PNG, APNG, JPEG, WebP, GIF, TIFF, BMP, ICO, DNG, PAM, PPM, PCX, ASCII,
HEIC, and AVIF support
- 🎨 **Multiple formats** - PNG, APNG, JPEG, WebP, GIF, TIFF, BMP, ICO, DNG, PAM, PPM, PGM, PBM,
PCX, QOI, ASCII, HEIC, and AVIF support
- ✂️ **Image manipulation** - Resize, crop, composite, and more
- 🎛️ **Image processing** - Chainable filters including `brightness`, `contrast`, `saturation`,
`hue`, `exposure`, `blur`, `sharpen`, `sepia`, and more
Expand Down Expand Up @@ -129,7 +129,10 @@ await Bun.write("output.jpg", jpeg);
| DNG | ✅ Full | Linear DNG (Uncompressed RGBA) |
| PAM | ✅ Full | Netpbm PAM format |
| PPM | ✅ Full | Netpbm PPM format (P3/P6) |
| PGM | ✅ Full | Netpbm PGM format (P2/P5 grayscale) |
| PBM | ✅ Full | Netpbm PBM format (P1/P4 monochrome) |
| PCX | ✅ Full | ZSoft PCX (RLE compressed) |
| QOI | ✅ Full | QOI — Quite OK Image (fast lossless) |
| ASCII | ✅ Full | Text-based ASCII art |
| JPEG | ⚠️ Baseline & Progressive | Pure-JS baseline & progressive DCT: decode with spectral selection; encode with 2-scan (DC+AC) |
| WebP | ⚠️ Lossless | Pure-JS lossless VP8L |
Expand Down
24 changes: 23 additions & 1 deletion docs/src/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ This table shows which image formats are supported and their implementation stat
| DNG | ✅ | ✅ | ✅ Full | ✅ Full | N/A | N/A | Linear DNG (Uncompressed RGBA) |
| PAM | ✅ | ✅ | ✅ Full | ✅ Full | N/A | N/A | Netpbm PAM (Portable Arbitrary Map) |
| PPM | ✅ | ✅ | ✅ Full | ✅ Full | N/A | N/A | Netpbm PPM (Portable PixMap) P3/P6 formats |
| PGM | ✅ | ✅ | ✅ Full | ✅ Full | N/A | N/A | Netpbm PGM (Portable GrayMap) P2/P5 formats |
| PBM | ✅ | ✅ | ✅ Full | ✅ Full | N/A | N/A | Netpbm PBM (Portable BitMap) P1/P4 formats |
| PCX | ✅ | ✅ | ✅ Full | ✅ Full | N/A | N/A | ZSoft PCX (RLE compressed) |
| QOI | ✅ | ✅ | ✅ Full | ✅ Full | N/A | N/A | Quite OK Image — fast lossless format |
| ASCII | ✅ | ✅ | ✅ Full | ✅ Full | N/A | N/A | Text-based ASCII art representation |
| JPEG | ✅ | ✅ | ⚠️ Baseline & Progressive | ⚠️ Baseline & Progressive | ✅ ImageDecoder | ✅ OffscreenCanvas | Pure-JS decode: baseline & progressive (spectral selection + successive approximation). Encode: baseline (native) + basic progressive (pure-JS) |
| GIF | ✅ | ✅ | ✅ Full | ✅ Full | ✅ ImageDecoder | ✅ OffscreenCanvas | Complete pure-JS implementation |
Expand Down Expand Up @@ -100,6 +103,22 @@ This table shows which format standards and variants are supported:
| | - P6 (Binary) format | ✅ Full | Pure-JS |
| | - Comments in header | ✅ Full | Pure-JS |
| | - Maxval scaling (1-255) | ✅ Full | Pure-JS |
| PGM | Netpbm PGM (Portable GrayMap) | ✅ Full | Pure-JS |
| | - P2 (ASCII) format | ✅ Full | Pure-JS |
| | - P5 (Binary) format | ✅ Full | Pure-JS |
| | - Comments in header | ✅ Full | Pure-JS |
| | - Maxval scaling (1-255) | ✅ Full | Pure-JS |
| | - Encode: RGB to grayscale via luma | ✅ Full | Pure-JS |
| PBM | Netpbm PBM (Portable BitMap) | ✅ Full | Pure-JS |
| | - P1 (ASCII) format | ✅ Full | Pure-JS |
| | - P4 (Binary) format, packed bits | ✅ Full | Pure-JS |
| | - Row padding to byte boundary | ✅ Full | Pure-JS |
| | - Encode: luma threshold at 128 | ✅ Full | Pure-JS |
| QOI | QOI (Quite OK Image) | ✅ Full | Pure-JS |
| | - RGBA channels | ✅ Full | Pure-JS |
| | - RUN encoding | ✅ Full | Pure-JS |
| | - INDEX (color cache) | ✅ Full | Pure-JS |
| | - DIFF / LUMA delta encoding | ✅ Full | Pure-JS |
| PCX | ZSoft PCX Version 5 (3.0) | ✅ Full | Pure-JS |
| | - 24-bit RGB (3 planes) | ✅ Full | Pure-JS |
| | - 8-bit Palette (1 plane) | ✅ Decode only | Pure-JS |
Expand All @@ -126,7 +145,10 @@ This table shows which format standards and variants are supported:
| DNG | ✅ | ✅ | ✅ | ✅ | Pure-JS works everywhere |
| PAM | ✅ | ✅ | ✅ | ✅ | Pure-JS works everywhere |
| PPM | ✅ | ✅ | ✅ | ✅ | Pure-JS works everywhere |
| PGM | ✅ | ✅ | ✅ | ✅ | Pure-JS works everywhere |
| PBM | ✅ | ✅ | ✅ | ✅ | Pure-JS works everywhere |
| PCX | ✅ | ✅ | ✅ | ✅ | Pure-JS works everywhere |
| QOI | ✅ | ✅ | ✅ | ✅ | Pure-JS works everywhere |
| ASCII | ✅ | ✅ | ✅ | ✅ | Pure-JS works everywhere |
| GIF | ✅ | ✅ | ✅ | ✅ | Pure-JS works everywhere |
| JPEG | ✅ | ✅ | ✅ | ✅ | Pure-JS baseline & progressive DCT with spectral selection |
Expand All @@ -136,7 +158,7 @@ This table shows which format standards and variants are supported:
| AVIF | ✅ | ⚠️ Runtime | ✅ | ✅ | Requires ImageDecoder API support |

**Note**: For maximum compatibility across all runtimes, use PNG, APNG, BMP, ICO, GIF, ASCII, PCX,
PPM or DNG formats which have complete pure-JS implementations.
PPM, PGM, PBM, QOI or DNG formats which have complete pure-JS implementations.

## Implementation Details

Expand Down
5 changes: 4 additions & 1 deletion mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @module @cross/image
*
* A pure JavaScript, dependency-free, cross-runtime image processing library.
* Supports decoding, resizing, and encoding common image formats (PNG, APNG, JPEG, WebP, GIF, TIFF, BMP, ICO, DNG, PAM, PPM, PCX).
* Supports decoding, resizing, and encoding common image formats (PNG, APNG, JPEG, WebP, GIF, TIFF, BMP, ICO, DNG, PAM, PPM, PGM, PBM, PCX, QOI).
* Includes image processing capabilities like compositing, level adjustments, and pixel manipulation.
*
* @example
Expand Down Expand Up @@ -81,6 +81,9 @@ export { DNGFormat } from "./src/formats/dng.ts";
export { PAMFormat } from "./src/formats/pam.ts";
export { PCXFormat } from "./src/formats/pcx.ts";
export { PPMFormat } from "./src/formats/ppm.ts";
export { PGMFormat } from "./src/formats/pgm.ts";
export { PBMFormat } from "./src/formats/pbm.ts";
export { QOIFormat } from "./src/formats/qoi.ts";
export { ASCIIFormat } from "./src/formats/ascii.ts";
export { HEICFormat } from "./src/formats/heic.ts";
export { AVIFFormat } from "./src/formats/avif.ts";
Expand Down
236 changes: 236 additions & 0 deletions src/formats/pbm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import type { ImageData, ImageDecoderOptions, ImageFormat, ImageMetadata } from "../types.ts";
import { validateImageDimensions } from "../utils/security.ts";

/**
* PBM format handler
* Implements the Netpbm PBM (Portable BitMap) format.
* Supports both P1 (ASCII) and P4 (binary) variants.
*
* In PBM, 0 = white and 1 = black (the opposite of most image formats).
*
* Format structure:
* - P1 (ASCII format):
* P1
* <width> <height>
* 0 1 0 1 ... (0=white, 1=black)
*
* - P4 (Binary format):
* P4
* <width> <height>
* <packed bits: 1 bit per pixel, MSB first, rows padded to byte boundary>
*/
export class PBMFormat implements ImageFormat {
/** Format name identifier */
readonly name = "pbm";
/** MIME type for PBM images */
readonly mimeType = "image/x-portable-bitmap";

/**
* Check if the given data is a PBM image
* @param data Raw image data to check
* @returns true if data has PBM signature (P1 or P4)
*/
canDecode(data: Uint8Array): boolean {
if (data.length < 3) return false;
return (
data[0] === 0x50 && // P
(data[1] === 0x31 || data[1] === 0x34) && // 1 or 4
(data[2] === 0x0a || data[2] === 0x0d || data[2] === 0x20 || data[2] === 0x09)
);
}

/**
* Decode PBM image data to RGBA
* Supports both P1 (ASCII) and P4 (binary) formats
* @param data Raw PBM image data
* @returns Decoded image data with RGBA pixels (0=white 255,255,255, 1=black 0,0,0)
*/
decode(data: Uint8Array, _options?: ImageDecoderOptions): Promise<ImageData> {
if (!this.canDecode(data)) {
throw new Error("Invalid PBM signature");
}

const isBinary = data[1] === 0x34; // P4

let offset = 2;
let width = 0;
let height = 0;
let headerValues = 0;

while (offset < data.length && this.isWhitespace(data[offset])) offset++;

while (headerValues < 2 && offset < data.length) {
if (data[offset] === 0x23) { // #
while (offset < data.length && data[offset] !== 0x0a) offset++;
if (offset < data.length) offset++;
continue;
}

while (offset < data.length && this.isWhitespace(data[offset])) offset++;

let numStr = "";
while (
offset < data.length &&
!this.isWhitespace(data[offset]) &&
data[offset] !== 0x23
) {
numStr += String.fromCharCode(data[offset]);
offset++;
}

if (numStr) {
const num = parseInt(numStr, 10);
if (isNaN(num) || num <= 0) {
throw new Error(`Invalid PBM header value: ${numStr}`);
}
if (headerValues === 0) width = num;
else if (headerValues === 1) height = num;
headerValues++;
}
}

if (headerValues < 2) {
throw new Error("Incomplete PBM header");
}

if (offset < data.length && this.isWhitespace(data[offset])) offset++;

validateImageDimensions(width, height);

const pixelCount = width * height;
const rgba = new Uint8Array(pixelCount * 4);

if (isBinary) {
// P4: packed bits, 1 bit per pixel, rows padded to byte boundary, MSB first
const rowBytes = Math.ceil(width / 8);
const expectedDataLength = rowBytes * height;
if (data.length - offset < expectedDataLength) {
throw new Error(
`Invalid PBM data length: expected ${expectedDataLength}, got ${data.length - offset}`,
);
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const byteIndex = offset + y * rowBytes + Math.floor(x / 8);
const bitIndex = 7 - (x % 8);
const bit = (data[byteIndex] >> bitIndex) & 1;
const i = (y * width + x) * 4;
const v = bit ? 0 : 255; // 1=black, 0=white
rgba[i] = v;
rgba[i + 1] = v;
rgba[i + 2] = v;
rgba[i + 3] = 255;
}
}
} else {
// P1: ASCII format, values are '0' or '1' separated by whitespace
let pixelIndex = 0;
while (pixelIndex < pixelCount && offset < data.length) {
while (offset < data.length) {
if (data[offset] === 0x23) {
while (offset < data.length && data[offset] !== 0x0a) offset++;
if (offset < data.length) offset++;
} else if (this.isWhitespace(data[offset])) {
offset++;
} else {
break;
}
}
if (offset >= data.length) break;

const ch = data[offset];
if (ch !== 0x30 && ch !== 0x31) { // '0' or '1'
throw new Error(`Invalid PBM pixel value at offset ${offset}`);
}
const bit = ch - 0x30;
offset++;

const v = bit ? 0 : 255;
rgba[pixelIndex * 4] = v;
rgba[pixelIndex * 4 + 1] = v;
rgba[pixelIndex * 4 + 2] = v;
rgba[pixelIndex * 4 + 3] = 255;
pixelIndex++;
}

if (pixelIndex < pixelCount) {
throw new Error(
`Incomplete PBM pixel data: expected ${pixelCount} values, got ${pixelIndex}`,
);
}
}

return Promise.resolve({ width, height, data: rgba });
}

/**
* Encode RGBA image data to PBM format (P4 binary)
* Converts to monochrome using standard luminance threshold (128)
* Note: Alpha channel is ignored during encoding
* @param imageData Image data to encode
* @returns Encoded PBM image bytes
*/
encode(imageData: ImageData, _options?: unknown): Promise<Uint8Array> {
const { width, height, data } = imageData;

if (data.length !== width * height * 4) {
throw new Error(
`Data length mismatch: expected ${width * height * 4}, got ${data.length}`,
);
}

const header = `P4\n${width} ${height}\n`;
const encoder = new TextEncoder();
const headerBytes = encoder.encode(header);

const rowBytes = Math.ceil(width / 8);
const output = new Uint8Array(headerBytes.length + rowBytes * height);
output.set(headerBytes, 0);

for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const si = (y * width + x) * 4;
const gray = Math.round(
0.299 * data[si] + 0.587 * data[si + 1] + 0.114 * data[si + 2],
);
// dark pixels become 1 (black in PBM), bright pixels become 0 (white)
const bit = gray < 128 ? 1 : 0;
const byteIndex = headerBytes.length + y * rowBytes + Math.floor(x / 8);
const bitPosition = 7 - (x % 8);
output[byteIndex] |= bit << bitPosition;
}
}

return Promise.resolve(output);
}

/**
* Check if a byte is whitespace (space, tab, CR, LF)
*/
private isWhitespace(byte: number): boolean {
return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d;
}

/**
* Get the list of metadata fields supported by PBM format
*/
getSupportedMetadata(): Array<keyof ImageMetadata> {
return [];
}

/**
* Extract metadata from PBM data without fully decoding the pixel data
* @param data Raw PBM data
* @returns Extracted metadata or undefined
*/
extractMetadata(data: Uint8Array): Promise<ImageMetadata | undefined> {
if (!this.canDecode(data)) return Promise.resolve(undefined);
return Promise.resolve({
format: "pbm",
compression: "none",
frameCount: 1,
bitDepth: 1,
colorType: "grayscale",
});
}
}
Loading
Loading