Author: tilman
Date: Tue Sep 22 09:05:27 2026
New Revision: 1938425

Log:
PDFBOX-5074: use the tint transform of DeviceN if a spot colorant isn't white 
at zero tint, by Valery Bokov and Claude Code; closes #534

Added:
   
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceNTest.java
   (contents, props changed)
Modified:
   
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceN.java
   
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDSeparation.java

Modified: 
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceN.java
==============================================================================
--- 
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceN.java
    Tue Sep 22 08:58:09 2026        (r1938424)
+++ 
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceN.java
    Tue Sep 22 09:05:27 2026        (r1938425)
@@ -55,6 +55,9 @@ public class PDDeviceN extends PDSpecial
     private static final int TINT_TRANSFORM = 3;
     private static final int DEVICEN_ATTRIBUTES = 4;
 
+    // RGB values (0..1) of a spot colorant with a zero tint must be at least 
this to count as white
+    private static final float WHITE_THRESHOLD = 0.9f;
+
     // fields
     private PDColorSpace alternateColorSpace = null;
     private PDFunction tintTransform = null;
@@ -62,6 +65,7 @@ public class PDDeviceN extends PDSpecial
     private PDColor initialColor;
 
     // color conversion cache
+    private Boolean useAttributes;
     private int numColorants;
     private int[] colorantToComponent;
     private PDColorSpace processColorSpace;
@@ -191,10 +195,51 @@ public class PDDeviceN extends PDSpecial
         }
     }
 
+    // Tells whether the conversion should be done with the attributes 
(process and spot
+    // colorants) instead of the tint transform. The result is calculated on 
first use.
+    private boolean useAttributes() throws IOException
+    {
+        if (attributes == null)
+        {
+            return false;
+        }
+        if (useAttributes == null)
+        {
+            useAttributes = !hasSpotColorantWithInkAtZeroTint();
+        }
+        return useAttributes;
+    }
+
+    // The attributes conversion multiplies the RGB values of the colorants, 
so a colorant with a
+    // zero tint has to be white, i.e. it must not change the result. A spot 
colorant that is not
+    // white at zero tint (e.g. black, see PDFBOX-5074) can't be combined like 
that and the
+    // result would be too dark. In that case the tint transform is the better 
choice.
+    private boolean hasSpotColorantWithInkAtZeroTint() throws IOException
+    {
+        for (int c = 0; c < numColorants; c++)
+        {
+            if (colorantToComponent[c] >= 0 || spotColorSpaces[c] == null)
+            {
+                // not a spot colorant that is used for the conversion
+                continue;
+            }
+            // don't use PDSeparation.toRGB(), it caches by tint, so the value 
for zero would also
+            // be returned for all the following tints that are near zero
+            PDSeparation spot = spotColorSpaces[c];
+            float[] altValue = spot.getTintTransform().eval(new float[] { 0 });
+            float[] rgb = spot.getAlternateColorSpace().toRGB(altValue);
+            if (rgb[0] < WHITE_THRESHOLD || rgb[1] < WHITE_THRESHOLD || rgb[2] 
< WHITE_THRESHOLD)
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
     @Override
     public BufferedImage toRGBImage(WritableRaster raster) throws IOException
     {
-        if (attributes != null)
+        if (useAttributes())
         {
             return toRGBWithAttributes(raster);
         }
@@ -365,7 +410,7 @@ public class PDDeviceN extends PDSpecial
     @Override
     public float[] toRGB(float[] value) throws IOException
     {
-        if (attributes != null)
+        if (useAttributes())
         {
             return toRGBWithAttributes(value);
         }
@@ -530,6 +575,7 @@ public class PDDeviceN extends PDSpecial
     public void setAttributes(PDDeviceNAttributes attributes)
     {
         this.attributes = attributes;
+        useAttributes = null;
         if (attributes == null)
         {
             array.remove(DEVICEN_ATTRIBUTES);

Modified: 
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDSeparation.java
==============================================================================
--- 
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDSeparation.java
 Tue Sep 22 08:58:09 2026        (r1938424)
+++ 
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDSeparation.java
 Tue Sep 22 09:05:27 2026        (r1938425)
@@ -270,6 +270,15 @@ public class PDSeparation extends PDSpec
     }
 
     /**
+     * Returns the tint transform function.
+     * @return the tint transform function
+     */
+    PDFunction getTintTransform()
+    {
+        return tintTransform;
+    }
+
+    /**
      * Returns the colorant name.
      * @return the name of the colorant
      */

Added: 
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceNTest.java
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceNTest.java
        Tue Sep 22 09:05:27 2026        (r1938425)
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.pdfbox.pdmodel.graphics.color;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBuffer;
+import java.awt.image.Raster;
+import java.awt.image.WritableRaster;
+import java.io.IOException;
+
+import org.apache.pdfbox.cos.COSArray;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSFloat;
+import org.apache.pdfbox.cos.COSName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests the conversion of DeviceN colorspaces that have a single spot 
colorant in the attributes.
+ */
+class PDDeviceNTest
+{
+    private static final String COLORANT = "PANTONE 342 C";
+
+    private static COSArray cosArrayOf(float... floats)
+    {
+        COSArray ar = new COSArray();
+        for (float f : floats)
+        {
+            ar.add(new COSFloat(f));
+        }
+        return ar;
+    }
+
+    // 1 input, exponent 1: a linear ramp from c0 to c1
+    private static COSDictionary linearFunction(float[] c0, float[] c1)
+    {
+        COSDictionary function = new COSDictionary();
+        function.setInt(COSName.FUNCTION_TYPE, 2);
+        function.setItem(COSName.DOMAIN, cosArrayOf(0, 1));
+        function.setItem(COSName.C0, cosArrayOf(c0));
+        function.setItem(COSName.C1, cosArrayOf(c1));
+        function.setInt(COSName.N, 1);
+        return function;
+    }
+
+    // DeviceN with CMYK as alternate and a spot colorant (Separation with RGB 
as alternate)
+    private static PDDeviceN createDeviceN(float[] spotAtZero, float[] 
spotAtOne,
+            float[] cmykAtOne) throws IOException
+    {
+        COSArray separation = new COSArray();
+        separation.add(COSName.SEPARATION);
+        separation.add(COSName.getPDFName(COLORANT));
+        separation.add(COSName.DEVICERGB);
+        separation.add(linearFunction(spotAtZero, spotAtOne));
+
+        COSDictionary colorants = new COSDictionary();
+        colorants.setItem(COSName.getPDFName(COLORANT), separation);
+        COSDictionary attributes = new COSDictionary();
+        attributes.setItem(COSName.COLORANTS, colorants);
+
+        COSArray deviceN = new COSArray();
+        deviceN.add(COSName.DEVICEN);
+        COSArray nameArray = new COSArray();
+        nameArray.add(COSName.getPDFName(COLORANT));
+        deviceN.add(nameArray);
+        deviceN.add(COSName.DEVICECMYK);
+        deviceN.add(linearFunction(new float[] { 0, 0, 0, 0 }, cmykAtOne));
+        deviceN.add(attributes);
+        return new PDDeviceN(deviceN, null);
+    }
+
+    private static float[] viaTintTransform(PDDeviceN deviceN, float tint) 
throws IOException
+    {
+        float[] alt = deviceN.getTintTransform().eval(new float[] { tint });
+        return deviceN.getAlternateColorSpace().toRGB(alt);
+    }
+
+    /**
+     * PDFBOX-5074: the spot colorant is black at zero tint, which would make 
the colorant (and
+     * therefore the whole space) dark even if no ink is used. The tint 
transform must be used.
+     */
+    @Test
+    void testSpotColorantNotWhiteAtZeroTint() throws IOException
+    {
+        PDDeviceN deviceN = createDeviceN(new float[] { 0, 0, 0 }, new float[] 
{ 0, 0.41f, 0.31f },
+                new float[] { 1, 0.09f, 0.66f, 0.41f });
+
+        float[] zero = deviceN.toRGB(new float[] { 0 });
+        for (float component : zero)
+        {
+            assertTrue(component > 0.9f, "no ink must be white, but was " + 
component);
+        }
+        assertArrayEquals(viaTintTransform(deviceN, 0), zero, 0.0001f);
+
+        float[] tint = deviceN.toRGB(new float[] { 0.4f });
+        assertArrayEquals(viaTintTransform(deviceN, 0.4f), tint, 0.0001f);
+        // the attributes would have resulted in a dark green: 0, 0.16, 0.12
+        assertTrue(tint[0] > 0.3f, "expected a light color, but red was " + 
tint[0]);
+    }
+
+    /**
+     * Same for images: white where there is no tint, and not the black of the 
colorant.
+     */
+    @Test
+    void testSpotColorantNotWhiteAtZeroTintImage() throws IOException
+    {
+        PDDeviceN deviceN = createDeviceN(new float[] { 0, 0, 0 }, new float[] 
{ 0, 0.41f, 0.31f },
+                new float[] { 1, 0.09f, 0.66f, 0.41f });
+
+        WritableRaster raster = 
Raster.createBandedRaster(DataBuffer.TYPE_BYTE, 2, 1, 1, null);
+        raster.setSample(0, 0, 0, 0);
+        raster.setSample(1, 0, 0, 102); // 0.4
+        BufferedImage image = deviceN.toRGBImage(raster);
+
+        int noInk = image.getRGB(0, 0);
+        assertEquals(0xFF, (noInk >> 16) & 0xFF, 2);
+        assertEquals(0xFF, (noInk >> 8) & 0xFF, 2);
+        assertEquals(0xFF, noInk & 0xFF, 2);
+
+        float[] expected = viaTintTransform(deviceN, 0.4f);
+        int tint = image.getRGB(1, 0);
+        assertEquals(expected[0] * 255, (tint >> 16) & 0xFF, 2);
+        assertEquals(expected[1] * 255, (tint >> 8) & 0xFF, 2);
+        assertEquals(expected[2] * 255, tint & 0xFF, 2);
+    }
+
+    /**
+     * A spot colorant that is white at zero tint is what the attributes 
expect, they must still be
+     * used and not the tint transform (which is red here to make the 
difference obvious).
+     */
+    @Test
+    void testSpotColorantWhiteAtZeroTint() throws IOException
+    {
+        PDDeviceN deviceN = createDeviceN(new float[] { 1, 1, 1 }, new float[] 
{ 0, 0.41f, 0.31f },
+                new float[] { 0, 1, 1, 0 });
+
+        float[] tint = deviceN.toRGB(new float[] { 0.5f });
+        assertEquals(0.5f, tint[0], 0.0001f);
+        assertEquals(0.705f, tint[1], 0.0001f);
+        assertEquals(0.655f, tint[2], 0.0001f);
+
+        WritableRaster raster = 
Raster.createBandedRaster(DataBuffer.TYPE_BYTE, 1, 1, 1, null);
+        raster.setSample(0, 0, 0, 255); // 1.0
+        int rgb = deviceN.toRGBImage(raster).getRGB(0, 0);
+        assertEquals(0, (rgb >> 16) & 0xFF, 2);
+        assertEquals(0.41f * 255, (rgb >> 8) & 0xFF, 2);
+        assertEquals(0.31f * 255, rgb & 0xFF, 2);
+    }
+
+    /**
+     * Checking whether the spot colorant is white must not change later 
conversions: the result
+     * of a Separation is cached per tint, and that must not make a tint near 
zero look like zero.
+     */
+    @Test
+    void testZeroTintCheckDoesNotAffectNearZeroTints() throws IOException
+    {
+        PDDeviceN deviceN = createDeviceN(new float[] { 1, 1, 1 }, new float[] 
{ 0, 0, 0 },
+                new float[] { 0, 1, 1, 0 });
+
+        // 0.003 * 255 < 1, so this tint is in the same cache slot as zero
+        float[] nearZero = deviceN.toRGB(new float[] { 0.003f });
+        assertEquals(1 - 0.003f, nearZero[0], 0.0001f);
+    }
+}

Reply via email to