diff --git a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceN.java b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceN.java index 6b06550ba0a..8bccbb5c1ae 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceN.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceN.java @@ -55,6 +55,9 @@ public class PDDeviceN extends PDSpecialColorSpace 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 PDSpecialColorSpace private PDColor initialColor; // color conversion cache + private Boolean useAttributes; private int numColorants; private int[] colorantToComponent; private PDColorSpace processColorSpace; @@ -176,10 +180,51 @@ private void initColorConversionCache(PDResources resources) throws IOException } } + // 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); } @@ -350,7 +395,7 @@ private BufferedImage toRGBWithTintTransform(WritableRaster raster) throws IOExc @Override public float[] toRGB(float[] value) throws IOException { - if (attributes != null) + if (useAttributes()) { return toRGBWithAttributes(value); } @@ -515,6 +560,7 @@ public void setColorantNames(List names) public void setAttributes(PDDeviceNAttributes attributes) { this.attributes = attributes; + useAttributes = null; if (attributes == null) { array.remove(DEVICEN_ATTRIBUTES); diff --git a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDSeparation.java b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDSeparation.java index 17100d890e8..5ae6e562c72 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDSeparation.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/color/PDSeparation.java @@ -257,6 +257,15 @@ public PDColorSpace getAlternateColorSpace() return alternateColorSpace; } + /** + * Returns the tint transform function. + * @return the tint transform function + */ + public PDFunction getTintTransform() + { + return tintTransform; + } + /** * Returns the colorant name. * @return the name of the colorant diff --git a/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceNTest.java b/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceNTest.java new file mode 100644 index 00000000000..31ff168d7b7 --- /dev/null +++ b/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/graphics/color/PDDeviceNTest.java @@ -0,0 +1,170 @@ +/* + * 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 java.util.List; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +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"; + + // 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, COSArray.of(0, 1)); + function.setItem(COSName.C0, COSArray.of(c0)); + function.setItem(COSName.C1, COSArray.of(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); + deviceN.add(COSArray.ofCOSNames(List.of(COLORANT))); + 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); + } +}