From 5e281fa002fd1da33354ce9773c3c72242e92eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Wed, 29 Oct 2025 22:36:36 +0100 Subject: [PATCH] perf(scanner-effect): Optimize fake-scanner effect with multithreading (#4614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes TLDR: should be 3-4x faster by my (very) unscientific benchmarks (I ran the new and old with 5 docs and averaged out the results) Notes: - Made Threadpool and pages are rendered separately (large perf improvement over previous code) - Most of actual rendering/processing logic remain unchanged - DPI values are now more "reasonable - Buffering related perf gains also somewhat significant --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs --- .../api/misc/ScannerEffectController.java | 1179 +++++++++++------ .../model/api/misc/ScannerEffectRequest.java | 36 +- 2 files changed, 769 insertions(+), 446 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java index f2f4ae824..41a558e50 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java @@ -5,9 +5,24 @@ import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.util.Random; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; @@ -30,7 +45,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.misc.ScannerEffectRequest; import stirling.software.common.model.ApplicationProperties; @@ -44,16 +58,501 @@ import stirling.software.common.util.WebResponseUtils; @RequestMapping("/api/v1/misc") @Tag(name = "Misc", description = "Miscellaneous PDF APIs") @RequiredArgsConstructor -@Slf4j public class ScannerEffectController { private final CustomPDFDocumentFactory pdfDocumentFactory; - private static final Random RANDOM = new Random(); - - // Size limits to prevent OutOfMemoryError private static final int MAX_IMAGE_WIDTH = 8192; private static final int MAX_IMAGE_HEIGHT = 8192; private static final long MAX_IMAGE_PIXELS = 16_777_216; // 4096x4096 + private static final long RENDER_CLONE_IN_MEMORY_THRESHOLD = 16 * 1024 * 1024; // 16 MB + + private static final ThreadLocal BUFFER_CACHE = + ThreadLocal.withInitial(BufferCache::new); + + private static int calculateSafeResolution( + float pageWidthPts, float pageHeightPts, int resolution) { + int projectedWidth = (int) Math.ceil(pageWidthPts * resolution / 72.0); + int projectedHeight = (int) Math.ceil(pageHeightPts * resolution / 72.0); + long projectedPixels = (long) projectedWidth * projectedHeight; + + if (projectedWidth <= MAX_IMAGE_WIDTH + && projectedHeight <= MAX_IMAGE_HEIGHT + && projectedPixels <= MAX_IMAGE_PIXELS) { + return resolution; + } + + double widthScale = (double) MAX_IMAGE_WIDTH / projectedWidth; + double heightScale = (double) MAX_IMAGE_HEIGHT / projectedHeight; + double pixelScale = Math.sqrt((double) MAX_IMAGE_PIXELS / projectedPixels); + double minScale = Math.min(Math.min(widthScale, heightScale), pixelScale); + + return (int) Math.max(72, resolution * minScale); + } + + private static int determineRenderResolution(ScannerEffectRequest request) { + return request.getResolution(); + } + + private static BufferedImage renderPageSafely(PDFRenderer renderer, int pageIndex, int dpi) + throws IOException { + try { + return renderer.renderImageWithDPI(pageIndex, dpi); + } catch (OutOfMemoryError | NegativeArraySizeException e) { + throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e); + } + } + + private static BufferedImage convertColorspace( + BufferedImage image, ScannerEffectRequest.Colorspace colorspace) { + BufferedImage result = + new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D g = result.createGraphics(); + g.drawImage(image, 0, 0, null); + g.dispose(); + + if (colorspace == ScannerEffectRequest.Colorspace.grayscale) { + convertToGrayscale(result); + } + + return result; + } + + private static void convertToGrayscale(BufferedImage image) { + int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + for (int i = 0; i < pixels.length; i++) { + int rgb = pixels[i]; + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + int gray = (r + g + b) / 3; + pixels[i] = (gray << 16) | (gray << 8) | gray; + } + } + + private static GradientConfig createRandomGradient() { + boolean vertical = ThreadLocalRandom.current().nextBoolean(); + float startGrey = 0.6f + 0.3f * ThreadLocalRandom.current().nextFloat(); + float endGrey = 0.6f + 0.3f * ThreadLocalRandom.current().nextFloat(); + + Color startColor = + new Color( + Math.round(startGrey * 255), + Math.round(startGrey * 255), + Math.round(startGrey * 255)); + Color endColor = + new Color( + Math.round(endGrey * 255), + Math.round(endGrey * 255), + Math.round(endGrey * 255)); + + return new GradientConfig(vertical, startColor, endColor); + } + + private static BufferedImage addBorderWithGradient( + BufferedImage image, int borderPx, GradientConfig gradient) { + int width = image.getWidth() + 2 * borderPx; + int height = image.getHeight() + 2 * borderPx; + + int[] gradientLUT = createGradientLUT(width, height, gradient); + BufferedImage result = new BufferedImage(width, height, image.getType()); + int[] pixels = ((DataBufferInt) result.getRaster().getDataBuffer()).getData(); + + fillWithGradient(pixels, width, height, gradientLUT, gradient.vertical); + + Graphics2D g = result.createGraphics(); + g.drawImage(image, borderPx, borderPx, null); + g.dispose(); + + return result; + } + + private static int[] createGradientLUT(int width, int height, GradientConfig gradient) { + int size = gradient.vertical ? height : width; + int[] lut = new int[size]; + + int rStart = gradient.startColor.getRed(); + int gStart = gradient.startColor.getGreen(); + int bStart = gradient.startColor.getBlue(); + int rDiff = gradient.endColor.getRed() - rStart; + int gDiff = gradient.endColor.getGreen() - gStart; + int bDiff = gradient.endColor.getBlue() - bStart; + + for (int i = 0; i < size; i++) { + float frac = (float) i / Math.max(1, size - 1); + int r = Math.round(rStart + rDiff * frac); + int g = Math.round(gStart + gDiff * frac); + int b = Math.round(bStart + bDiff * frac); + lut[i] = (r << 16) | (g << 8) | b; + } + + return lut; + } + + private static void fillWithGradient( + int[] pixels, int width, int height, int[] gradientLUT, boolean vertical) { + if (vertical) { + for (int y = 0; y < height; y++) { + Arrays.fill(pixels, y * width, (y + 1) * width, gradientLUT[y]); + } + } else { + for (int y = 0; y < height; y++) { + System.arraycopy(gradientLUT, 0, pixels, y * width, width); + } + } + } + + private static double calculateRotation(int baseRotation, int rotateVariance) { + if (baseRotation == 0 && rotateVariance == 0) { + return 0; + } + return baseRotation + (ThreadLocalRandom.current().nextDouble() * 2 - 1) * rotateVariance; + } + + private static BufferedImage rotateImage( + BufferedImage image, double rotation, GradientConfig gradient) { + if (rotation == 0) { + return image; + } + + int w = image.getWidth(); + int h = image.getHeight(); + double radians = Math.toRadians(rotation); + double sin = Math.abs(Math.sin(radians)); + double cos = Math.abs(Math.cos(radians)); + int rotW = (int) Math.floor(w * cos + h * sin); + int rotH = (int) Math.floor(h * cos + w * sin); + + BufferedImage background = createRotatedBackground(rotW, rotH, image.getType(), gradient); + BufferedImage result = new BufferedImage(rotW, rotH, image.getType()); + + Graphics2D g = result.createGraphics(); + g.drawImage(background, 0, 0, null); + + AffineTransform transform = new AffineTransform(); + transform.translate((rotW - w) / 2.0, (rotH - h) / 2.0); + transform.rotate(radians, w / 2.0, h / 2.0); + + g.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.drawImage(image, transform, null); + g.dispose(); + + return result; + } + + private static BufferedImage createRotatedBackground( + int width, int height, int imageType, GradientConfig gradient) { + BufferedImage background = new BufferedImage(width, height, imageType); + int[] pixels = ((DataBufferInt) background.getRaster().getDataBuffer()).getData(); + int[] gradientLUT = createGradientLUT(width, height, gradient); + fillWithGradient(pixels, width, height, gradientLUT, gradient.vertical); + return background; + } + + private static BufferedImage applyAllEffectsSinglePass( + BufferedImage image, + float brightness, + float contrast, + boolean yellowish, + double noise) { + int width = image.getWidth(); + int height = image.getHeight(); + BufferedImage output = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + + int[] srcPixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + int[] dstPixels = ((DataBufferInt) output.getRaster().getDataBuffer()).getData(); + + double scaledStrength = noise * Math.min(width, height) / 1000.0; + boolean applyNoise = scaledStrength > 0; + float contrastOffset = 128.0f - 128.0f * contrast; + float inv765 = 1.0f / 765.0f; + for (int i = 0; i < srcPixels.length; i++) { + int rgb = srcPixels[i]; + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + + r = (int) ((r * contrast + contrastOffset) * brightness); + g = (int) ((g * contrast + contrastOffset) * brightness); + b = (int) ((b * contrast + contrastOffset) * brightness); + r = Math.min(255, Math.max(0, r)); + g = Math.min(255, Math.max(0, g)); + b = Math.min(255, Math.max(0, b)); + + if (yellowish) { + float bright = (r + g + b) * inv765; + r = Math.min(255, (int) (r + (255 - r) * 0.18f * bright)); + g = Math.min(255, (int) (g + (255 - g) * 0.12f * bright)); + b = Math.max(0, (int) (b * (1.0f - 0.25f * bright))); + } + + if (applyNoise) { + r = + Math.min( + 255, + Math.max( + 0, + r + + (int) + (ThreadLocalRandom.current().nextGaussian() + * scaledStrength))); + g = + Math.min( + 255, + Math.max( + 0, + g + + (int) + (ThreadLocalRandom.current().nextGaussian() + * scaledStrength))); + b = + Math.min( + 255, + Math.max( + 0, + b + + (int) + (ThreadLocalRandom.current().nextGaussian() + * scaledStrength))); + } + + dstPixels[i] = (r << 16) | (g << 8) | b; + } + + return output; + } + + private static BufferedImage softenEdges( + BufferedImage image, + int featherRadius, + Color startColor, + Color endColor, + boolean vertical) { + int width = image.getWidth(); + int height = image.getHeight(); + BufferedImage output = new BufferedImage(width, height, image.getType()); + + int[] srcPixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + int[] dstPixels = ((DataBufferInt) output.getRaster().getDataBuffer()).getData(); + + int[] gradientLUT = + createGradientLUT( + width, height, new GradientConfig(vertical, startColor, endColor)); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int dx = Math.min(x, width - 1 - x); + int dy = Math.min(y, height - 1 - y); + int d = Math.min(dx, dy); + + int bgVal = gradientLUT[vertical ? y : x]; + int fgVal = srcPixels[y * width + x]; + + float alpha = d < featherRadius ? (float) d / featherRadius : 1.0f; + dstPixels[y * width + x] = blendColors(fgVal, bgVal, alpha); + } + } + return output; + } + + private static int blendColors(int fg, int bg, float alpha) { + int r = Math.round(((fg >> 16) & 0xFF) * alpha + ((bg >> 16) & 0xFF) * (1 - alpha)); + int g = Math.round(((fg >> 8) & 0xFF) * alpha + ((bg >> 8) & 0xFF) * (1 - alpha)); + int b = Math.round((fg & 0xFF) * alpha + (bg & 0xFF) * (1 - alpha)); + return (r << 16) | (g << 8) | b; + } + + private static BufferedImage applyGaussianBlur(BufferedImage image, double sigma) { + if (sigma <= 0) { + return image; + } + + double scaledSigma = sigma * Math.min(image.getWidth(), image.getHeight()) / 1000.0; + int radius = Math.max(1, (int) Math.ceil(scaledSigma * 2)); + int width = image.getWidth(); + int height = image.getHeight(); + int pixelCount = width * height; + + int[] srcPixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + BufferCache cache = BUFFER_CACHE.get(); + int[] tempPixels = cache.getTempBuffer(pixelCount); + int[] dstPixels = cache.getDstBuffer(pixelCount); + + System.arraycopy(srcPixels, 0, tempPixels, 0, pixelCount); + + for (int pass = 0; pass < 2; pass++) { + boxBlurHorizontal(tempPixels, dstPixels, width, height, radius); + boxBlurVertical(dstPixels, tempPixels, width, height, radius); + } + + BufferedImage result = new BufferedImage(width, height, image.getType()); + int[] resultPixels = ((DataBufferInt) result.getRaster().getDataBuffer()).getData(); + System.arraycopy(tempPixels, 0, resultPixels, 0, pixelCount); + + return result; + } + + private static void boxBlurHorizontal(int[] src, int[] dst, int width, int height, int radius) { + int diameter = radius * 2 + 1; + float invDiameter = 1.0f / diameter; + + for (int y = 0; y < height; y++) { + int rowStart = y * width; + int sumR = 0, sumG = 0, sumB = 0; + + for (int x = -radius; x <= radius; x++) { + int px = Math.max(0, Math.min(width - 1, x)); + int rgb = src[rowStart + px]; + sumR += (rgb >> 16) & 0xFF; + sumG += (rgb >> 8) & 0xFF; + sumB += rgb & 0xFF; + } + + for (int x = 0; x < width; x++) { + int r = (int) (sumR * invDiameter); + int g = (int) (sumG * invDiameter); + int b = (int) (sumB * invDiameter); + dst[rowStart + x] = (r << 16) | (g << 8) | b; + + int leftX = Math.max(0, x - radius); + int rightX = Math.min(width - 1, x + radius + 1); + + int leftRgb = src[rowStart + leftX]; + int rightRgb = src[rowStart + rightX]; + + sumR += ((rightRgb >> 16) & 0xFF) - ((leftRgb >> 16) & 0xFF); + sumG += ((rightRgb >> 8) & 0xFF) - ((leftRgb >> 8) & 0xFF); + sumB += (rightRgb & 0xFF) - (leftRgb & 0xFF); + } + } + } + + private static void boxBlurVertical(int[] src, int[] dst, int width, int height, int radius) { + int diameter = radius * 2 + 1; + float invDiameter = 1.0f / diameter; + + for (int x = 0; x < width; x++) { + int sumR = 0, sumG = 0, sumB = 0; + + for (int y = -radius; y <= radius; y++) { + int py = Math.max(0, Math.min(height - 1, y)); + int rgb = src[py * width + x]; + sumR += (rgb >> 16) & 0xFF; + sumG += (rgb >> 8) & 0xFF; + sumB += rgb & 0xFF; + } + + for (int y = 0; y < height; y++) { + int r = (int) (sumR * invDiameter); + int g = (int) (sumG * invDiameter); + int b = (int) (sumB * invDiameter); + dst[y * width + x] = (r << 16) | (g << 8) | b; + + int topY = Math.max(0, y - radius); + int bottomY = Math.min(height - 1, y + radius + 1); + + int topRgb = src[topY * width + x]; + int bottomRgb = src[bottomY * width + x]; + + sumR += ((bottomRgb >> 16) & 0xFF) - ((topRgb >> 16) & 0xFF); + sumG += ((bottomRgb >> 8) & 0xFF) - ((topRgb >> 8) & 0xFF); + sumB += (bottomRgb & 0xFF) - (topRgb & 0xFF); + } + } + } + + private static void writeProcessedPagesToDocument( + List pages, PDDocument document) throws IOException { + for (ProcessedPage page : pages) { + PDPage newPage = new PDPage(new PDRectangle(page.origW, page.origH)); + document.addPage(newPage); + + try (PDPageContentStream contentStream = new PDPageContentStream(document, newPage)) { + PDImageXObject pdImage = LosslessFactory.createFromImage(document, page.image); + contentStream.drawImage( + pdImage, page.offsetX, page.offsetY, page.drawW, page.drawH); + } + + page.image.flush(); + } + } + + private static ProcessedPage processPage( + int pageIndex, + RenderingResources renderingResources, + int baseRotation, + int rotateVariance, + int borderPx, + float brightness, + float contrast, + float blur, + float noise, + boolean yellowish, + int renderResolution, + ScannerEffectRequest.Colorspace colorspace) { + + try { + PDRectangle pageSize = renderingResources.getPageMediaBox(pageIndex); + float pageWidthPts = pageSize.getWidth(); + float pageHeightPts = pageSize.getHeight(); + + int safeResolution = + calculateSafeResolution(pageWidthPts, pageHeightPts, renderResolution); + + BufferedImage image = renderingResources.renderPage(pageIndex, safeResolution); + BufferedImage processed = convertColorspace(image, colorspace); + image.flush(); + + GradientConfig gradient = createRandomGradient(); + BufferedImage composed = addBorderWithGradient(processed, borderPx, gradient); + processed.flush(); + + double rotation = calculateRotation(baseRotation, rotateVariance); + BufferedImage rotated = rotateImage(composed, rotation, gradient); + + if (rotated != composed) { + composed.flush(); + } + + // Reuse the pageSize we already retrieved to avoid redundant document access + float origW = pageSize.getWidth(); + float origH = pageSize.getHeight(); + + int rotW = rotated.getWidth(); + int rotH = rotated.getHeight(); + float scale = Math.max(origW / rotW, origH / rotH); + float drawW = rotW * scale; + float drawH = rotH * scale; + float offsetX = (origW - drawW) / 2f; + float offsetY = (origH - drawH) / 2f; + + int featherRadius = Math.max(10, Math.round(Math.min(rotW, rotH) * 0.02f)); + BufferedImage softened = + softenEdges( + rotated, + featherRadius, + gradient.startColor, + gradient.endColor, + gradient.vertical); + + BufferedImage blurred = applyGaussianBlur(softened, blur); + BufferedImage adjusted = + applyAllEffectsSinglePass(blurred, brightness, contrast, yellowish, noise); + + softened.flush(); + blurred.flush(); + + if (rotated != composed) { + rotated.flush(); + } + return new ProcessedPage(adjusted, origW, origH, offsetX, offsetY, drawW, drawH); + } catch (IOException e) { + throw new RuntimeException("Failed to process page " + (pageIndex + 1), e); + } catch (OutOfMemoryError | NegativeArraySizeException e) { + throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, renderResolution, e); + } + } @PostMapping(value = "/scanner-effect", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( @@ -64,443 +563,275 @@ public class ScannerEffectController { throws IOException { MultipartFile file = request.getFileInput(); - // Apply preset first if needed - if (!request.isAdvancedEnabled()) { - switch (request.getQuality()) { - case high -> request.applyHighQualityPreset(); - case medium -> request.applyMediumQualityPreset(); - case low -> request.applyLowQualityPreset(); - } - } + List tempFiles = new ArrayList<>(); + Path processingInput; - // Extract values after preset application - int baseRotation = request.getRotationValue() + request.getRotate(); - int rotateVariance = request.getRotateVariance(); - int borderPx = request.getBorder(); - float brightness = request.getBrightness(); - float contrast = request.getContrast(); - float blur = request.getBlur(); - float noise = request.getNoise(); - boolean yellowish = request.isYellowish(); - int resolution = request.getResolution(); - ScannerEffectRequest.Colorspace colorspace = request.getColorspace(); + try { + Path originalInput = Files.createTempFile("scanner_effect_input_", ".pdf"); + tempFiles.add(originalInput); + file.transferTo(originalInput.toFile()); - // Validate and limit DPI to prevent excessive memory usage (respecting global limits) - int maxSafeDpi = 500; // Default maximum safe DPI - ApplicationProperties properties = - ApplicationContextProvider.getBean(ApplicationProperties.class); - if (properties != null && properties.getSystem() != null) { - maxSafeDpi = properties.getSystem().getMaxDPI(); - } - if (resolution > maxSafeDpi) { - throw ExceptionUtils.createIllegalArgumentException( - "error.dpiExceedsLimit", - "DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause" - + " memory issues and crashes. Please use a lower DPI value.", - resolution, - maxSafeDpi); - } + processingInput = originalInput; - try (PDDocument document = pdfDocumentFactory.load(file)) { - PDDocument outputDocument = new PDDocument(); - PDFRenderer pdfRenderer = new PDFRenderer(document); - - for (int i = 0; i < document.getNumberOfPages(); i++) { - // Get page dimensions to calculate safe resolution - PDRectangle pageSize = document.getPage(i).getMediaBox(); - float pageWidthPts = pageSize.getWidth(); - float pageHeightPts = pageSize.getHeight(); - - // Calculate what the image dimensions would be at the requested resolution - int projectedWidth = (int) Math.ceil(pageWidthPts * resolution / 72.0); - int projectedHeight = (int) Math.ceil(pageHeightPts * resolution / 72.0); - long projectedPixels = (long) projectedWidth * projectedHeight; - - // Calculate safe resolution that stays within limits - int safeResolution = resolution; - if (projectedWidth > MAX_IMAGE_WIDTH - || projectedHeight > MAX_IMAGE_HEIGHT - || projectedPixels > MAX_IMAGE_PIXELS) { - double widthScale = (double) MAX_IMAGE_WIDTH / projectedWidth; - double heightScale = (double) MAX_IMAGE_HEIGHT / projectedHeight; - double pixelScale = Math.sqrt((double) MAX_IMAGE_PIXELS / projectedPixels); - double minScale = Math.min(Math.min(widthScale, heightScale), pixelScale); - safeResolution = (int) Math.max(72, resolution * minScale); - - log.warn( - "Page {} would be too large at {}dpi ({}x{} pixels). Reducing to {}dpi", - i + 1, - resolution, - projectedWidth, - projectedHeight, - safeResolution); + if (!request.isAdvancedEnabled()) { + switch (request.getQuality()) { + case high -> request.applyHighQualityPreset(); + case medium -> request.applyMediumQualityPreset(); + case low -> request.applyLowQualityPreset(); } + } - // Render page to image with safe resolution - BufferedImage image; + int baseRotation = request.getRotationValue() + request.getRotate(); + int rotateVariance = request.getRotateVariance(); + int borderPx = request.getBorder(); + float brightness = request.getBrightness(); + float contrast = request.getContrast(); + float blur = request.getBlur(); + float noise = request.getNoise(); + boolean yellowish = request.isYellowish(); + int resolution = request.getResolution(); + int renderResolution = determineRenderResolution(request); + ScannerEffectRequest.Colorspace colorspace = request.getColorspace(); + + long inputFileSize = Files.size(processingInput); + byte[] renderingPdfBytes = null; + if (inputFileSize <= RENDER_CLONE_IN_MEMORY_THRESHOLD) { + renderingPdfBytes = Files.readAllBytes(processingInput); + } + + final byte[] sharedPdfBytes = renderingPdfBytes; + final Path sharedPdfPath = sharedPdfBytes == null ? processingInput : null; + + int maxSafeDpi = 500; // Default maximum safe DPI + ApplicationProperties properties = + ApplicationContextProvider.getBean(ApplicationProperties.class); + if (properties != null && properties.getSystem() != null) { + maxSafeDpi = properties.getSystem().getMaxDPI(); + } + if (resolution > maxSafeDpi) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dpiExceedsLimit", + "DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause" + + " memory issues and crashes. Please use a lower DPI value.", + resolution, + maxSafeDpi); + } + + try (PDDocument document = + sharedPdfBytes != null + ? pdfDocumentFactory.load(sharedPdfBytes) + : pdfDocumentFactory.load(processingInput); + PDDocument outputDocument = new PDDocument()) { + + int totalPages = document.getNumberOfPages(); + if (totalPages == 0) { + throw ExceptionUtils.createIllegalArgumentException( + "error.emptyDocument", + "The provided PDF contains no pages to process."); + } + int configuredParallelism = + Math.min(64, Math.max(2, Runtime.getRuntime().availableProcessors() * 2)); + int desiredParallelism = Math.max(1, Math.min(totalPages, configuredParallelism)); + + try (ManagedForkJoinPool managedPool = + new ManagedForkJoinPool(desiredParallelism)) { + ForkJoinPool customPool = managedPool.getPool(); + + Queue renderingResourcesToClose = + new ConcurrentLinkedQueue<>(); + ThreadLocal renderingResources = + ThreadLocal.withInitial( + () -> { + try { + RenderingResources resources = + sharedPdfBytes != null + ? RenderingResources.fromBytes( + pdfDocumentFactory, + sharedPdfBytes) + : RenderingResources.fromPath( + pdfDocumentFactory, + sharedPdfPath); + renderingResourcesToClose.add(resources); + return resources; + } catch (IOException e) { + throw new UncheckedIOException( + "Failed to prepare rendering resources", e); + } + }); + List processedPages; + try { + List> tasks = + IntStream.range(0, totalPages) + .mapToObj( + i -> + (Callable) + () -> + processPage( + i, + renderingResources + .get(), + baseRotation, + rotateVariance, + borderPx, + brightness, + contrast, + blur, + noise, + yellowish, + renderResolution, + colorspace)) + .toList(); + + List> futures = customPool.invokeAll(tasks); + processedPages = new ArrayList<>(totalPages); + for (Future future : futures) { + processedPages.add(future.get()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Parallel page processing interrupted", e); + } catch (ExecutionException e) { + throw new IOException("Parallel page processing failed", e.getCause()); + } finally { + renderingResources.remove(); + for (RenderingResources resources : renderingResourcesToClose) { + resources.closeQuietly(); + } + } + + writeProcessedPagesToDocument(processedPages, outputDocument); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + outputDocument.save(outputStream); + + return WebResponseUtils.bytesToWebResponse( + outputStream.toByteArray(), + GeneralUtils.generateFilename( + file.getOriginalFilename(), "_scanner_effect.pdf")); + } + } + } finally { + for (Path tempFile : tempFiles) { try { - image = pdfRenderer.renderImageWithDPI(i, safeResolution); - } catch (OutOfMemoryError e) { - throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, safeResolution, e); - } catch (NegativeArraySizeException e) { - throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, safeResolution, e); + Files.deleteIfExists(tempFile); + } catch (IOException e) { + // Ignore cleanup failures } + } + } + } - log.debug( - "Processing page {} with dimensions {}x{} ({} pixels) at {}dpi", - i + 1, - image.getWidth(), - image.getHeight(), - (long) image.getWidth() * image.getHeight(), - safeResolution); + private static final class RenderingResources implements AutoCloseable { + private final PDDocument document; + private final PDFRenderer renderer; - // 1. Convert to grayscale or keep color - BufferedImage processed; - if (colorspace == ScannerEffectRequest.Colorspace.grayscale) { - processed = - new BufferedImage( - image.getWidth(), - image.getHeight(), - BufferedImage.TYPE_INT_RGB); - Graphics2D gGray = processed.createGraphics(); - gGray.setColor(Color.BLACK); - gGray.fillRect(0, 0, image.getWidth(), image.getHeight()); - gGray.drawImage(image, 0, 0, null); - gGray.dispose(); + private RenderingResources(PDDocument document) { + this.document = document; + this.renderer = new PDFRenderer(document); + this.renderer.setSubsamplingAllowed(true); + this.renderer.setImageDownscalingOptimizationThreshold(0.5f); + } - // Convert to grayscale manually - for (int y = 0; y < processed.getHeight(); y++) { - for (int x = 0; x < processed.getWidth(); x++) { - int rgb = processed.getRGB(x, y); - int r = (rgb >> 16) & 0xFF; - int g = (rgb >> 8) & 0xFF; - int b = rgb & 0xFF; - int gray = (r + g + b) / 3; - int grayRGB = (gray << 16) | (gray << 8) | gray; - processed.setRGB(x, y, grayRGB); - } - } - } else { - processed = - new BufferedImage( - image.getWidth(), - image.getHeight(), - BufferedImage.TYPE_INT_RGB); - Graphics2D gCol = processed.createGraphics(); - gCol.drawImage(image, 0, 0, null); - gCol.dispose(); - } + static RenderingResources fromBytes(CustomPDFDocumentFactory factory, byte[] pdfBytes) + throws IOException { + return new RenderingResources(factory.load(pdfBytes, true)); + } - // 2. Add border with randomized grey gradient - int baseW = processed.getWidth() + 2 * borderPx; - int baseH = processed.getHeight() + 2 * borderPx; - boolean vertical = RANDOM.nextBoolean(); - float startGrey = 0.6f + 0.3f * RANDOM.nextFloat(); - float endGrey = 0.6f + 0.3f * RANDOM.nextFloat(); - Color startColor = - new Color( - Math.round(startGrey * 255), - Math.round(startGrey * 255), - Math.round(startGrey * 255)); - Color endColor = - new Color( - Math.round(endGrey * 255), - Math.round(endGrey * 255), - Math.round(endGrey * 255)); - BufferedImage composed = new BufferedImage(baseW, baseH, processed.getType()); - Graphics2D gBg = composed.createGraphics(); - for (int y = 0; y < baseH; y++) { - for (int x = 0; x < baseW; x++) { - float frac = vertical ? (float) y / (baseH - 1) : (float) x / (baseW - 1); - int r = - Math.round( - startColor.getRed() - + (endColor.getRed() - startColor.getRed()) * frac); - int g = - Math.round( - startColor.getGreen() - + (endColor.getGreen() - startColor.getGreen()) - * frac); - int b = - Math.round( - startColor.getBlue() - + (endColor.getBlue() - startColor.getBlue()) - * frac); - composed.setRGB(x, y, new Color(r, g, b).getRGB()); + static RenderingResources fromPath(CustomPDFDocumentFactory factory, Path pdfPath) + throws IOException { + return new RenderingResources(factory.load(pdfPath, true)); + } + + PDRectangle getPageMediaBox(int pageIndex) { + return document.getPage(pageIndex).getMediaBox(); + } + + BufferedImage renderPage(int pageIndex, int dpi) throws IOException { + return renderPageSafely(renderer, pageIndex, dpi); + } + + @Override + public void close() throws IOException { + document.close(); + } + + void closeQuietly() { + try { + close(); + } catch (IOException e) { + // Ignore close failure + } + } + } + + private static class BufferCache { + int[] tempPixels = new int[0]; + int[] dstPixels = new int[0]; + + int[] getTempBuffer(int requiredSize) { + if (tempPixels.length < requiredSize) { + tempPixels = new int[requiredSize]; + } + return tempPixels; + } + + int[] getDstBuffer(int requiredSize) { + if (dstPixels.length < requiredSize) { + dstPixels = new int[requiredSize]; + } + return dstPixels; + } + } + + private static class ManagedForkJoinPool implements AutoCloseable { + private final ForkJoinPool pool; + + ManagedForkJoinPool(int parallelism) { + this.pool = new ForkJoinPool(parallelism); + } + + ForkJoinPool getPool() { + return pool; + } + + @Override + public void close() { + pool.shutdown(); + try { + if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { + pool.shutdownNow(); + if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { + throw new RuntimeException("ForkJoinPool did not terminate"); } } - gBg.drawImage(processed, borderPx, borderPx, null); - gBg.dispose(); - - // 3. Rotate the entire composed image - double pageRotation = baseRotation; - if (baseRotation != 0 || rotateVariance != 0) { - pageRotation += (RANDOM.nextDouble() * 2 - 1) * rotateVariance; - } - - BufferedImage rotated; - int w = composed.getWidth(); - int h = composed.getHeight(); - int rotW = w; - int rotH = h; - - // Skip rotation entirely if no rotation is needed - if (pageRotation == 0) { - rotated = composed; - } else { - double radians = Math.toRadians(pageRotation); - double sin = Math.abs(Math.sin(radians)); - double cos = Math.abs(Math.cos(radians)); - rotW = (int) Math.floor(w * cos + h * sin); - rotH = (int) Math.floor(h * cos + w * sin); - BufferedImage rotatedBg = new BufferedImage(rotW, rotH, composed.getType()); - Graphics2D gBgRot = rotatedBg.createGraphics(); - for (int y = 0; y < rotH; y++) { - for (int x = 0; x < rotW; x++) { - float frac = vertical ? (float) y / (rotH - 1) : (float) x / (rotW - 1); - int r = - Math.round( - startColor.getRed() - + (endColor.getRed() - startColor.getRed()) - * frac); - int g = - Math.round( - startColor.getGreen() - + (endColor.getGreen() - startColor.getGreen()) - * frac); - int b = - Math.round( - startColor.getBlue() - + (endColor.getBlue() - startColor.getBlue()) - * frac); - rotatedBg.setRGB(x, y, new Color(r, g, b).getRGB()); - } - } - gBgRot.dispose(); - rotated = new BufferedImage(rotW, rotH, composed.getType()); - Graphics2D g2d = rotated.createGraphics(); - g2d.drawImage(rotatedBg, 0, 0, null); - AffineTransform at = new AffineTransform(); - at.translate((rotW - w) / 2.0, (rotH - h) / 2.0); - at.rotate(radians, w / 2.0, h / 2.0); - g2d.setRenderingHint( - RenderingHints.KEY_INTERPOLATION, - RenderingHints.VALUE_INTERPOLATION_BICUBIC); - g2d.setRenderingHint( - RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); - g2d.setRenderingHint( - RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - g2d.drawImage(composed, at, null); - g2d.dispose(); - } - - // 4. Scale and center the rotated image to cover the original page size - PDRectangle origPageSize = document.getPage(i).getMediaBox(); - float origW = origPageSize.getWidth(); - float origH = origPageSize.getHeight(); - float scale = Math.max(origW / rotW, origH / rotH); - float drawW = rotW * scale; - float drawH = rotH * scale; - float offsetX = (origW - drawW) / 2f; - float offsetY = (origH - drawH) / 2f; - - // 5. Apply adaptive blur and edge softening - BufferedImage softened = - softenEdges( - rotated, - Math.max(10, Math.round(Math.min(rotW, rotH) * 0.02f)), - startColor, - endColor, - vertical); - BufferedImage blurred = applyGaussianBlur(softened, blur); - - // 6. Adjust brightness and contrast - BufferedImage adjusted = adjustBrightnessContrast(blurred, brightness, contrast); - - // 7. Add noise and yellowish effect to the content - if (yellowish) { - applyYellowishEffect(adjusted); - } - addGaussianNoise(adjusted, noise); - - // 8. Write to PDF - PDPage newPage = new PDPage(new PDRectangle(origW, origH)); - outputDocument.addPage(newPage); - try (PDPageContentStream contentStream = - new PDPageContentStream(outputDocument, newPage)) { - PDImageXObject pdImage = - LosslessFactory.createFromImage(outputDocument, adjusted); - contentStream.drawImage(pdImage, offsetX, offsetY, drawW, drawH); - } - } - - // Save to byte array - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - outputDocument.save(outputStream); - outputDocument.close(); - - return WebResponseUtils.bytesToWebResponse( - outputStream.toByteArray(), - GeneralUtils.generateFilename( - file.getOriginalFilename(), "_scanner_effect.pdf")); - } - } - - private BufferedImage softenEdges( - BufferedImage image, - int featherRadius, - Color startColor, - Color endColor, - boolean vertical) { - int width = image.getWidth(); - int height = image.getHeight(); - BufferedImage output = new BufferedImage(width, height, image.getType()); - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - int dx = Math.min(x, width - 1 - x); - int dy = Math.min(y, height - 1 - y); - int d = Math.min(dx, dy); - float frac = vertical ? (float) y / (height - 1) : (float) x / (width - 1); - int rBg = - Math.round( - startColor.getRed() - + (endColor.getRed() - startColor.getRed()) * frac); - int gBg = - Math.round( - startColor.getGreen() - + (endColor.getGreen() - startColor.getGreen()) * frac); - int bBg = - Math.round( - startColor.getBlue() - + (endColor.getBlue() - startColor.getBlue()) * frac); - int bgVal = new Color(rBg, gBg, bBg).getRGB(); - int fgVal = image.getRGB(x, y); - float alpha = d < featherRadius ? (float) d / featherRadius : 1.0f; - int blended = blendColors(fgVal, bgVal, alpha); - output.setRGB(x, y, blended); - } - } - return output; - } - - private int blendColors(int fg, int bg, float alpha) { - int r = Math.round(((fg >> 16) & 0xFF) * alpha + ((bg >> 16) & 0xFF) * (1 - alpha)); - int g = Math.round(((fg >> 8) & 0xFF) * alpha + ((bg >> 8) & 0xFF) * (1 - alpha)); - int b = Math.round((fg & 0xFF) * alpha + (bg & 0xFF) * (1 - alpha)); - return (r << 16) | (g << 8) | b; - } - - private BufferedImage applyGaussianBlur(BufferedImage image, double sigma) { - if (sigma <= 0) { - return image; - } - - // Scale sigma based on image size to maintain consistent blur effect - double scaledSigma = sigma * Math.min(image.getWidth(), image.getHeight()) / 1000.0; - - int radius = Math.max(1, (int) Math.ceil(scaledSigma * 3)); - int size = 2 * radius + 1; - float[] data = new float[size * size]; - double sum = 0.0; - - // Generate Gaussian kernel - for (int i = -radius; i <= radius; i++) { - for (int j = -radius; j <= radius; j++) { - double xDistance = (double) i * i; - double yDistance = (double) j * j; - double g = Math.exp(-(xDistance + yDistance) / (2 * scaledSigma * scaledSigma)); - data[(i + radius) * size + j + radius] = (float) g; - sum += g; - } - } - - // Normalize kernel - for (int i = 0; i < data.length; i++) { - data[i] /= (float) sum; - } - - // Create and apply convolution - java.awt.image.Kernel kernel = new java.awt.image.Kernel(size, size, data); - java.awt.image.ConvolveOp op = - new java.awt.image.ConvolveOp(kernel, java.awt.image.ConvolveOp.EDGE_NO_OP, null); - - // Apply blur with high-quality rendering hints - BufferedImage result = - new BufferedImage(image.getWidth(), image.getHeight(), image.getType()); - Graphics2D g2d = result.createGraphics(); - g2d.setRenderingHint( - RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); - g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); - g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - g2d.drawImage(op.filter(image, null), 0, 0, null); - g2d.dispose(); - - return result; - } - - private void applyYellowishEffect(BufferedImage image) { - for (int x = 0; x < image.getWidth(); x++) { - for (int y = 0; y < image.getHeight(); y++) { - int rgb = image.getRGB(x, y); - int r = (rgb >> 16) & 0xFF; - int g = (rgb >> 8) & 0xFF; - int b = rgb & 0xFF; - - // Stronger yellow tint while preserving brightness - float brightness = (r + g + b) / 765.0f; // Normalize to 0-1 - r = Math.min(255, (int) (r + (255 - r) * 0.18f * brightness)); - g = Math.min(255, (int) (g + (255 - g) * 0.12f * brightness)); - b = Math.max(0, (int) (b * (1 - 0.25f * brightness))); - - image.setRGB(x, y, (r << 16) | (g << 8) | b); + } catch (InterruptedException e) { + pool.shutdownNow(); + Thread.currentThread().interrupt(); } } } - private void addGaussianNoise(BufferedImage image, double strength) { - if (strength <= 0) return; + private record GradientConfig(boolean vertical, Color startColor, Color endColor) {} - // Scale noise based on image size - double scaledStrength = strength * Math.min(image.getWidth(), image.getHeight()) / 1000.0; + private static class ProcessedPage { + final BufferedImage image; + final float origW, origH, offsetX, offsetY, drawW, drawH; - for (int x = 0; x < image.getWidth(); x++) { - for (int y = 0; y < image.getHeight(); y++) { - int rgb = image.getRGB(x, y); - int r = (rgb >> 16) & 0xFF; - int g = (rgb >> 8) & 0xFF; - int b = rgb & 0xFF; - - // Generate noise with better distribution - double noiseR = RANDOM.nextGaussian() * scaledStrength; - double noiseG = RANDOM.nextGaussian() * scaledStrength; - double noiseB = RANDOM.nextGaussian() * scaledStrength; - - // Apply noise with better color preservation - r = Math.min(255, Math.max(0, r + (int) noiseR)); - g = Math.min(255, Math.max(0, g + (int) noiseG)); - b = Math.min(255, Math.max(0, b + (int) noiseB)); - - image.setRGB(x, y, (r << 16) | (g << 8) | b); - } + ProcessedPage( + BufferedImage image, + float origW, + float origH, + float offsetX, + float offsetY, + float drawW, + float drawH) { + this.image = image; + this.origW = origW; + this.origH = origH; + this.offsetX = offsetX; + this.offsetY = offsetY; + this.drawW = drawW; + this.drawH = drawH; } } - - private BufferedImage adjustBrightnessContrast( - BufferedImage image, float brightness, float contrast) { - BufferedImage output = - new BufferedImage(image.getWidth(), image.getHeight(), image.getType()); - for (int y = 0; y < image.getHeight(); y++) { - for (int x = 0; x < image.getWidth(); x++) { - int rgb = image.getRGB(x, y); - int r = (int) (((((rgb >> 16) & 0xFF) - 128) * contrast + 128) * brightness); - int g = (int) (((((rgb >> 8) & 0xFF) - 128) * contrast + 128) * brightness); - int b = (int) ((((rgb & 0xFF) - 128) * contrast + 128) * brightness); - r = Math.min(255, Math.max(0, r)); - g = Math.min(255, Math.max(0, g)); - b = Math.min(255, Math.max(0, b)); - output.setRGB(x, y, (r << 16) | (g << 8) | b); - } - } - return output; - } } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ScannerEffectRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ScannerEffectRequest.java index 411f6dbe6..3b2eebfcc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ScannerEffectRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ScannerEffectRequest.java @@ -79,14 +79,6 @@ public class ScannerEffectRequest { @Schema(description = "Whether advanced settings are enabled", example = "false") private boolean advancedEnabled = false; - public int getQualityValue() { - return switch (quality) { - case low -> 30; - case medium -> 60; - case high -> 100; - }; - } - public int getRotationValue() { return switch (rotation) { case none -> 0; @@ -97,26 +89,26 @@ public class ScannerEffectRequest { } public void applyHighQualityPreset() { - this.blur = 0.1f; + this.blur = 0.10f; this.noise = 1.0f; - this.brightness = 1.02f; - this.contrast = 1.05f; - this.resolution = 300; + this.brightness = 1.03f; + this.contrast = 1.06f; + this.resolution = 150; } public void applyMediumQualityPreset() { - this.blur = 0.5f; - this.noise = 3.0f; - this.brightness = 1.05f; - this.contrast = 1.1f; - this.resolution = 300; + this.blur = 0.10f; + this.noise = 1.0f; + this.brightness = 1.06f; + this.contrast = 1.12f; + this.resolution = 100; } public void applyLowQualityPreset() { - this.blur = 1.0f; - this.noise = 5.0f; - this.brightness = 1.1f; - this.contrast = 1.2f; - this.resolution = 150; + this.blur = 0.9f; + this.noise = 2.5f; + this.brightness = 1.08f; + this.contrast = 1.15f; + this.resolution = 75; } }