From e9a93ce6b319371eb8e313e80e4600341313bee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20R=C3=B8sdal?= Date: Tue, 28 Jul 2026 13:00:17 +0200 Subject: [PATCH 1/2] Honor /SMask soft masks on Image XObjects in OpenPdfCorePageRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image XObjects with a /SMask entry (PDF §11.6.5.3) -- the mechanism behind transparent-background PNGs (logos, product photos, ...) embedded in a PDF -- previously rendered fully opaque, since decodeImage()/renderImage() never looked at /SMask. Decode the soft mask as an 8-bit DeviceGray alpha channel (supporting the same JPEG/JPX/Flate/uncompressed encodings as the base image raster path, with nearest-neighbor resampling when the mask's own dimensions differ from the base image's) and composite it onto the decoded image before drawing. Update the README to document the new capability. --- openpdf-renderer/README.md | 7 ++ .../core/OpenPdfCorePageRenderer.java | 114 +++++++++++++++++- .../OpenPdfCorePageRendererOperatorsTest.java | 96 +++++++++++++++ 3 files changed, 216 insertions(+), 1 deletion(-) diff --git a/openpdf-renderer/README.md b/openpdf-renderer/README.md index 40a4f7b1f..9a5875654 100644 --- a/openpdf-renderer/README.md +++ b/openpdf-renderer/README.md @@ -122,6 +122,13 @@ XObject coverage: DeviceCMYK streams (CMYK approximated to sRGB on the fly). 8-bit Indexed color images are expanded through their palette into the base color space (DeviceGray / DeviceRGB / DeviceCMYK). +- An image's `/SMask` (soft mask, PDF §11.6.5.3) is decoded as an 8-bit + DeviceGray alpha channel and composited onto it — the mechanism behind + transparent-background PNGs (logos, product photos, ...) embedded in a PDF. + The soft mask may be JPEG/JPX, Flate-decoded or uncompressed, and its own + `/Width`/`/Height` need not match the base image (resampled with nearest- + neighbor when they differ). `/Mask` (stencil / color-key masking) is not + yet supported. Text rendering: for each `Tf`-selected font, the renderer pulls the embedded font program (`FontFile2`/`FontFile3`/`FontFile`) out of the diff --git a/openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java b/openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java index f362219b3..52cc08150 100644 --- a/openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java +++ b/openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java @@ -90,7 +90,9 @@ * (JPEG via {@code DCTDecode}, JPEG2000 via {@code JPXDecode} when * supported by {@code ImageIO}, uncompressed / Flate-decoded 8-bit * DeviceGray / DeviceRGB / DeviceCMYK images, and 8-bit Indexed - * images expanded through the palette into their base color space). + * images expanded through the palette into their base color space). + * An image's {@code /SMask} (soft mask), if present, is decoded as an + * 8-bit DeviceGray alpha channel and composited onto it. * * *

Inline images ({@code BI}/{@code ID}/{@code EI}) are promoted out of the @@ -1256,6 +1258,14 @@ private BufferedImage decodeImage(PRStream stream) { if (width <= 0 || height <= 0) { return null; } + BufferedImage base = decodeBaseImage(stream, width, height); + if (base == null) { + return null; + } + return applySoftMask(base, stream); + } + + private BufferedImage decodeBaseImage(PRStream stream, int width, int height) { PdfObject filterObj = stream.get(PdfName.FILTER); if (hasFilter(filterObj, PdfName.DCTDECODE) || hasFilter(filterObj, PdfName.JPXDECODE)) { return decodeViaImageIO(stream); @@ -1267,6 +1277,108 @@ private BufferedImage decodeImage(PRStream stream) { return decodeRawRaster(stream, width, height); } + /** + * If {@code imageStream} declares a {@code /SMask} (PDF §11.6.5.3 soft mask — the + * mechanism behind transparent PNGs / logos embedded in a PDF), decodes it as an 8-bit + * DeviceGray alpha channel and composites it onto {@code base}. Falls back to returning + * {@code base} unchanged when there is no soft mask, or it can't be decoded. + */ + private BufferedImage applySoftMask(BufferedImage base, PRStream imageStream) { + PdfObject smaskObj = imageStream.get(PdfName.SMASK); + PdfObject direct = smaskObj instanceof PRIndirectReference ref + ? PdfReader.getPdfObject(ref) : smaskObj; + if (!(direct instanceof PRStream smaskStream)) { + return base; + } + try { + return compositeSoftMask(base, smaskStream); + } catch (IOException | RuntimeException e) { + LOG.log(Level.FINE, "Skipping SMask due to: {0}", e); + return base; + } + } + + /** + * Combines {@code base} with the alpha channel decoded from {@code smaskStream}, producing a + * new {@link BufferedImage#TYPE_INT_ARGB} image. The soft mask's own {@code /Width}/{@code + * /Height} need not match the base image (PDF §11.6.5.3); a mismatch is handled with nearest- + * neighbor resampling to the base image's dimensions. + */ + private BufferedImage compositeSoftMask(BufferedImage base, PRStream smaskStream) throws IOException { + PdfNumber maskWidthN = smaskStream.getAsNumber(PdfName.WIDTH); + PdfNumber maskHeightN = smaskStream.getAsNumber(PdfName.HEIGHT); + if (maskWidthN == null || maskHeightN == null) { + return base; + } + int maskWidth = maskWidthN.intValue(); + int maskHeight = maskHeightN.intValue(); + if (maskWidth <= 0 || maskHeight <= 0) { + return base; + } + byte[] maskGray = readSoftMaskGray(smaskStream, maskWidth, maskHeight); + if (maskGray == null) { + return base; + } + int width = base.getWidth(); + int height = base.getHeight(); + BufferedImage out = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < height; y++) { + int my = maskHeight == height ? y : y * maskHeight / height; + for (int x = 0; x < width; x++) { + int mx = maskWidth == width ? x : x * maskWidth / width; + int alpha = maskGray[my * maskWidth + mx] & 0xFF; + out.setRGB(x, y, (alpha << 24) | (base.getRGB(x, y) & 0x00FFFFFF)); + } + } + return out; + } + + /** + * Decodes a soft-mask stream's pixel data into a flat 8-bit gray byte array (one byte per + * pixel, row-major). Soft masks are DeviceGray by definition (PDF §11.6.5.3); this supports + * the same JPEG / Flate / uncompressed encodings as the base image raster path. Bit depths + * other than 8 are rare for soft masks and not yet supported. + */ + private byte[] readSoftMaskGray(PRStream smaskStream, int width, int height) throws IOException { + PdfObject filterObj = smaskStream.get(PdfName.FILTER); + if (hasFilter(filterObj, PdfName.DCTDECODE) || hasFilter(filterObj, PdfName.JPXDECODE)) { + BufferedImage decoded = decodeViaImageIO(smaskStream); + return decoded == null ? null : grayBytesFrom(decoded, width, height); + } + PdfNumber bpcN = smaskStream.getAsNumber(PdfName.BITSPERCOMPONENT); + int bpc = bpcN == null ? 8 : bpcN.intValue(); + if (bpc != 8) { + return null; + } + byte[] decoded = PdfReader.getStreamBytes(smaskStream); + int expected = width * height; + if (decoded.length < expected) { + return null; + } + return decoded.length == expected ? decoded : Arrays.copyOf(decoded, expected); + } + + /** + * Resamples {@code img} to {@code width x height} (nearest-neighbor) and reduces it to a flat + * 8-bit gray byte array, used when a soft mask is JPEG/JPX-encoded and its decoded size + * doesn't already match the request. + */ + private static byte[] grayBytesFrom(BufferedImage img, int width, int height) { + byte[] out = new byte[width * height]; + for (int y = 0; y < height; y++) { + int sy = img.getHeight() == height ? y : y * img.getHeight() / height; + for (int x = 0; x < width; x++) { + int sx = img.getWidth() == width ? x : x * img.getWidth() / width; + int rgb = img.getRGB(sx, sy); + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + out[y * width + x] = (byte) ((r + g + b) / 3); + } + } + return out; + } + /** * Returns the {@code [/Indexed base hival lookup]} array if {@code csObj} (possibly * indirect) is an indexed color space; {@code null} otherwise. diff --git a/openpdf-renderer/src/test/java/org/openpdf/renderer/core/OpenPdfCorePageRendererOperatorsTest.java b/openpdf-renderer/src/test/java/org/openpdf/renderer/core/OpenPdfCorePageRendererOperatorsTest.java index f63520123..021c7f4e0 100644 --- a/openpdf-renderer/src/test/java/org/openpdf/renderer/core/OpenPdfCorePageRendererOperatorsTest.java +++ b/openpdf-renderer/src/test/java/org/openpdf/renderer/core/OpenPdfCorePageRendererOperatorsTest.java @@ -274,6 +274,102 @@ void rendersJpegImageXObject() throws Exception { } } + /** + * PDF §11.6.5.3: an Image XObject's {@code /SMask} entry is a soft mask that supplies + * a per-pixel alpha channel. This is exactly how a transparent-background PNG (a logo, + * a product photo, ...) ends up embedded in a PDF. Before {@code /SMask} support, the + * renderer ignored it entirely and painted every image fully opaque, hiding whatever was + * underneath. Build a red image with three alpha bands — fully transparent, half + * transparent, fully opaque — over a green background rectangle, and verify all + * three composite differently. + */ + @Test + void rendersImageSoftMaskAsPerPixelAlpha() throws Exception { + int size = 30; + BufferedImage argb = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + int band = x / (size / 3); + // band 0 = fully transparent, 1 = half transparent, 2 = fully opaque; all red. + int alpha = switch (band) { + case 0 -> 0; + case 1 -> 128; + default -> 255; + }; + argb.setRGB(x, y, (alpha << 24) | 0xFF0000); + } + } + // color=null so Image.getInstance() derives a real /SMask stream from the alpha + // channel rather than binary color-key masking (the mixed 0/128/255 alpha values + // force openpdf-core's "shades" detection down the SMask path). + org.openpdf.text.Image pdfImage = org.openpdf.text.Image.getInstance((java.awt.Image) argb, null); + + // Placement in PDF user-space points: a 150x150pt image with its lower-left corner at + // (50, 75), i.e. spanning x:[50,200], y:[75,225]. + float imgXPt = 50f; + float imgYPt = 75f; + float imgWPt = 150f; + float imgHPt = 150f; + byte[] pdf = buildPdf(cb -> { + cb.setRGBColorFillF(0f, 1f, 0f); // green background, full page + cb.rectangle(0, 0, 300, 300); + cb.fill(); + cb.addImage(pdfImage, imgWPt, 0f, 0f, imgHPt, imgXPt, imgYPt); + }); + + try (OpenPdfCoreRenderer r = new OpenPdfCoreRenderer(pdf)) { + BufferedImage img = r.renderPage(1, 150f); + saveForInspection(img, "image-smask.png"); + + // Translate the PDF-space placement into device pixels the same way the renderer + // does (dpi/72 scale, Y flipped against the page height) so band samples land + // squarely inside the image regardless of the test page's exact dimensions. + float dpi = 150f; + float scale = dpi / 72f; + double pageHeightPt = r.getPageSize(1).getHeight(); + int imgLeftPx = Math.round(imgXPt * scale); + int imgWidthPx = Math.round(imgWPt * scale); + int imgTopPx = Math.round((float) (pageHeightPt - (imgYPt + imgHPt)) * scale); + int imgBottomPx = Math.round((float) (pageHeightPt - imgYPt) * scale); + int sampleY = (imgTopPx + imgBottomPx) / 2; + int transparentX = imgLeftPx + imgWidthPx / 6; // center of the first (transparent) third + int halfX = imgLeftPx + imgWidthPx / 2; // center of the middle (half-alpha) third + int opaqueX = imgLeftPx + imgWidthPx * 5 / 6; // center of the last (opaque) third + + int[] transparentRgb = channelsAt(img, transparentX, sampleY); + int[] halfRgb = channelsAt(img, halfX, sampleY); + int[] opaqueRgb = channelsAt(img, opaqueX, sampleY); + + assertThat(transparentRgb[1]) + .as("fully transparent (alpha=0) band must let the green background show through") + .isGreaterThan(150); + assertThat(transparentRgb[0]) + .as("fully transparent band must not show the image's red") + .isLessThan(80); + + assertThat(opaqueRgb[0]) + .as("fully opaque (alpha=255) band must show the image's red") + .isGreaterThan(200); + assertThat(opaqueRgb[1]) + .as("fully opaque band must not let the green background show through") + .isLessThan(80); + + assertThat(halfRgb[0]) + .as("half-transparent (alpha=128) band must blend red and the green background, " + + "not read as pure background") + .isBetween(70, 200); + assertThat(halfRgb[1]) + .as("half-transparent band must blend red and the green background, " + + "not read as pure opaque red") + .isBetween(70, 200); + } + } + + private static int[] channelsAt(BufferedImage img, int x, int y) { + int argb = img.getRGB(x, y); + return new int[]{(argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF}; + } + @Test void rendersFormXObjectViaNestedContentStream() throws Exception { // Form XObjects embed their own content stream. Wrap a colored rectangle From 02465c9c59b2b5b65b07027b83920f7d251b86f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20R=C3=B8sdal?= Date: Tue, 28 Jul 2026 13:44:58 +0200 Subject: [PATCH 2/2] Drop README changes from this PR Consolidated into #1607 along with the README updates for #1603, #1605, and #1606, so these four independent PRs (all based on master) don't each carry their own overlapping README.md diff and risk merge conflicts with each other depending on merge order. --- openpdf-renderer/README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/openpdf-renderer/README.md b/openpdf-renderer/README.md index 9a5875654..40a4f7b1f 100644 --- a/openpdf-renderer/README.md +++ b/openpdf-renderer/README.md @@ -122,13 +122,6 @@ XObject coverage: DeviceCMYK streams (CMYK approximated to sRGB on the fly). 8-bit Indexed color images are expanded through their palette into the base color space (DeviceGray / DeviceRGB / DeviceCMYK). -- An image's `/SMask` (soft mask, PDF §11.6.5.3) is decoded as an 8-bit - DeviceGray alpha channel and composited onto it — the mechanism behind - transparent-background PNGs (logos, product photos, ...) embedded in a PDF. - The soft mask may be JPEG/JPX, Flate-decoded or uncompressed, and its own - `/Width`/`/Height` need not match the base image (resampled with nearest- - neighbor when they differ). `/Mask` (stencil / color-key masking) is not - yet supported. Text rendering: for each `Tf`-selected font, the renderer pulls the embedded font program (`FontFile2`/`FontFile3`/`FontFile`) out of the