Author: tilman
Date: Sun Aug 16 12:20:11 2026
New Revision: 1937155
Log:
PDFBOX-6077: combine stencil mask alpha with the pattern's own alpha, fix
soft-masked patterns used as a stencil mask fill, dilate paint alpha before
combining with the stencil mask, by Valery Bokov and Claude Code; closes #491
Modified:
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawer.java
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/rendering/SoftMask.java
Modified:
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawer.java
==============================================================================
---
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawer.java
Sun Aug 16 12:20:07 2026 (r1937154)
+++
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawer.java
Sun Aug 16 12:20:11 2026 (r1937155)
@@ -1217,6 +1217,15 @@ public class PageDrawer extends PDFGraph
// draw the paint
Paint paint = getNonStrokingPaint();
+ // PDFBOX-6077: a soft mask's Paint/PaintContext machinery (see
+ // applySoftMaskToPaint()) assumes it is asked to render
directly onto the real
+ // page raster, using its own cached absolute page-device
coordinates. This
+ // stencil-mask-with-pattern case instead renders into an
isolated scratch image
+ // (see the note above about "device scale is not used"), so
unwrap any soft mask
+ // here, fill with the plain underlying paint below, and apply
the soft mask's own
+ // alpha afterwards by directly looking up its backing raster
(applySoftMaskAlpha).
+ SoftMask softMask = paint instanceof SoftMask ? (SoftMask)
paint : null;
+ Paint innerPaint = softMask != null ? softMask.getPaint() :
paint;
Rectangle2D unitRect = new Rectangle2D.Float(0, 0, 1, 1);
Rectangle2D bounds =
at.createTransformedShape(unitRect).getBounds2D();
int w = (int) Math.ceil(bounds.getWidth());
@@ -1224,11 +1233,25 @@ public class PageDrawer extends PDFGraph
BufferedImage renderedPaint = new BufferedImage(w, h,
BufferedImage.TYPE_INT_ARGB);
Graphics2D g = (Graphics2D) renderedPaint.getGraphics();
g.translate(-bounds.getMinX(), -bounds.getMinY());
- g.setPaint(paint);
+ g.setPaint(innerPaint);
g.setRenderingHints(graphics.getRenderingHints());
g.fill(bounds);
g.dispose();
+ // PDFBOX-6077 (for the file in PDFBOX-5403):
+ // a paint such as a TilingPaint can have hairline, fully
+ // transparent seams of its own (e.g. sub-pixel rounding at
tile boundaries),
+ // which used to be invisible because the mask's alpha always
overwrote the
+ // paint's alpha below. Now that the two are combined, widen
the paint's alpha to
+ // the maximum of its 4-neighbors first, so those seams don't
get mistaken for
+ // genuine gaps the paint never painted into.
+ dilateAlpha(renderedPaint);
+
+ if (softMask != null)
+ {
+ applySoftMaskAlpha(renderedPaint, bounds, softMask);
+ }
+
// draw the mask
BufferedImage mask = pdImage.getImage();
AffineTransform imageTransform = new AffineTransform(at);
@@ -1307,7 +1330,12 @@ public class PageDrawer extends PDFGraph
{
alphaPixel = alpha.getPixel(x, y, alphaPixel);
rasterPixel = raster.getPixel(x, y, rasterPixel);
- rasterPixel[3] = alphaPixel[0];
+ // PDFBOX-6077 (for the file in PDFBOX-5403):
+ // combine with the paint's own alpha instead of
+ // overwriting it, so gaps the paint never drew into
(e.g. between
+ // tiles of a tiling pattern) stay transparent instead
of turning
+ // into opaque black.
+ rasterPixel[3] = rasterPixel[3] * alphaPixel[0] / 255;
raster.setPixel(x, y, rasterPixel);
}
}
@@ -1349,6 +1377,135 @@ public class PageDrawer extends PDFGraph
}
}
+ /**
+ * PDFBOX-6077 (for the file of PDFBOX-5403):
+ * widens each pixel's alpha channel to the maximum of itself and its 4
+ * neighbors, in place. Used to absorb hairline (1 pixel wide)
fully-transparent seams in a
+ * paint's own rendering - e.g. rounding seams between adjacent tiles of a
TilingPaint -
+ * before that alpha is combined with a stencil mask's alpha, so such a
seam isn't mistaken
+ * for a genuine gap the paint never painted into.
+ *
+ * @param image the ARGB image to dilate the alpha channel of, in place.
+ */
+ private static void dilateAlpha(BufferedImage image)
+ {
+ WritableRaster raster = image.getRaster();
+ int width = image.getWidth();
+ int height = image.getHeight();
+ int[] alpha = new int[width * height];
+ int[] pixel = null;
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ pixel = raster.getPixel(x, y, pixel);
+ alpha[y * width + x] = pixel[3];
+ }
+ }
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ int index = y * width + x;
+ int widened = alpha[index];
+ if (x > 0)
+ {
+ widened = Math.max(widened, alpha[index - 1]);
+ }
+ if (x < width - 1)
+ {
+ widened = Math.max(widened, alpha[index + 1]);
+ }
+ if (y > 0)
+ {
+ widened = Math.max(widened, alpha[index - width]);
+ }
+ if (y < height - 1)
+ {
+ widened = Math.max(widened, alpha[index + width]);
+ }
+ if (widened != alpha[index])
+ {
+ pixel = raster.getPixel(x, y, pixel);
+ pixel[3] = widened;
+ raster.setPixel(x, y, pixel);
+ }
+ }
+ }
+ }
+
+ /**
+ * PDFBOX-6077: applies a soft mask's alpha directly to "image", which was
filled with the
+ * soft mask's underlying paint but not yet masked by it. This
re-implements
+ * {@link SoftMask}'s own alpha lookup (see its
SoftPaintContext.getRaster()) rather than
+ * relying on the Paint/PaintContext machinery, because "image" is a small
scratch buffer -
+ * not the real page raster that the soft mask's absolute device
coordinates are relative to
+ * - and unlike a plain coordinate offset, transforming each pixel
individually stays correct
+ * even though this scratch buffer isn't rendered at the page's actual
device scale (see the
+ * "device scale is not used" note where this method is called from).
+ *
+ * @param image the ARGB image to apply the soft mask's alpha to, in place.
+ * @param bounds the device-independent bounds (see "at" in drawImage())
that image's pixel
+ * (0, 0) to (image.getWidth(), image.getHeight()) covers.
+ * @param softMask the soft mask to apply.
+ */
+ private void applySoftMaskAlpha(BufferedImage image, Rectangle2D bounds,
SoftMask softMask) throws IOException
+ {
+ AffineTransform deviceTransform = graphics.getTransform();
+ Raster maskRaster = softMask.getMask().getRaster();
+ Rectangle2D bboxDevice = softMask.getBBoxDevice();
+ int backdropColorValue = softMask.getBackdropColorValue();
+ PDFunction transferFunction = softMask.getTransferFunction();
+ Float[] map = transferFunction != null ? new Float[256] : null;
+ float[] input = transferFunction != null ? new float[1] : null;
+
+ WritableRaster raster = image.getRaster();
+ int width = image.getWidth();
+ int height = image.getHeight();
+ Point2D.Double point = new Point2D.Double();
+ int[] gray = new int[1];
+ int[] rasterPixel = null;
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ point.setLocation(bounds.getMinX() + x, bounds.getMinY() + y);
+ deviceTransform.transform(point, point);
+ int maskX = (int) Math.floor(point.getX() - bboxDevice.getX());
+ int maskY = (int) Math.floor(point.getY() - bboxDevice.getY());
+
+ int alphaScale;
+ if (maskX >= 0 && maskY >= 0 && maskX < maskRaster.getWidth()
&& maskY < maskRaster.getHeight())
+ {
+ maskRaster.getPixel(maskX, maskY, gray);
+ if (transferFunction != null)
+ {
+ Float f = map[gray[0]];
+ if (f == null)
+ {
+ input[0] = gray[0] / 255f;
+ f = transferFunction.eval(input)[0];
+ map[gray[0]] = f;
+ }
+ alphaScale = Math.round(255 * f);
+ }
+ else
+ {
+ alphaScale = gray[0];
+ }
+ }
+ else
+ {
+ alphaScale = backdropColorValue;
+ }
+
+ rasterPixel = raster.getPixel(x, y, rasterPixel);
+ rasterPixel[3] = rasterPixel[3] * alphaScale / 255;
+ raster.setPixel(x, y, rasterPixel);
+ }
+ }
+ }
+
/**
* Calculates the subsampling frequency for a given PDImage based on the
current transformation
* and its calculated transform. Extend this method if you want to use
your own strategy.
Modified:
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/rendering/SoftMask.java
==============================================================================
---
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/rendering/SoftMask.java
Sun Aug 16 12:20:07 2026 (r1937154)
+++
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/rendering/SoftMask.java
Sun Aug 16 12:20:11 2026 (r1937155)
@@ -90,6 +90,35 @@ class SoftMask implements Paint
}
}
+ // PDFBOX-6077: accessors used by PageDrawer to re-implement this soft
mask's alpha lookup
+ // directly (with its own, correctly-scaled device coordinates) when this
Paint is rendered
+ // into a scratch image that isn't the real page raster, e.g. for a
stencil mask filled with
+ // a pattern.
+ Paint getPaint()
+ {
+ return paint;
+ }
+
+ BufferedImage getMask()
+ {
+ return mask;
+ }
+
+ Rectangle2D getBBoxDevice()
+ {
+ return bboxDevice;
+ }
+
+ int getBackdropColorValue()
+ {
+ return bc;
+ }
+
+ PDFunction getTransferFunction()
+ {
+ return transferFunction;
+ }
+
@Override
public PaintContext createContext(ColorModel cm, Rectangle deviceBounds,
Rectangle2D userBounds, AffineTransform
xform,