Skip to content

JPEG decoder crashes on TIFF files with unpadded edge tiles #531

Description

@poedator

JPEG decoder crashes on TIFF files with unpadded edge tiles

thank you for the project!
this was detected by Opus when vibecoding an app using your library. hope for quick fix;

Bug

readRasters / readRGB throws RangeError: Offset is outside the bounds of the DataView when decoding JPEG-compressed tiled TIFFs whose edge tiles are not zero-padded to the full tile dimensions.

Reproduction

Any JPEG-compressed (compression=7) tiled TIFF where ImageWidth is not a multiple of TileWidth or ImageLength is not a multiple of TileLength, and the TIFF writer encoded edge tiles at their actual pixel dimensions rather than padding them to the full tile size.

Example: a 3744×5616 image with 256×256 tiles. The last column of tiles covers 160 pixels (3744 mod 256 = 160). The JPEG data inside those tiles has SOF dimensions 160×256 instead of 256×256.

const tiff = await GeoTIFF.fromArrayBuffer(buffer);
const image = await tiff.getImage(0);
await image.readRGB({ interleave: true }); // RangeError

Root cause

In src/geotiffimage.js, _readRaster computes pixel offsets into the decoded tile buffer using tileWidth as the row stride (line ~561):

const pixelOffset = ((y * tileWidth) + x) * bytesPerPixel;

The JPEG decoder in src/compression/jpeg.js returns a buffer whose dimensions match the JPEG SOF header (frame.samplesPerLine × frame.scanLines). For edge tiles this is smaller than tileWidth × tileHeight, so the offset goes out of bounds.

getBlockHeight(y) already handles the last strip row for stripped images, but for tiled images it always returns tileHeight (line ~291). getBlockWidth() always returns tileWidth. Neither accounts for the actual decoded buffer size.

TIFF spec context

TIFF 6.0 §15 says edge tiles "are padded to tile boundaries", but many real-world writers (including some that produce BigTIFF) emit unpadded edge tiles. libtiff, GDAL, and other readers handle both cases.

Proposed fix

Pad the JPEG decoder output to the expected tile dimensions in JpegDecoder.decodeBlock(). The decoder already has access to this.parameters.tileWidth and this.parameters.tileHeight, and the actual JPEG dimensions are available from the parsed frame.

// src/compression/jpeg.js — JpegDecoder.decodeBlock()
decodeBlock(buffer) {
  this.reader.resetFrames();
  this.reader.parse(new Uint8Array(buffer));
  const result = this.reader.getResult();

  const { tileWidth, tileHeight } = this.parameters;
  const frame = this.reader.frames[0];
  if (frame) {
    const jpegW = frame.samplesPerLine;
    const jpegH = frame.scanLines;
    const numComponents = frame.componentsOrder.length;
    if (jpegW !== tileWidth || jpegH !== tileHeight) {
      const padded = new Uint8Array(tileWidth * tileHeight * numComponents);
      for (let y = 0; y < jpegH; y++) {
        const srcOff = y * jpegW * numComponents;
        const dstOff = y * tileWidth * numComponents;
        padded.set(
          result.subarray(srcOff, srcOff + jpegW * numComponents),
          dstOff,
        );
      }
      return padded.buffer;
    }
  }

  return result.buffer;
}

This copies each row of the decoded JPEG into a zero-padded buffer with the correct stride. The extra pixels are zero-filled and never read by _readRaster because xmax/ymax already clamp iteration to the image extent.

An alternative fix would be in _readRaster itself — derive the actual buffer stride from buffer.byteLength rather than assuming tileWidth. The decoder-side fix is simpler and more contained.

Environment

  • geotiff.js v3.0.5
  • Browser (UMD bundle)
  • BigTIFF, JPEG compression, YCbCr photometric, 256×256 tiles, image dimensions not a multiple of tile size

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions