Skip to content

Commit 0bff2ef

Browse files
oboasturio
authored andcommitted
fix: resolve indexed color image color distortion in PDF output (#1499)
1 parent 673940e commit 0bff2ef

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

openpdf-core/src/main/java/org/openpdf/text/Image.java

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,15 @@
6161
import org.openpdf.text.pdf.PdfObject;
6262
import org.openpdf.text.pdf.PdfReader;
6363
import org.openpdf.text.pdf.PdfStream;
64+
import org.openpdf.text.pdf.PdfString;
6465
import org.openpdf.text.pdf.PdfTemplate;
6566
import org.openpdf.text.pdf.PdfWriter;
6667
import org.openpdf.text.pdf.codec.CCITTG4Encoder;
6768
import java.awt.Graphics2D;
6869
import java.awt.color.ICC_Profile;
6970
import java.awt.image.BufferedImage;
71+
import java.awt.image.IndexColorModel;
72+
import java.awt.image.WritableRaster;
7073
import java.io.IOException;
7174
import java.io.InputStream;
7275
import java.lang.reflect.Constructor;
@@ -824,6 +827,63 @@ public static Image getInstance(java.awt.Image image, java.awt.Color color,
824827
if (bi.getType() == BufferedImage.TYPE_BYTE_BINARY && bi.getColorModel().getNumColorComponents() <= 2) {
825828
forceBW = true;
826829
}
830+
831+
// Handle indexed color images
832+
if (bi.getColorModel() instanceof IndexColorModel && !forceBW) {
833+
IndexColorModel icm = (IndexColorModel) bi.getColorModel();
834+
int mapSize = icm.getMapSize();
835+
int bitsPerPixel = icm.getPixelSize();
836+
837+
// Ensure bits per pixel is valid (1, 2, 4, or 8)
838+
// For PDF indexed images, bpc should be the bits needed to index the palette
839+
if (bitsPerPixel > 8 || bitsPerPixel == 0) {
840+
bitsPerPixel = 8;
841+
} else if (bitsPerPixel > 4) {
842+
bitsPerPixel = 8;
843+
} else if (bitsPerPixel > 2) {
844+
bitsPerPixel = 4;
845+
} else if (bitsPerPixel > 1) {
846+
bitsPerPixel = 2;
847+
} else {
848+
bitsPerPixel = 1;
849+
}
850+
851+
// Extract palette data
852+
byte[] reds = new byte[mapSize];
853+
byte[] greens = new byte[mapSize];
854+
byte[] blues = new byte[mapSize];
855+
icm.getReds(reds);
856+
icm.getGreens(greens);
857+
icm.getBlues(blues);
858+
859+
// Build palette as RGB byte array
860+
byte[] palette = new byte[mapSize * 3];
861+
for (int i = 0; i < mapSize; i++) {
862+
palette[i * 3] = reds[i];
863+
palette[i * 3 + 1] = greens[i];
864+
palette[i * 3 + 2] = blues[i];
865+
}
866+
867+
// Extract pixel indices
868+
int width = bi.getWidth();
869+
int height = bi.getHeight();
870+
byte[] pixelData = generateIndexedColorPixelData(width, bitsPerPixel, height, bi.getRaster());
871+
// Create indexed image with palette
872+
Image img = Image.getInstance(width, height, 1, bitsPerPixel, pixelData);
873+
874+
// Set up indexed colorspace: [/Indexed /DeviceRGB maxIndex palette]
875+
PdfArray indexed = new PdfArray();
876+
indexed.add(PdfName.INDEXED);
877+
indexed.add(PdfName.DEVICERGB);
878+
indexed.add(new PdfNumber(mapSize - 1));
879+
indexed.add(new PdfString(palette));
880+
881+
PdfDictionary additional = new PdfDictionary();
882+
additional.put(PdfName.COLORSPACE, indexed);
883+
img.setAdditional(additional);
884+
885+
return img;
886+
}
827887
}
828888

829889
java.awt.image.PixelGrabber pg = new java.awt.image.PixelGrabber(image,
@@ -987,6 +1047,88 @@ public static Image getInstance(java.awt.Image image, java.awt.Color color,
9871047
}
9881048
}
9891049

1050+
/**
1051+
* Generates PDF-compliant pixel data for indexed color images (IndexColorModel).
1052+
* <p>
1053+
* This method packs palette indices from a WritableRaster into a byte array that strictly adheres to
1054+
* PDF specification requirements for indexed color image storage:
1055+
* <ul>
1056+
* <li>Pixel indices are packed starting from the Most Significant Bit (MSB, bit 7) of each byte (PDF mandatory rule)</li>
1057+
* <li>Each row of pixel data is byte-aligned (padded with zeros to match calculated row stride)</li>
1058+
* <li>Supports standard indexed color bit depths: 1, 2, 4, 8 bits per pixel</li>
1059+
* <li>Normalizes palette indices to unsigned 0-255 range to prevent invalid negative values</li>
1060+
* </ul>
1061+
*
1062+
* @param width Width of the indexed color image (in pixels)
1063+
* @param bitsPerPixel Number of bits per pixel (must be 1, 2, 4, or 8 for valid indexed color)
1064+
* @param height Height of the indexed color image (in pixels)
1065+
* @param raster WritableRaster containing the indexed color pixel indices (from IndexColorModel BufferedImage)
1066+
* @return Byte array of pixel data packed according to PDF indexed color specifications, with row-wise byte alignment
1067+
* @see WritableRaster
1068+
* @see IndexColorModel
1069+
*/
1070+
private static byte[] generateIndexedColorPixelData(int width, int bitsPerPixel, int height, WritableRaster raster) {
1071+
int rowStride = (width * bitsPerPixel + 7) / 8;
1072+
byte[] pixelData = new byte[rowStride * height];
1073+
1074+
int bytePos = 0;
1075+
int bitOffset;
1076+
1077+
for (int y = 0; y < height; y++) {
1078+
bitOffset = 7;
1079+
for (int x = 0; x < width; x++) {
1080+
int pixelIndex = raster.getSample(x, y, 0);
1081+
if (pixelIndex < 0) {
1082+
pixelIndex = 0;
1083+
}
1084+
pixelIndex = pixelIndex & 0xFF;
1085+
1086+
bitOffset = packPixelByBitDepth(bitsPerPixel, pixelIndex, pixelData, bytePos, bitOffset);
1087+
1088+
if (bitOffset < 0) {
1089+
bytePos++;
1090+
bitOffset = 7;
1091+
}
1092+
}
1093+
int usedBytesInRow = bytePos - (y * rowStride);
1094+
if (usedBytesInRow < rowStride) {
1095+
int padBytes = rowStride - usedBytesInRow;
1096+
bytePos += padBytes;
1097+
}
1098+
}
1099+
return pixelData;
1100+
}
1101+
1102+
/**
1103+
* Packs a single pixel index into the target byte array based on specified bit depth (PDF MSB-first rule).
1104+
*/
1105+
private static int packPixelByBitDepth(int bitsPerPixel, int pixelIndex, byte[] pixelData, int bytePos, int bitOffset) {
1106+
int currentBitOffset = bitOffset;
1107+
1108+
switch (bitsPerPixel) {
1109+
case 1:
1110+
if ((pixelIndex & 0x01) == 1) {
1111+
pixelData[bytePos] |= (byte) (1 << currentBitOffset);
1112+
}
1113+
currentBitOffset--;
1114+
break;
1115+
case 2:
1116+
pixelData[bytePos] |= (byte) ((pixelIndex & 0x03) << (currentBitOffset - 1));
1117+
currentBitOffset -= 2;
1118+
break;
1119+
case 4:
1120+
pixelData[bytePos] |= (byte) ((pixelIndex & 0x0F) << (currentBitOffset - 3));
1121+
currentBitOffset -= 4;
1122+
break;
1123+
case 8:
1124+
default:
1125+
pixelData[bytePos] = (byte) pixelIndex;
1126+
currentBitOffset = -1;
1127+
break;
1128+
}
1129+
return currentBitOffset;
1130+
}
1131+
9901132
/**
9911133
* Gets an instance of an Image from a java.awt.Image.
9921134
*

openpdf-core/src/test/java/org/openpdf/text/ImageTest.java

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
import static org.junit.jupiter.api.Assertions.assertNotNull;
55
import static org.junit.jupiter.api.Assertions.assertNull;
66

7+
import java.awt.image.BufferedImage;
8+
import java.awt.image.IndexColorModel;
79
import java.io.IOException;
810
import java.io.InputStream;
911
import org.junit.jupiter.api.Test;
12+
import org.openpdf.text.pdf.PdfName;
1013

1114
class ImageTest {
1215

@@ -119,4 +122,59 @@ private byte[] readFileBytes() throws IOException {
119122
return bytes;
120123
}
121124

125+
@Test
126+
void shouldDetectIndexedColorGif() throws Exception {
127+
// Load H.gif which is an indexed color GIF
128+
String fileName = "src/test/resources/H.gif";
129+
Image image = Image.getInstance(fileName);
130+
131+
assertNotNull(image);
132+
// colorspace should be 1 for indexed images (not 3 for RGB)
133+
assertThat(image.getColorspace()).isEqualTo(1);
134+
135+
// Verify that additional colorspace info is set for indexed images
136+
assertThat(image.getAdditional()).isNotNull();
137+
assertThat(image.getAdditional().get(PdfName.COLORSPACE)).isNotNull();
138+
}
139+
140+
@Test
141+
void shouldDetectIndexedColorFromBufferedImage() throws Exception {
142+
// Create an indexed color BufferedImage programmatically
143+
int width = 10;
144+
int height = 10;
145+
146+
// Create a simple 4-color palette (red, green, blue, black)
147+
byte[] reds = {(byte) 255, 0, 0, 0};
148+
byte[] greens = {0, (byte) 255, 0, 0};
149+
byte[] blues = {0, 0, (byte) 255, 0};
150+
151+
IndexColorModel colorModel = new IndexColorModel(
152+
2, // 2 bits per pixel (4 colors)
153+
4, // 4 colors in palette
154+
reds, greens, blues
155+
);
156+
157+
BufferedImage bufferedImage = new BufferedImage(
158+
width, height, BufferedImage.TYPE_BYTE_INDEXED, colorModel
159+
);
160+
161+
// Fill with some pattern
162+
for (int y = 0; y < height; y++) {
163+
for (int x = 0; x < width; x++) {
164+
bufferedImage.getRaster().setSample(x, y, 0, (x + y) % 4);
165+
}
166+
}
167+
168+
// Convert to Image
169+
Image image = Image.getInstance(bufferedImage, null);
170+
171+
assertNotNull(image);
172+
// Should be indexed (colorspace = 1), not RGB (colorspace = 3)
173+
assertThat(image.getColorspace()).isEqualTo(1);
174+
175+
// Verify that additional colorspace info is set
176+
assertThat(image.getAdditional()).isNotNull();
177+
assertThat(image.getAdditional().get(PdfName.COLORSPACE)).isNotNull();
178+
}
179+
122180
}

0 commit comments

Comments
 (0)