Skip to content

fix: resolve indexed color image color distortion in PDF output (#1499)#1502

Merged
asturio merged 1 commit intoLibrePDF:masterfrom
wang0331:master
Mar 1, 2026
Merged

fix: resolve indexed color image color distortion in PDF output (#1499)#1502
asturio merged 1 commit intoLibrePDF:masterfrom
wang0331:master

Conversation

@wang0331
Copy link
Contributor

@wang0331 wang0331 commented Mar 1, 2026

Root Cause

The core issue was that the indexed color image processing logic failed to comply with PDF specification requirements for pixel data storage:

  1. Pixel data was filled from the least significant bit (LSB) of the byte instead of the most significant bit (MSB) as mandated by PDF, leading to incorrect palette index parsing and color distortion.
  2. Pixel data lacked byte alignment (row stride calculation) — incomplete bytes at the end of each row were not padded with zeros, causing row misalignment and image garbling.
  3. 4-bit pixel values were not properly shifted to their correct bit positions in the byte, resulting in truncated or misplaced index values.
  4. Unsigned palette index values were incorrectly cast to signed bytes, leading to negative index values and invalid palette lookups.

Solution

  1. Implemented strict PDF-compliant bit packing: Fill pixel data starting from the MSB (bit 7) of each byte and adjust bit offset dynamically based on pixel bit depth (1/2/4/8 bits).
  2. Calculated row stride ((width * bitsPerPixel + 7) / 8) to ensure each row of pixel data occupies an integer number of bytes, with zero-padding for incomplete bytes at row boundaries.
  3. Added dedicated bit-shifting logic for 4-bit pixels to ensure they occupy contiguous 4-bit positions in the byte (MSB to LSB).
  4. Normalized palette index values using & 0xFF to maintain unsigned range (0-255) and prevent negative values.
  5. Enforced row boundary alignment by padding empty bytes at the end of each row to match the calculated row stride, ensuring no cross-row pixel splitting.

Related Issue: #1499

Unit-Tests for the new Feature/Bugfix

Executed org.openpdf.text.ImageTest to ensure correct results

Compatibilities Issues

Your real name

account:wang0331
name:Xue-Ren Wang

Testing details

example code from @DRoppelt

@wang0331
Copy link
Contributor Author

wang0331 commented Mar 1, 2026

@asturio @andreasrosdal
I recreated a repo, patched my code and resubmitted the PR.

Unit tests and part of the code are based on #1479, so the relevant issue can be accepted directly.

@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 1, 2026

@asturio asturio merged commit 0bff2ef into LibrePDF:master Mar 1, 2026
10 checks passed
@DRoppelt
Copy link

DRoppelt commented Mar 2, 2026

for the curious, here is a git-patch from Image.java compared to the faulty copilot version

Index: openpdf-core/src/main/java/org/openpdf/text/Image.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/openpdf-core/src/main/java/org/openpdf/text/Image.java b/openpdf-core/src/main/java/org/openpdf/text/Image.java
--- a/openpdf-core/src/main/java/org/openpdf/text/Image.java	(revision cd29acb4f4449936e194ab288a7d40949ff355db)
+++ b/openpdf-core/src/main/java/org/openpdf/text/Image.java	(date 1772437230591)
@@ -827,13 +827,13 @@
             if (bi.getType() == BufferedImage.TYPE_BYTE_BINARY && bi.getColorModel().getNumColorComponents() <= 2) {
                 forceBW = true;
             }
-            
+
             // Handle indexed color images
             if (bi.getColorModel() instanceof IndexColorModel && !forceBW) {
                 IndexColorModel icm = (IndexColorModel) bi.getColorModel();
                 int mapSize = icm.getMapSize();
                 int bitsPerPixel = icm.getPixelSize();
-                
+
                 // Ensure bits per pixel is valid (1, 2, 4, or 8)
                 // For PDF indexed images, bpc should be the bits needed to index the palette
                 if (bitsPerPixel > 8 || bitsPerPixel == 0) {
@@ -847,7 +847,7 @@
                 } else {
                     bitsPerPixel = 1;
                 }
-                
+
                 // Extract palette data
                 byte[] reds = new byte[mapSize];
                 byte[] greens = new byte[mapSize];
@@ -855,7 +855,7 @@
                 icm.getReds(reds);
                 icm.getGreens(greens);
                 icm.getBlues(blues);
-                
+
                 // Build palette as RGB byte array
                 byte[] palette = new byte[mapSize * 3];
                 for (int i = 0; i < mapSize; i++) {
@@ -863,33 +863,25 @@
                     palette[i * 3 + 1] = greens[i];
                     palette[i * 3 + 2] = blues[i];
                 }
-                
+
                 // Extract pixel indices
                 int width = bi.getWidth();
                 int height = bi.getHeight();
-                byte[] pixelData = new byte[width * height];
-                
-                WritableRaster raster = bi.getRaster();
-                for (int y = 0; y < height; y++) {
-                    for (int x = 0; x < width; x++) {
-                        pixelData[y * width + x] = (byte) raster.getSample(x, y, 0);
-                    }
-                }
-                
+                byte[] pixelData = generateIndexedColorPixelData(width, bitsPerPixel, height, bi.getRaster());
                 // Create indexed image with palette
                 Image img = Image.getInstance(width, height, 1, bitsPerPixel, pixelData);
-                
+
                 // Set up indexed colorspace: [/Indexed /DeviceRGB maxIndex palette]
                 PdfArray indexed = new PdfArray();
                 indexed.add(PdfName.INDEXED);
                 indexed.add(PdfName.DEVICERGB);
                 indexed.add(new PdfNumber(mapSize - 1));
                 indexed.add(new PdfString(palette));
-                
+
                 PdfDictionary additional = new PdfDictionary();
                 additional.put(PdfName.COLORSPACE, indexed);
                 img.setAdditional(additional);
-                
+
                 return img;
             }
         }
@@ -1055,6 +1047,88 @@
         }
     }
 
+    /**
+     * Generates PDF-compliant pixel data for indexed color images (IndexColorModel).
+     * <p>
+     * This method packs palette indices from a WritableRaster into a byte array that strictly adheres to
+     * PDF specification requirements for indexed color image storage:
+     * <ul>
+     *   <li>Pixel indices are packed starting from the Most Significant Bit (MSB, bit 7) of each byte (PDF mandatory rule)</li>
+     *   <li>Each row of pixel data is byte-aligned (padded with zeros to match calculated row stride)</li>
+     *   <li>Supports standard indexed color bit depths: 1, 2, 4, 8 bits per pixel</li>
+     *   <li>Normalizes palette indices to unsigned 0-255 range to prevent invalid negative values</li>
+     * </ul>
+     *
+     * @param width Width of the indexed color image (in pixels)
+     * @param bitsPerPixel Number of bits per pixel (must be 1, 2, 4, or 8 for valid indexed color)
+     * @param height Height of the indexed color image (in pixels)
+     * @param raster WritableRaster containing the indexed color pixel indices (from IndexColorModel BufferedImage)
+     * @return Byte array of pixel data packed according to PDF indexed color specifications, with row-wise byte alignment
+     * @see WritableRaster
+     * @see IndexColorModel
+     */
+    private static byte[] generateIndexedColorPixelData(int width, int bitsPerPixel, int height, WritableRaster raster) {
+        int rowStride = (width * bitsPerPixel + 7) / 8;
+        byte[] pixelData = new byte[rowStride * height];
+
+        int bytePos = 0;
+        int bitOffset;
+
+        for (int y = 0; y < height; y++) {
+            bitOffset = 7;
+            for (int x = 0; x < width; x++) {
+                int pixelIndex = raster.getSample(x, y, 0);
+                if (pixelIndex < 0) {
+                    pixelIndex = 0;
+                }
+                pixelIndex = pixelIndex & 0xFF;
+
+                bitOffset = packPixelByBitDepth(bitsPerPixel, pixelIndex, pixelData, bytePos, bitOffset);
+
+                if (bitOffset < 0) {
+                    bytePos++;
+                    bitOffset = 7;
+                }
+            }
+            int usedBytesInRow = bytePos - (y * rowStride);
+            if (usedBytesInRow < rowStride) {
+                int padBytes = rowStride - usedBytesInRow;
+                bytePos += padBytes;
+            }
+        }
+        return pixelData;
+    }
+
+    /**
+     * Packs a single pixel index into the target byte array based on specified bit depth (PDF MSB-first rule).
+     */
+    private static int packPixelByBitDepth(int bitsPerPixel, int pixelIndex, byte[] pixelData, int bytePos, int bitOffset) {
+        int currentBitOffset = bitOffset;
+
+        switch (bitsPerPixel) {
+            case 1:
+                if ((pixelIndex & 0x01) == 1) {
+                    pixelData[bytePos] |= (byte) (1 << currentBitOffset);
+                }
+                currentBitOffset--;
+                break;
+            case 2:
+                pixelData[bytePos] |= (byte) ((pixelIndex & 0x03) << (currentBitOffset - 1));
+                currentBitOffset -= 2;
+                break;
+            case 4:
+                pixelData[bytePos] |= (byte) ((pixelIndex & 0x0F) << (currentBitOffset - 3));
+                currentBitOffset -= 4;
+                break;
+            case 8:
+            default:
+                pixelData[bytePos] = (byte) pixelIndex;
+                currentBitOffset = -1;
+                break;
+        }
+        return currentBitOffset;
+    }
+
     /**
      * Gets an instance of an Image from a java.awt.Image.
      *

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants