Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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).</li>
* 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.</li>
* </ul>
*
* <p>Inline images ({@code BI}/{@code ID}/{@code EI}) are promoted out of the
Expand Down Expand Up @@ -1256,6 +1258,14 @@
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);
Expand All @@ -1267,6 +1277,108 @@
return decodeRawRaster(stream, width, height);
}

/**
* If {@code imageStream} declares a {@code /SMask} (PDF §11.6.5.3 soft mask &mdash; 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);

Check warning on line 1296 in openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Not enough arguments.

See more on https://sonarcloud.io/project/issues?id=LibrePDF_OpenPDF&issues=AaGrni0rVottWbylfePL&open=AaGrni0rVottWbylfePL&pullRequest=1604
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;

Check warning on line 1351 in openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Return an empty array instead of null.

See more on https://sonarcloud.io/project/issues?id=LibrePDF_OpenPDF&issues=AaGrni0rVottWbylfePM&open=AaGrni0rVottWbylfePM&pullRequest=1604
}
byte[] decoded = PdfReader.getStreamBytes(smaskStream);
int expected = width * height;
if (decoded.length < expected) {
return null;

Check warning on line 1356 in openpdf-renderer/src/main/java/org/openpdf/renderer/core/OpenPdfCorePageRenderer.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Return an empty array instead of null.

See more on https://sonarcloud.io/project/issues?id=LibrePDF_OpenPDF&issues=AaGrni0rVottWbylfePN&open=AaGrni0rVottWbylfePN&pullRequest=1604
}
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 &mdash; fully transparent, half
* transparent, fully opaque &mdash; 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
Expand Down
Loading