Skip to content

putImageData regression in 0.1.92: opaque pixels incorrectly blended with semi-transparent pixels when rendering to PNG #1210

@mirao

Description

@mirao

Bug Report

I hit the bug when using the pdf-to-img module.

    const { pdf } = await import("pdf-to-img");
    const document = await pdf(pdfFile, {
        scale: 300 / 72, // scale = target DPI / PDF base DPI (72 pt/in) → renders at 300 DPI
    });
    writeFileSync(pngFile, await document.getPage(pageNumber));

The following report was created by Claude Opus 4.6

Versions affected

  • Broken: 0.1.92, 0.1.93
  • Working: 0.1.91

The regression was introduced in 0.1.92 (PR #1206 — "fix: putImageData bypassed deferred rendering PageRecorder").

Description

When an ImageData contains a mix of fully opaque pixels and semi-transparent pixels, calling putImageData() and then toBuffer("image/png") produces wrong pixel values. The opaque pixels are incorrectly blended with the surrounding semi-transparent pixels, as if compositing rather than replacing.

This manifests in practice when using pdfjs-dist to render PDFs containing embedded raster images: pdfjs-dist internally calls putImageData() in 16-row chunks where each chunk has fully opaque content pixels mixed with semi-transparent edge/anti-alias pixels (from the PDF's SMask alpha channel). The image area is replaced by a repeating horizontal stripe pattern. Each stripe corresponds to one of the 16-row chunks written by putBinaryImageData — only the top rows of each chunk appear visible, while the remaining rows are lost, producing a banded/tiled effect instead of the actual image content.

Minimal reproducer

import { createCanvas } from "@napi-rs/canvas";
import { PNG } from "pngjs";

// Simulate pdfjs putBinaryImageData: canvas filled in 16-row chunks,
// each chunk has MIXED pixels — mostly semi-transparent (edge/anti-alias
// pixels from SMask) plus one fully opaque content pixel.
const WIDTH = 20, CHUNK_HEIGHT = 16, NUM_CHUNKS = 3;
const canvas = createCanvas(WIDTH, CHUNK_HEIGHT * NUM_CHUNKS);
const ctx = canvas.getContext("2d");

for (let c = 0; c < NUM_CHUNKS; c++) {
  const imgData = ctx.createImageData(WIDTH, CHUNK_HEIGHT);
  // Fill with semi-transparent light gray (alpha=14)
  for (let i = 0; i < imgData.data.length; i += 4) {
    imgData.data[i]=230; imgData.data[i+1]=229; imgData.data[i+2]=229; imgData.data[i+3]=14;
  }
  // One fully opaque dark pixel in the middle
  const idx = (8 * WIDTH + 10) * 4;
  const v = 50 + c * 48;
  imgData.data[idx]=v; imgData.data[idx+1]=v; imgData.data[idx+2]=v; imgData.data[idx+3]=255;
  ctx.putImageData(imgData, 0, c * CHUNK_HEIGHT);
}

const png = PNG.sync.read(canvas.toBuffer("image/png"));
for (let c = 0; c < NUM_CHUNKS; c++) {
  const row = c * CHUNK_HEIGHT + 8;
  const idx = (row * WIDTH + 10) * 4;
  const expected = 50 + c * 48;
  const got = png.data[idx];
  console.log(`Chunk ${c}: expected RGBA(${expected},${expected},${expected},255) — got RGBA(${png.data[idx]},${png.data[idx+1]},${png.data[idx+2]},${png.data[idx+3]})`);
}

Expected output (0.1.91)

Chunk 0: expected RGBA(50,50,50,255) — got RGBA(50,50,50,255)
Chunk 1: expected RGBA(98,98,98,255) — got RGBA(98,98,98,255)
Chunk 2: expected RGBA(146,146,146,255) — got RGBA(146,146,146,255)

Actual output (0.1.92, 0.1.93)

Chunk 0: expected RGBA(50,50,50,255) — got RGBA(87,87,87,204)
Chunk 1: expected RGBA(98,98,98,255) — got RGBA(126,126,126,204)
Chunk 2: expected RGBA(146,146,146,255) — got RGBA(164,164,164,204)

The opaque pixel (alpha=255) is incorrectly blended with the surrounding semi-transparent pixels (alpha=14), producing a wrong colour and a non-255 alpha in the output.

Root cause hypothesis

PR #1206 routed putImageData through PageRecorder.put_pixels() using write_pixels_dirty. It appears that when pixels with mixed alpha values are written, the blend mode or compositing used by write_pixels_dirty incorrectly composites the pixels instead of replacing them as the HTML Canvas spec requires for putImageData (putImageData must replace pixel values verbatim, ignoring globalAlpha and compositing).

Workaround

Pin @napi-rs/canvas to 0.1.91 via pnpm overrides:

"pnpm": {
  "overrides": {
    "@napi-rs/canvas": "0.1.91"
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions