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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`src/utils/gif_decoder.ts` — prevents call-stack overflow
(`RangeError: Maximum call stack
size exceeded`) on adversarial GIF input
- Security: PCX decoder now calls `validateImageDimensions` after computing width/height from the
header — prevents excessively large unchecked allocations on crafted input
- Correctness: TIFF multi-frame encoder (`encodeFrames`) now caches compressed frame data from the
first pass and reuses it when writing `StripByteCounts` — previously the data was re-compressed
independently, producing a byte-count that could differ from the actual strip data and making the
TIFF unreadable
- Correctness: ICO DIB decoder now uses `Math.floor` when computing `actualHeight` from the stored
double-height field — previously an odd stored height (e.g. 5) produced a non-integer value (2.5)
that caused `validateImageDimensions` to throw on valid ICO files
- Correctness: PNG `decode()` loop now checks `pos + 8 > data.length` before reading each chunk
header — prevents silent mis-decoding of truncated PNG files
- Correctness: BMP `extractMetadata` now uses `readInt32LE` (signed) for the `xPelsPerMeter` /
`yPelsPerMeter` DPI fields, consistent with `decode()` — previously a negative stored DPI was
returned as a large positive value
- Performance: JPEG IDCT now uses a precomputed 8×8 cosine table instead of calling `Math.cos` per
coefficient — eliminates ~200M `Math.cos` calls when decoding a 2000×2000 JPEG
- Performance: `medianFilter` now allocates the four channel-value arrays once outside the pixel
loop instead of per-pixel — reduces GC pressure on large images
- Fixed misleading comment in `adjustHue`: normalization produces 0–360, not −180 to 180
- PNG decoder: 16-bit per-channel images (bitDepth=16) now decode correctly; the pixel stride was
using a fixed 8-bit offset (`x*4`, `x*3`, `x`) causing pixel-offset corruption in 16-bit RGBA,
RGB, and grayscale images
Expand Down
4 changes: 2 additions & 2 deletions src/formats/bmp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,8 @@ export class BMPFormat implements ImageFormat {

// DPI information (pixels per meter)
if (headerSize >= 40) {
const xPelsPerMeter = readUint32LE(data, 38);
const yPelsPerMeter = readUint32LE(data, 42);
const xPelsPerMeter = readInt32LE(data, 38);
const yPelsPerMeter = readInt32LE(data, 42);

if (xPelsPerMeter > 0) {
metadata.dpiX = Math.round(xPelsPerMeter * 0.0254);
Expand Down
8 changes: 4 additions & 4 deletions src/formats/ico.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ export class ICOFormat implements ImageFormat {
const bitDepth = readUint16LE(data, 14);
const compression = readUint32LE(data, 16);

// Validate dimensions
validateImageDimensions(width, Math.abs(height) / 2); // DIB height includes both XOR and AND mask data

// ICO files store height as 2x actual height (for AND mask)
const actualHeight = Math.abs(height) / 2;
const actualHeight = Math.floor(Math.abs(height) / 2);

// Validate dimensions
validateImageDimensions(width, actualHeight); // DIB height includes both XOR and AND mask data

// Only support uncompressed DIBs
if (compression !== 0) {
Expand Down
7 changes: 7 additions & 0 deletions src/formats/pcx.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ImageData, ImageDecoderOptions, ImageFormat, ImageMetadata } from "../types.ts";
import { validateImageDimensions } from "../utils/security.ts";

/**
* PCX format handler
Expand Down Expand Up @@ -53,6 +54,12 @@ export class PCXFormat implements ImageFormat {
return Promise.reject(new Error("Invalid PCX dimensions"));
}

try {
validateImageDimensions(width, height);
} catch (e) {
return Promise.reject(e);
}

// Decode RLE data
let offset = 128;
const scanlineLength = nPlanes * bytesPerLine;
Expand Down
1 change: 1 addition & 0 deletions src/formats/png.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export class PNGFormat extends PNGBase implements ImageFormat {

// Parse chunks
while (pos < data.length) {
if (pos + 8 > data.length) break;
const length = this.readUint32(data, pos);
pos += 4;
const type = String.fromCharCode(
Expand Down
18 changes: 5 additions & 13 deletions src/formats/tiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,9 @@ export class TIFFFormat implements ImageFormat {
let currentOffset = 8;
const ifdOffsets: number[] = [];
const pixelDataOffsets: number[] = [];
const compressedFrames: Uint8Array[] = [];

// Write all pixel data first
// Write all pixel data first, caching the compressed results
for (const frame of imageData.frames) {
pixelDataOffsets.push(currentOffset);

Expand All @@ -590,6 +591,7 @@ export class TIFFFormat implements ImageFormat {
pixelData = frame.data;
}

compressedFrames.push(pixelData);
for (let i = 0; i < pixelData.length; i++) {
result.push(pixelData[i]);
}
Expand Down Expand Up @@ -651,18 +653,8 @@ export class TIFFFormat implements ImageFormat {
// RowsPerStrip
this.writeIFDEntry(result, 0x0116, 4, 1, frame.height);

// StripByteCounts
let pixelDataSize: number;
if (compression === "lzw") {
pixelDataSize = new TIFFLZWEncoder().compress(frame.data).length;
} else if (compression === "packbits") {
pixelDataSize = packBitsCompress(frame.data).length;
} else if (compression === "deflate") {
pixelDataSize = (await deflateCompress(frame.data)).length;
} else {
pixelDataSize = frame.data.length;
}
this.writeIFDEntry(result, 0x0117, 4, 1, pixelDataSize);
// StripByteCounts — use the cached compressed length from the first pass
this.writeIFDEntry(result, 0x0117, 4, 1, compressedFrames[i].length);

// XResolution
const xResOffset = dataOffset;
Expand Down
14 changes: 9 additions & 5 deletions src/utils/image_processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
*/
export function adjustHue(data: Uint8Array, degrees: number): Uint8Array {
const result = new Uint8Array(data.length);
// Normalize rotation to -180 to 180 range
// Normalize rotation to 0–360 range
const rotation = ((degrees % 360) + 360) % 360;

for (let i = 0; i < data.length; i += 4) {
Expand Down Expand Up @@ -723,13 +723,17 @@ export function medianFilter(
): Uint8Array {
const result = new Uint8Array(data.length);
const clampedRadius = Math.max(1, Math.floor(radius));
const rValues: number[] = [];
const gValues: number[] = [];
const bValues: number[] = [];
const aValues: number[] = [];

for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const rValues: number[] = [];
const gValues: number[] = [];
const bValues: number[] = [];
const aValues: number[] = [];
rValues.length = 0;
gValues.length = 0;
bValues.length = 0;
aValues.length = 0;

// Collect values in kernel window
for (let ky = -clampedRadius; ky <= clampedRadius; ky++) {
Expand Down
26 changes: 19 additions & 7 deletions src/utils/jpeg_decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ interface JPEGDecoderOptions extends ImageDecoderOptions {
extractCoefficients?: boolean;
}

/**
* Precomputed IDCT cosine table: IDCT_COS[k][n] = cos((2n+1)*k*PI/16)
* Eliminates all Math.cos calls from the IDCT hot path.
*/
const IDCT_COS: Float64Array[] = Array.from(
{ length: 8 },
(_, k) =>
Float64Array.from(
{ length: 8 },
(_, n) => Math.cos((2 * n + 1) * k * Math.PI / 16),
),
);
const IDCT_SCALE = 1 / Math.sqrt(2);

export class JPEGDecoder {
private data: Uint8Array;
private pos: number = 0;
Expand Down Expand Up @@ -959,8 +973,7 @@ export class JPEGDecoder {
}

private idct(block: number[] | Int32Array): void {
// Simplified 2D IDCT
// This is a basic implementation - not optimized
// 2D IDCT using precomputed cosine table — no Math.cos in the hot path
const temp = new Float32Array(64);

// 1D IDCT on rows
Expand All @@ -970,9 +983,8 @@ export class JPEGDecoder {
for (let j = 0; j < 8; j++) {
let sum = 0;
for (let k = 0; k < 8; k++) {
const c = k === 0 ? 1 / Math.sqrt(2) : 1;
sum += c * block[offset + k] *
Math.cos((2 * j + 1) * k * Math.PI / 16);
const c = k === 0 ? IDCT_SCALE : 1;
sum += c * block[offset + k] * IDCT_COS[k][j];
}
temp[offset + j] = sum / 2;
}
Expand All @@ -983,8 +995,8 @@ export class JPEGDecoder {
for (let i = 0; i < 8; i++) {
let sum = 0;
for (let k = 0; k < 8; k++) {
const c = k === 0 ? 1 / Math.sqrt(2) : 1;
sum += c * temp[k * 8 + j] * Math.cos((2 * i + 1) * k * Math.PI / 16);
const c = k === 0 ? IDCT_SCALE : 1;
sum += c * temp[k * 8 + j] * IDCT_COS[k][i];
}
// Level shift and clamp
block[i * 8 + j] = Math.max(
Expand Down
Loading