Author: tilman
Date: Mon Sep 14 13:24:12 2026
New Revision: 1938201
Log:
PDFBOX-6260: add chromatic adaptation, by Valery Bokov and Claude Code
Modified:
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDCIEDictionaryBasedColorSpace.java
Modified:
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDCIEDictionaryBasedColorSpace.java
==============================================================================
---
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDCIEDictionaryBasedColorSpace.java
Mon Sep 14 13:24:09 2026 (r1938200)
+++
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDCIEDictionaryBasedColorSpace.java
Mon Sep 14 13:24:12 2026 (r1938201)
@@ -33,6 +33,20 @@ public abstract class PDCIEDictionaryBas
private static final ColorSpace CIEXYZ =
ColorSpace.getInstance(ColorSpace.CS_CIEXYZ);
+ // java.awt.color.ColorSpace(CS_CIEXYZ) treats its input as relative to
the D50 profile
+ // connection space (verified: XYZ(0.9642 1.0 0.8249) is the only
whitepoint that round-trips
+ // to RGB(1,1,1)). A CalRGB/CalGray/Lab dictionary can declare any other
whitepoint, so the
+ // XYZ computed from it must be chromatically adapted to D50 first, or
non-D50 whitepoints
+ // pick up a uniform color cast - see PDFBOX-6260, where whitepoint
(0.5505 1.0 1.989) made
+ // pure white input render as cyan and mid-gray as a cyan-blue.
+ private static final double[][] BRADFORD = {
+ { 0.8951000, 0.2664000, -0.1614000},
+ {-0.7502000, 1.7135000, 0.0367000},
+ { 0.0389000, -0.0685000, 1.0296000}
+ };
+ private static final double[][] BRADFORD_INV = invert(BRADFORD);
+ private static final double[] PCS_WHITE_D50 = {0.9642, 1.0, 0.8249};
+
// we need to cache whitepoint values, because using getWhitePoint()
// would create a new default object for each pixel conversion if the
original
// PDF didn't have a whitepoint array
@@ -40,6 +54,10 @@ public abstract class PDCIEDictionaryBas
protected float wpY = 1;
protected float wpZ = 1;
+ // chromatic adaptation matrix from this space's whitepoint to the D50 PCS
white, cached
+ // per color space instance (not per pixel) since it only depends on the
declared whitepoint
+ private float[] chromaticAdaptation;
+
protected PDCIEDictionaryBasedColorSpace(COSName cosName)
{
array = new COSArray();
@@ -68,31 +86,102 @@ public abstract class PDCIEDictionaryBas
wpX = whitepoint.getX();
wpY = whitepoint.getY();
wpZ = whitepoint.getZ();
+ chromaticAdaptation = adaptationMatrix(new double[] { wpX, wpY, wpZ },
PCS_WHITE_D50);
}
protected float[] convXYZtoRGB(float x, float y, float z)
{
+ float ax = chromaticAdaptation[0] * x + chromaticAdaptation[1] * y +
chromaticAdaptation[2] * z;
+ float ay = chromaticAdaptation[3] * x + chromaticAdaptation[4] * y +
chromaticAdaptation[5] * z;
+ float az = chromaticAdaptation[6] * x + chromaticAdaptation[7] * y +
chromaticAdaptation[8] * z;
+
// toRGB() malfunctions with negative values
// XYZ must be non-negative anyway:
//
http://ninedegreesbelow.com/photography/icc-profile-negative-tristimulus.html
- if (x < 0)
+ if (ax < 0)
{
- x = 0;
+ ax = 0;
}
- if (y < 0)
+ if (ay < 0)
{
- y = 0;
+ ay = 0;
}
- if (z < 0)
+ if (az < 0)
{
- z = 0;
+ az = 0;
}
return CIEXYZ.toRGB(new float[]
{
- x, y, z
+ ax, ay, az
});
}
+ private static float[] adaptationMatrix(double[] srcWhite, double[]
dstWhite)
+ {
+ double[] srcCone = multiply(BRADFORD, srcWhite);
+ double[] dstCone = multiply(BRADFORD, dstWhite);
+ double[][] scale = {
+ { dstCone[0] / srcCone[0], 0, 0 },
+ { 0, dstCone[1] / srcCone[1], 0 },
+ { 0, 0, dstCone[2] / srcCone[2] }
+ };
+ double[][] m = multiply(multiply(BRADFORD_INV, scale), BRADFORD);
+ return new float[]
+ {
+ (float) m[0][0], (float) m[0][1], (float) m[0][2],
+ (float) m[1][0], (float) m[1][1], (float) m[1][2],
+ (float) m[2][0], (float) m[2][1], (float) m[2][2]
+ };
+ }
+
+ private static double[] multiply(double[][] m, double[] v)
+ {
+ return new double[]
+ {
+ m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
+ m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
+ m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2]
+ };
+ }
+
+ private static double[][] multiply(double[][] a, double[][] b)
+ {
+ double[][] r = new double[3][3];
+ for (int i = 0; i < 3; i++)
+ {
+ for (int j = 0; j < 3; j++)
+ {
+ r[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] *
b[2][j];
+ }
+ }
+ return r;
+ }
+
+ private static double[][] invert(double[][] m)
+ {
+ double det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
+ - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
+ return new double[][]
+ {
+ {
+ (m[1][1] * m[2][2] - m[1][2] * m[2][1]) / det,
+ -(m[0][1] * m[2][2] - m[0][2] * m[2][1]) / det,
+ (m[0][1] * m[1][2] - m[0][2] * m[1][1]) / det
+ },
+ {
+ -(m[1][0] * m[2][2] - m[1][2] * m[2][0]) / det,
+ (m[0][0] * m[2][2] - m[0][2] * m[2][0]) / det,
+ -(m[0][0] * m[1][2] - m[0][2] * m[1][0]) / det
+ },
+ {
+ (m[1][0] * m[2][1] - m[1][1] * m[2][0]) / det,
+ -(m[0][0] * m[2][1] - m[0][1] * m[2][0]) / det,
+ (m[0][0] * m[1][1] - m[0][1] * m[1][0]) / det
+ }
+ };
+ }
+
/**
* This will return the whitepoint tristimulus. As this is a required field
* this will never return null. A default of 1,1,1 will be returned if the