Author: tilman
Date: Tue Sep  8 12:02:52 2026
New Revision: 1937994

Log:
PDFBOX-5876: use upper bound subsampling during initJPXValues() except when 
smask in data + add OOM test by Valery Bokov assisted by Claude; closes #527

Added:
   
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/rendering/JPXLowMemoryRenderMain.java
   (contents, props changed)
Modified:
   
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/image/PDImageXObject.java
   
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/rendering/TestQuality.java

Modified: 
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/image/PDImageXObject.java
==============================================================================
--- 
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/image/PDImageXObject.java
       Tue Sep  8 12:02:47 2026        (r1937993)
+++ 
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/graphics/image/PDImageXObject.java
       Tue Sep  8 12:02:52 2026        (r1937994)
@@ -85,6 +85,9 @@ public final class PDImageXObject extend
     private boolean jpxValuesInitialized = false;
     private BufferedImage jpxSMask = null;
 
+    // PDFBOX-5876: upper bound for the subsampling used by initJPXValues 
method.
+    private static final int JPX_METADATA_SUBSAMPLING = 8;
+
     /**
      * current resource dictionary (has color spaces)
      */
@@ -745,7 +748,12 @@ public final class PDImageXObject extend
         // bits per component
         // the colorspace of the image is used if the dictionary doesn't 
provide any value
         PDStream stream = getStream();
-        try (COSInputStream is = stream.createInputStream())
+        // PDFBOX-5876: subsample this metadata-only read, see the 
JPX_METADATA_SUBSAMPLING field.
+        // Not when a soft mask may be extracted from the image data, because 
that mask is kept
+        // and used later at its own resolution, so it must not be subsampled.
+        try (COSInputStream is = mayHaveJPXSMask()
+                ? stream.createInputStream()
+                : stream.createInputStream(new 
DecodeOptions(JPX_METADATA_SUBSAMPLING)))
         {
             DecodeResult decodeResult = is.getDecodeResult();
             stream.getCOSObject().addAll(decodeResult.getParameters());
@@ -763,6 +771,19 @@ public final class PDImageXObject extend
     }
 
     /**
+     * Tells whether decoding this image may produce a soft mask taken from 
the image data
+     * (PDFBOX-5657). {@code JPXFilter} only extracts such a mask when the 
image dictionary has no
+     * /ColorSpace entry and a positive /SMaskInData entry, so both are 
checked here to mirror it.
+     *
+     * @return true if a soft mask may be extracted from the image data.
+     */
+    private boolean mayHaveJPXSMask()
+    {
+        COSDictionary dict = getCOSObject();
+        return !dict.containsKey(COSName.COLORSPACE) && 
dict.getInt(COSName.SMASK_IN_DATA) > 0;
+    }
+
+    /**
      * High-quality image scaling.
      */
     private static BufferedImage scaleImage(BufferedImage image, int width, 
int height, int type, boolean interpolate)

Added: 
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/rendering/JPXLowMemoryRenderMain.java
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/rendering/JPXLowMemoryRenderMain.java
    Tue Sep  8 12:02:52 2026        (r1937994)
@@ -0,0 +1,45 @@
+/*
+ * 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.rendering;
+
+import java.io.File;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.io.IOUtils;
+import org.apache.pdfbox.pdmodel.PDDocument;
+
+/**
+ * Renders the first page of a PDF at half scale, the same way as reported in 
PDFBOX-5876. Run in
+ * its own JVM with a constrained heap by {@link 
TestQuality#testPDFBox5876()}, since the heap size
+ * of the JVM already running the test suite can't be changed after the fact.
+ */
+public final class JPXLowMemoryRenderMain
+{
+    private JPXLowMemoryRenderMain()
+    {
+    }
+
+    public static void main(String[] args) throws Exception
+    {
+        File file = new File(args[0]);
+        try (PDDocument doc = Loader.loadPDF(file, 
IOUtils.createTempFileOnlyStreamCache()))
+        {
+            PDFRenderer renderer = new PDFRenderer(doc);
+            renderer.setSubsamplingAllowed(true);
+            renderer.renderImage(0, 0.5f);
+        }
+    }
+}

Modified: 
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/rendering/TestQuality.java
==============================================================================
--- 
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/rendering/TestQuality.java
       Tue Sep  8 12:02:47 2026        (r1937993)
+++ 
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/rendering/TestQuality.java
       Tue Sep  8 12:02:52 2026        (r1937994)
@@ -19,13 +19,19 @@ package org.apache.pdfbox.rendering;
 import java.awt.image.BufferedImage;
 import java.io.File;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+
 import org.apache.pdfbox.Loader;
 import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.io.IOUtils;
 import org.apache.pdfbox.pdmodel.PDDocument;
 import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
 import org.apache.pdfbox.pdmodel.graphics.image.ValidateXImage;
+
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
 
 /**
  *
@@ -126,4 +132,35 @@ class TestQuality
                     "expected a dark text pixel but was too light: " + 
Integer.toHexString(rgb));
         }
     }
+
+    /**
+     * PDFBOX-5876: rendering a page containing a very large JPEG 2000 (JPX) 
image at reduced
+     * scale must not decode the image at full resolution first just to read 
its width, height
+     * and color space. Before the fix, {@code PDImageXObject.initJPXValues()} 
did exactly that,
+     * on top of the properly subsampled decode done afterwards for the actual 
rendering, so
+     * memory usage was driven by the full image size regardless of how small 
the rendered output
+     * was. This must run in a separate, heap-constrained JVM, since the heap 
size of the JVM
+     * already running the test suite can't be changed after the fact, and the 
failure (an
+     * OutOfMemoryError) only reproduces below a certain heap size.
+     *
+     * @throws IOException
+     * @throws InterruptedException
+     */
+    @Test
+    @EnabledIfSystemProperty(named = "TestOOM", matches = "true")
+    void testPDFBox5876() throws IOException, InterruptedException
+    {
+        File file = new File(TARGET_PDF_DIR, "PDFBOX-5876-jpeg2000.pdf");
+        String javaBin = System.getProperty("java.home") + File.separator + 
"bin" +
+                File.separator + "java";
+        ProcessBuilder builder = new ProcessBuilder(javaBin, "-Xmx600m",
+                "-cp", System.getProperty("java.class.path"),
+                JPXLowMemoryRenderMain.class.getName(), 
file.getAbsolutePath());
+        builder.redirectErrorStream(true);
+        Process process = builder.start();
+        String output = new 
String(IOUtils.toByteArray(process.getInputStream()), StandardCharsets.UTF_8);
+        boolean finished = process.waitFor(120, TimeUnit.SECONDS);
+        Assertions.assertTrue(finished, "subprocess timed out");
+        Assertions.assertEquals(0, process.exitValue(), "subprocess failed:\n" 
+ output);
+    }
 }

Reply via email to