qr split fixes (#6043)

This commit is contained in:
Anthony Stirling
2026-04-01 11:54:33 +01:00
committed by GitHub
parent 5ffa808c0f
commit cfa8d1e5d7

View File

@@ -1,19 +1,22 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.awt.image.DataBufferInt;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -21,6 +24,7 @@ import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import com.google.zxing.*;
import com.google.zxing.common.GlobalHistogramBinarizer;
import com.google.zxing.common.HybridBinarizer;
import io.github.pixee.security.Filenames;
@@ -35,7 +39,6 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
@@ -48,61 +51,219 @@ import stirling.software.common.util.WebResponseUtils;
public class AutoSplitPdfController {
private static final Set<String> VALID_QR_CONTENTS =
new HashSet<>(
Set.of(
"https://github.com/Stirling-Tools/Stirling-PDF",
"https://github.com/Frooodle/Stirling-PDF",
"https://stirlingpdf.com"));
Set.of(
"https://github.com/Stirling-Tools/Stirling-PDF",
"https://github.com/Frooodle/Stirling-PDF",
"https://stirlingpdf.com");
private static final int MAX_IMAGES_FOR_DIRECT_EXTRACTION = 3;
// 150 DPI is sufficient for QR code detection — higher wastes memory and CPU
private static final int QR_DETECTION_DPI = 150;
// Max total pixels before we downscale to avoid OOM on getRGB() allocation
private static final long MAX_IMAGE_PIXELS = 100_000_000L; // ~10000x10000
// Number of evenly-spaced pixel samples used for the blank image check
private static final int BLANK_CHECK_SAMPLES = 20;
private static final Map<DecodeHintType, Object> DECODE_HINTS;
static {
DECODE_HINTS = new EnumMap<>(DecodeHintType.class);
DECODE_HINTS.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
DECODE_HINTS.put(DecodeHintType.ALSO_INVERTED, Boolean.TRUE);
DECODE_HINTS.put(DecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.QR_CODE));
}
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final ApplicationProperties applicationProperties;
private static String decodeQRCode(BufferedImage bufferedImage) {
LuminanceSource source;
if (bufferedImage.getRaster().getDataBuffer() instanceof DataBufferByte dataBufferByte) {
byte[] pixels = dataBufferByte.getData();
source =
new PlanarYUVLuminanceSource(
pixels,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
0,
0,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
false);
} else if (bufferedImage.getRaster().getDataBuffer()
instanceof DataBufferInt dataBufferInt) {
int[] pixels = dataBufferInt.getData();
byte[] newPixels = new byte[pixels.length];
for (int i = 0; i < pixels.length; i++) {
newPixels[i] = (byte) (pixels[i] & 0xff);
}
source =
new PlanarYUVLuminanceSource(
newPixels,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
0,
0,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
false);
} else {
throw new IllegalArgumentException(
"BufferedImage must have 8-bit gray scale, 24-bit RGB, 32-bit ARGB (packed"
+ " int), byte gray, or 3-byte/4-byte RGB image data");
/**
* Downscale an image if it exceeds the maximum pixel count. Scales uniformly based on the
* pixel-count ratio so both portrait and landscape images are handled correctly.
*/
private static BufferedImage downscaleIfNeeded(BufferedImage image) {
long totalPixels = (long) image.getWidth() * image.getHeight();
if (totalPixels <= MAX_IMAGE_PIXELS) {
return image;
}
double scale = Math.sqrt((double) MAX_IMAGE_PIXELS / totalPixels);
int newWidth = Math.max(1, (int) (image.getWidth() * scale));
int newHeight = Math.max(1, (int) (image.getHeight() * scale));
log.debug(
"Downscaling image from {}x{} to {}x{} for QR detection",
image.getWidth(),
image.getHeight(),
newWidth,
newHeight);
BufferedImage scaled = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.drawImage(image, 0, 0, newWidth, newHeight, null);
g.dispose();
return scaled;
}
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
/**
* Quick check whether an image appears to be blank (single solid colour). Samples pixels at
* evenly-spaced positions — if all samples match the first pixel the image is almost certainly
* blank (e.g. a masked image that returned solid white).
*/
private static boolean isBlankImage(int[] pixels) {
if (pixels.length == 0) return true;
int first = pixels[0];
int step = Math.max(1, pixels.length / BLANK_CHECK_SAMPLES);
for (int i = step; i < pixels.length; i += step) {
if (pixels[i] != first) {
return false;
}
}
return true;
}
/**
* Try to decode a QR code from pre-extracted RGB pixel data using multiple binarization
* strategies. Returns the decoded text or null.
*
* <p>Strategy 1: HybridBinarizer — good for variable brightness (digital PDFs).
*
* <p>Strategy 2: GlobalHistogramBinarizer — better for scanned/noisy images with uniform
* lighting, and for QR codes with embedded logos that confuse the hybrid approach.
*/
private static String tryDecodeQR(int[] pixels, int width, int height) {
RGBLuminanceSource source = new RGBLuminanceSource(width, height, pixels);
MultiFormatReader reader = new MultiFormatReader();
// Strategy 1: HybridBinarizer — good for variable brightness (digital PDFs)
try {
Result result = new MultiFormatReader().decode(bitmap);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
Result result = reader.decode(bitmap, DECODE_HINTS);
log.debug("QR detected via HybridBinarizer: '{}'", result.getText());
return result.getText();
} catch (NotFoundException e) {
return null; // there is no QR code in the image
// continue
}
// Strategy 2: GlobalHistogramBinarizer — better for scanned/noisy images
try {
BinaryBitmap bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));
Result result = reader.decode(bitmap, DECODE_HINTS);
log.debug("QR detected via GlobalHistogramBinarizer: '{}'", result.getText());
return result.getText();
} catch (NotFoundException e) {
return null;
}
}
/**
* Attempt to decode a QR code from a BufferedImage. Handles downscaling for oversized images
* and skips blank images early.
*/
private static String decodeQRCode(BufferedImage bufferedImage) {
bufferedImage = downscaleIfNeeded(bufferedImage);
int width = bufferedImage.getWidth();
int height = bufferedImage.getHeight();
int[] pixels = new int[width * height];
bufferedImage.getRGB(0, 0, width, height, pixels, 0, width);
// Skip blank images early (e.g. masked images that decode to solid white)
if (isBlankImage(pixels)) {
log.debug("Skipping blank {}x{} image", width, height);
return null;
}
return tryDecodeQR(pixels, width, height);
}
/** Count the number of images embedded in a page's resources. */
private static int countPageImages(PDPage page) {
if (page.getResources() == null || page.getResources().getXObjectNames() == null) {
return 0;
}
int count = 0;
for (COSName name : page.getResources().getXObjectNames()) {
if (page.getResources().isImageXObject(name)) {
count++;
}
}
return count;
}
/**
* Extract images directly from a page's resources and check each for a QR code. Returns the QR
* code text if found, null otherwise.
*/
private static String checkPageImagesDirect(PDPage page) throws IOException {
if (page.getResources() == null || page.getResources().getXObjectNames() == null) {
return null;
}
for (COSName name : page.getResources().getXObjectNames()) {
if (!page.getResources().isImageXObject(name)) {
continue;
}
PDImageXObject imageObject = (PDImageXObject) page.getResources().getXObject(name);
BufferedImage image;
try {
image = imageObject.getImage();
} catch (OutOfMemoryError e) {
log.warn(
"Skipping oversized embedded image '{}' ({}x{}) - out of memory",
name.getName(),
imageObject.getWidth(),
imageObject.getHeight());
continue;
}
String result = decodeQRCode(image);
if (result != null) {
return result;
}
}
return null;
}
/**
* Render the full page to an image and scan it for a QR code. Tries a low DPI first (fast, low
* memory) and only retries at the system's maxDPI if detection fails. The first rendered image
* is released before the retry to allow GC to reclaim it.
*/
private String checkPageByRendering(PDFRenderer pdfRenderer, int pageNum) throws IOException {
log.debug("Rendering page {} at {} DPI for QR detection", pageNum + 1, QR_DETECTION_DPI);
BufferedImage bim =
ExceptionUtils.handleOomRendering(
pageNum + 1,
QR_DETECTION_DPI,
() -> pdfRenderer.renderImageWithDPI(pageNum, QR_DETECTION_DPI));
String result = decodeQRCode(bim);
bim = null; // allow GC before potential high-DPI retry
if (result == null) {
int maxDpi = getSystemMaxDpi();
if (maxDpi > QR_DETECTION_DPI) {
log.debug(
"Retrying page {} at {} DPI (low-DPI detection failed)",
pageNum + 1,
maxDpi);
BufferedImage highRes =
ExceptionUtils.handleOomRendering(
pageNum + 1,
maxDpi,
() -> pdfRenderer.renderImageWithDPI(pageNum, maxDpi));
result = decodeQRCode(highRes);
}
}
return result;
}
private int getSystemMaxDpi() {
if (applicationProperties != null && applicationProperties.getSystem() != null) {
return applicationProperties.getSystem().getMaxDPI();
}
return QR_DETECTION_DPI;
}
@AutoJobPostMapping(value = "/auto-split-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -111,42 +272,56 @@ public class AutoSplitPdfController {
summary = "Auto split PDF pages into separate documents",
description =
"This endpoint accepts a PDF file, scans each page for a specific QR code, and"
+ " splits the document at the QR code boundaries. The output is a zip file"
+ " containing each separate PDF document. Input:PDF Output:ZIP-PDF"
+ " splits the document at the QR code boundaries. The output is a zip"
+ " file containing each separate PDF document. Input:PDF Output:ZIP-PDF"
+ " Type:SISO")
public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute AutoSplitPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
boolean duplexMode = Boolean.TRUE.equals(request.getDuplexMode());
log.info(
"Auto-split starting: filename='{}', size={} bytes, duplexMode={}",
file.getOriginalFilename(),
file.getSize(),
duplexMode);
List<PDDocument> splitDocuments = new ArrayList<>();
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
int totalPages = document.getNumberOfPages();
log.info("PDF loaded, totalPages={}", totalPages);
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
for (int page = 0; page < document.getNumberOfPages(); ++page) {
BufferedImage bim;
for (int page = 0; page < totalPages; ++page) {
PDPage pdPage = document.getPage(page);
int imageCount = countPageImages(pdPage);
// Use global maximum DPI setting, fallback to 300 if not set
int renderDpi = 150; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
String qrResult;
if (imageCount > 0 && imageCount <= MAX_IMAGES_FOR_DIRECT_EXTRACTION) {
// Try extracting images directly from the PDF (faster, avoids rendering)
qrResult = checkPageImagesDirect(pdPage);
if (qrResult == null) {
// Fall back to rendering — the image may use masking/compositing
// that getImage() doesn't resolve, or the QR may be vector-drawn
qrResult = checkPageByRendering(pdfRenderer, page);
}
} else {
// Too many images or no images — render the full page
qrResult = checkPageByRendering(pdfRenderer, page);
}
final int dpi = renderDpi;
final int pageNum = page;
bim =
ExceptionUtils.handleOomRendering(
pageNum + 1,
dpi,
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
String result = decodeQRCode(bim);
boolean isValidQrCode = qrResult != null && VALID_QR_CONTENTS.contains(qrResult);
if (isValidQrCode) {
log.info(
"Page {}/{} contains QR divider ('{}')",
page + 1,
totalPages,
qrResult);
}
boolean isValidQrCode = VALID_QR_CONTENTS.contains(result);
log.debug("detected qr code {}, code is vale={}", result, isValidQrCode);
if (isValidQrCode && page != 0) {
splitDocuments.add(new PDDocument());
}
@@ -159,32 +334,25 @@ public class AutoSplitPdfController {
splitDocuments.add(firstDocument);
}
// If duplexMode is true and current page is a divider, then skip next page
if (duplexMode && isValidQrCode) {
page++;
page++; // skip back of divider page
}
}
// Remove split documents that have no pages
splitDocuments.removeIf(pdDocument -> pdDocument.getNumberOfPages() == 0);
log.info("Split complete, {} output documents", splitDocuments.size());
String filename =
GeneralUtils.removeExtension(
Filenames.toSimpleFileName(file.getOriginalFilename()));
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
// Stream split documents directly into zip — avoids holding all PDFs in memory
try (OutputStream fileOut = Files.newOutputStream(outputTempFile.getPath());
ZipOutputStream zipOut = new ZipOutputStream(fileOut)) {
for (int i = 0; i < splitDocuments.size(); i++) {
String fileName = filename + "_" + (i + 1) + ".pdf";
PDDocument splitDocument = splitDocuments.get(i);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
splitDocument.save(baos);
byte[] pdf = baos.toByteArray();
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.putNextEntry(new ZipEntry(fileName));
splitDocuments.get(i).save(zipOut);
zipOut.closeEntry();
}
}
@@ -197,7 +365,6 @@ public class AutoSplitPdfController {
log.error("Error in auto split", e);
throw e;
} finally {
// Clean up split documents
for (PDDocument splitDoc : splitDocuments) {
try {
splitDoc.close();