mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-06-06 09:12:02 +00:00
Added scan filter feature (#3530)
# Description of Changes Please provide a summary of the changes, including: This pull request introduces the "Fake Scan" feature, which simulates scanned PDFs with customizable settings. The changes include the removal of a work-in-progress controller, the addition of a new request model, and updates to the frontend to support the feature. ### Backend Changes: * **Removed the unfinished `FakeScanControllerWIP`:** The entire `FakeScanControllerWIP` class, which contained unimplemented and experimental code for processing PDFs, has been removed. This cleanup eliminates unused code and dependencies. Some of the original code of removed file was ported to the new Controller. * **Added `FakeScanRequest` model:** Introduced a new model class `FakeScanRequest` to handle input parameters for the "Fake Scan" feature. It includes fields for file input, quality, rotation, colorspace, and other advanced settings, with validation and default values. ### Frontend Changes: * **Localization updates for the "Fake Scan" feature:** Added new localization keys for the "Fake Scan" feature, including titles, descriptions, and advanced settings options like quality, rotation, and colorspace. * **Added "Fake Scan" card to the homepage ### Pictures: Front-end  Example document (based on defaults; can be drastically changed according to need.):  ### Quirks/known issues - Performance: It might take even reasonable hardware to convert bigger pdf >500KB more than a few minutes. - Yellowish filter applies to the whole document and also incl to the background. (not desirable in some instances) - There is some randomness involved in the default preset, helps imitate scan but some user might find it annoying. (but it can be disabled through advanced settings). - Some features might confusing to people with no additional context. Closes #458 --- ## 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/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/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/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [x] 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/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
parent
209c76d885
commit
631c4fef0b
@ -0,0 +1,440 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.AffineTransform;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Random;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
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.FakeScanRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous PDF APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FakeScanController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private static final Random RANDOM = new Random();
|
||||
|
||||
@PostMapping(value = "/fake-scan", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Convert PDF to look like a scanned document",
|
||||
description =
|
||||
"Applies various effects to make a PDF look like it was scanned, including rotation, noise, and edge softening. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> fakeScan(@Valid @ModelAttribute FakeScanRequest request)
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
FakeScanRequest.Colorspace colorspace = request.getColorspace();
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
PDDocument outputDocument = new PDDocument();
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
// Render page to image with specified resolution
|
||||
BufferedImage image = pdfRenderer.renderImageWithDPI(i, resolution);
|
||||
|
||||
// 1. Convert to grayscale or keep color
|
||||
BufferedImage processed;
|
||||
if (colorspace == FakeScanRequest.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();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
||||
String outputFilename =
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "")
|
||||
+ "_scanned.pdf";
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(), outputFilename, MediaType.APPLICATION_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addGaussianNoise(BufferedImage image, double strength) {
|
||||
if (strength <= 0) return;
|
||||
|
||||
// Scale noise based on image size
|
||||
double scaledStrength = strength * Math.min(image.getWidth(), image.getHeight()) / 1000.0;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,311 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.geom.AffineTransform;
|
||||
import java.awt.geom.Ellipse2D;
|
||||
import java.awt.geom.Path2D;
|
||||
import java.awt.image.*;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
public class FakeScanControllerWIP {
|
||||
|
||||
// TODO finish
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/fake-scan")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Repair a PDF file",
|
||||
description =
|
||||
"This endpoint repairs a given PDF file by running qpdf command. The PDF is first saved to a temporary location, repaired, read back, and then returned as a response.")
|
||||
public ResponseEntity<byte[]> fakeScan(@ModelAttribute PDFFile request) throws IOException {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
|
||||
// Load the PDF document
|
||||
PDDocument document = Loader.loadPDF(inputFile.getBytes());
|
||||
PDFRenderer renderer = new PDFRenderer(document);
|
||||
List<BufferedImage> images = new ArrayList<>();
|
||||
// Convert each page to an image
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
BufferedImage image = renderer.renderImageWithDPI(i, 150, ImageType.GRAY);
|
||||
images.add(processImage(image));
|
||||
}
|
||||
document.close();
|
||||
|
||||
// Create a new PDF document with the processed images
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
PDDocument newDocument = new PDDocument();
|
||||
for (BufferedImage img : images) {
|
||||
// PDPageContentStream contentStream = new PDPageContentStream(newDocument, new
|
||||
// PDPage());
|
||||
PDImageXObject pdImage = JPEGFactory.createFromImage(newDocument, img);
|
||||
PdfUtils.addImageToDocument(newDocument, pdImage, "maintainAspectRatio", false);
|
||||
}
|
||||
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
// Return the optimized PDF as a response
|
||||
String outputFilename =
|
||||
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "")
|
||||
+ "_scanned.pdf";
|
||||
return WebResponseUtils.boasToWebResponse(baos, outputFilename);
|
||||
}
|
||||
|
||||
public BufferedImage processImage(BufferedImage image) {
|
||||
// Rotation
|
||||
|
||||
image = softenEdges(image, 50);
|
||||
image = rotate(image, 1);
|
||||
|
||||
image = applyGaussianBlur(image, 0.5);
|
||||
addGaussianNoise(image, 0.5);
|
||||
image = linearStretch(image);
|
||||
addDustAndHairs(image, 3);
|
||||
return image;
|
||||
}
|
||||
|
||||
private BufferedImage rotate(BufferedImage image, double rotation) {
|
||||
|
||||
double rotationRequired = Math.toRadians(rotation);
|
||||
double locationX = (double) image.getWidth() / 2;
|
||||
double locationY = (double) image.getHeight() / 2;
|
||||
AffineTransform tx =
|
||||
AffineTransform.getRotateInstance(rotationRequired, locationX, locationY);
|
||||
AffineTransformOp op = new AffineTransformOp(tx, AffineTransformOp.TYPE_BICUBIC);
|
||||
return op.filter(image, null);
|
||||
}
|
||||
|
||||
private BufferedImage applyGaussianBlur(BufferedImage image, double sigma) {
|
||||
int radius = 3; // Fixed radius size for simplicity
|
||||
|
||||
int size = 2 * radius + 1;
|
||||
float[] data = new float[size * size];
|
||||
double sum = 0.0;
|
||||
|
||||
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 * sigma * sigma));
|
||||
data[(i + radius) * size + j + radius] = (float) g;
|
||||
sum += g;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize the kernel
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
if (sum != 0) data[i] /= sum;
|
||||
}
|
||||
|
||||
Kernel kernel = new Kernel(size, size, data);
|
||||
BufferedImageOp op = new ConvolveOp(kernel, ConvolveOp.EDGE_NO_OP, null);
|
||||
return op.filter(image, null);
|
||||
}
|
||||
|
||||
public BufferedImage softenEdges(BufferedImage image, int featherRadius) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
BufferedImage output = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
|
||||
Graphics2D g2 = output.createGraphics();
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
g2.setRenderingHint(
|
||||
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||
|
||||
g2.drawImage(image, 0, 0, null);
|
||||
g2.setComposite(AlphaComposite.DstIn);
|
||||
|
||||
// Top edge
|
||||
g2.setPaint(
|
||||
new GradientPaint(
|
||||
0,
|
||||
0,
|
||||
new Color(0, 0, 0, 1f),
|
||||
0,
|
||||
featherRadius * 2f,
|
||||
new Color(0, 0, 0, 0f)));
|
||||
g2.fillRect(0, 0, width, featherRadius);
|
||||
|
||||
// Bottom edge
|
||||
g2.setPaint(
|
||||
new GradientPaint(
|
||||
0,
|
||||
height - featherRadius * 2f,
|
||||
new Color(0, 0, 0, 0f),
|
||||
0,
|
||||
height,
|
||||
new Color(0, 0, 0, 1f)));
|
||||
g2.fillRect(0, height - featherRadius, width, featherRadius);
|
||||
|
||||
// Left edge
|
||||
g2.setPaint(
|
||||
new GradientPaint(
|
||||
0,
|
||||
0,
|
||||
new Color(0, 0, 0, 1f),
|
||||
featherRadius * 2f,
|
||||
0,
|
||||
new Color(0, 0, 0, 0f)));
|
||||
g2.fillRect(0, 0, featherRadius, height);
|
||||
|
||||
// Right edge
|
||||
g2.setPaint(
|
||||
new GradientPaint(
|
||||
width - featherRadius * 2f,
|
||||
0,
|
||||
new Color(0, 0, 0, 0f),
|
||||
width,
|
||||
0,
|
||||
new Color(0, 0, 0, 1f)));
|
||||
g2.fillRect(width - featherRadius, 0, featherRadius, height);
|
||||
|
||||
g2.dispose();
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private void addDustAndHairs(BufferedImage image, float intensity) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
Random random = new SecureRandom();
|
||||
|
||||
// Set rendering hints for better quality
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
// Calculate the number of artifacts based on intensity
|
||||
int numSpots = (int) (intensity * 10);
|
||||
int numHairs = (int) (intensity * 20);
|
||||
|
||||
// Add spots with more variable sizes
|
||||
g2d.setColor(new Color(100, 100, 100, 50)); // Semi-transparent gray
|
||||
for (int i = 0; i < numSpots; i++) {
|
||||
int x = random.nextInt(width);
|
||||
int y = random.nextInt(height);
|
||||
int ovalSize = 1 + random.nextInt(3); // Base size + variable component
|
||||
if (random.nextFloat() > 0.9) {
|
||||
// 10% chance to get a larger spot
|
||||
ovalSize += random.nextInt(3);
|
||||
}
|
||||
g2d.fill(new Ellipse2D.Double(x, y, ovalSize, ovalSize));
|
||||
}
|
||||
|
||||
// Add hairs
|
||||
g2d.setStroke(new BasicStroke(0.5f)); // Thin stroke for hairs
|
||||
g2d.setColor(new Color(80, 80, 80, 40)); // Slightly lighter and more transparent
|
||||
for (int i = 0; i < numHairs; i++) {
|
||||
int x1 = random.nextInt(width);
|
||||
int y1 = random.nextInt(height);
|
||||
int x2 = x1 + random.nextInt(20) - 10; // Random length and direction
|
||||
int y2 = y1 + random.nextInt(20) - 10;
|
||||
Path2D.Double hair = new Path2D.Double();
|
||||
hair.moveTo(x1, y1);
|
||||
hair.curveTo(x1, y1, (double) (x1 + x2) / 2, (double) (y1 + y2) / 2, x2, y2);
|
||||
g2d.draw(hair);
|
||||
}
|
||||
|
||||
g2d.dispose();
|
||||
}
|
||||
|
||||
private void addGaussianNoise(BufferedImage image, double strength) {
|
||||
Random rand = new SecureRandom();
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
|
||||
for (int i = 0; i < width; i++) {
|
||||
for (int j = 0; j < height; j++) {
|
||||
int rgba = image.getRGB(i, j);
|
||||
int alpha = (rgba >> 24) & 0xff;
|
||||
int red = (rgba >> 16) & 0xff;
|
||||
int green = (rgba >> 8) & 0xff;
|
||||
int blue = rgba & 0xff;
|
||||
|
||||
// Apply Gaussian noise
|
||||
red = (int) (red + rand.nextGaussian() * strength);
|
||||
green = (int) (green + rand.nextGaussian() * strength);
|
||||
blue = (int) (blue + rand.nextGaussian() * strength);
|
||||
|
||||
// Clamping values to the 0-255 range
|
||||
red = Math.min(Math.max(0, red), 255);
|
||||
green = Math.min(Math.max(0, green), 255);
|
||||
blue = Math.min(Math.max(0, blue), 255);
|
||||
|
||||
image.setRGB(i, j, (alpha << 24) | (red << 16) | (green << 8) | blue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BufferedImage linearStretch(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int min = 255;
|
||||
int max = 0;
|
||||
|
||||
// First pass: find the min and max grayscale values
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int rgb = image.getRGB(x, y);
|
||||
int gray =
|
||||
(int)
|
||||
(((rgb >> 16) & 0xff) * 0.299
|
||||
+ ((rgb >> 8) & 0xff) * 0.587
|
||||
+ (rgb & 0xff) * 0.114); // Convert to grayscale
|
||||
if (gray < min) min = gray;
|
||||
if (gray > max) max = gray;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: stretch the histogram
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int rgb = image.getRGB(x, y);
|
||||
int alpha = (rgb >> 24) & 0xff;
|
||||
int red = (rgb >> 16) & 0xff;
|
||||
int green = (rgb >> 8) & 0xff;
|
||||
int blue = rgb & 0xff;
|
||||
|
||||
// Apply linear stretch to each channel
|
||||
red = (int) (((red - min) / (float) (max - min)) * 255);
|
||||
green = (int) (((green - min) / (float) (max - min)) * 255);
|
||||
blue = (int) (((blue - min) / (float) (max - min)) * 255);
|
||||
|
||||
// Set new RGB value maintaining the alpha channel
|
||||
rgb = (alpha << 24) | (red << 16) | (green << 8) | blue;
|
||||
image.setRGB(x, y, rgb);
|
||||
}
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
}
|
@ -0,0 +1,126 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class FakeScanRequest {
|
||||
public enum Quality {
|
||||
low,
|
||||
medium,
|
||||
high
|
||||
}
|
||||
|
||||
public enum Rotation {
|
||||
none,
|
||||
slight,
|
||||
moderate,
|
||||
severe
|
||||
}
|
||||
|
||||
public enum Colorspace {
|
||||
grayscale,
|
||||
color
|
||||
}
|
||||
|
||||
@Schema(
|
||||
description = "PDF file to process",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
type = "string",
|
||||
format = "binary")
|
||||
@NotNull(message = "File input is required")
|
||||
private MultipartFile fileInput;
|
||||
|
||||
@Schema(description = "Scan quality preset", example = "high")
|
||||
@NotNull(message = "Quality is required")
|
||||
private Quality quality = Quality.high;
|
||||
|
||||
@Schema(description = "Rotation preset", example = "none")
|
||||
@NotNull(message = "Rotation is required")
|
||||
private Rotation rotation = Rotation.slight;
|
||||
|
||||
@Schema(description = "Colorspace for output image", example = "grayscale")
|
||||
private Colorspace colorspace = Colorspace.grayscale;
|
||||
|
||||
@Schema(description = "Border thickness in pixels", example = "20")
|
||||
private int border = 20;
|
||||
|
||||
@Schema(description = "Base rotation in degrees", example = "0")
|
||||
private int rotate = 0;
|
||||
|
||||
@Schema(description = "Random rotation variance in degrees", example = "2")
|
||||
private int rotateVariance = 2;
|
||||
|
||||
@Schema(description = "Brightness multiplier (1.0 = no change)", example = "1.0")
|
||||
private float brightness = 1.0f;
|
||||
|
||||
@Schema(description = "Contrast multiplier (1.0 = no change)", example = "1.0")
|
||||
private float contrast = 1.0f;
|
||||
|
||||
@Schema(description = "Blur amount (0 = none, higher = more blur)", example = "1.0")
|
||||
private float blur = 1.0f;
|
||||
|
||||
@Schema(description = "Noise amount (0 = none, higher = more noise)", example = "8.0")
|
||||
private float noise = 8.0f;
|
||||
|
||||
@Schema(description = "Simulate yellowed paper", example = "false")
|
||||
private boolean yellowish = false;
|
||||
|
||||
@Schema(description = "Rendering resolution in DPI", example = "300")
|
||||
private int resolution = 300;
|
||||
|
||||
@Schema(description = "Whether advanced settings are enabled", example = "false")
|
||||
private boolean advancedEnabled = false;
|
||||
|
||||
public boolean isAdvancedEnabled() {
|
||||
return advancedEnabled;
|
||||
}
|
||||
|
||||
public int getQualityValue() {
|
||||
return switch (quality) {
|
||||
case low -> 30;
|
||||
case medium -> 60;
|
||||
case high -> 100;
|
||||
};
|
||||
}
|
||||
|
||||
public int getRotationValue() {
|
||||
return switch (rotation) {
|
||||
case none -> 0;
|
||||
case slight -> 2;
|
||||
case moderate -> 5;
|
||||
case severe -> 8;
|
||||
};
|
||||
}
|
||||
|
||||
public void applyHighQualityPreset() {
|
||||
this.blur = 0.1f;
|
||||
this.noise = 1.0f;
|
||||
this.brightness = 1.02f;
|
||||
this.contrast = 1.05f;
|
||||
this.resolution = 600;
|
||||
}
|
||||
|
||||
public void applyMediumQualityPreset() {
|
||||
this.blur = 0.5f;
|
||||
this.noise = 3.0f;
|
||||
this.brightness = 1.05f;
|
||||
this.contrast = 1.1f;
|
||||
this.resolution = 300;
|
||||
}
|
||||
|
||||
public void applyLowQualityPreset() {
|
||||
this.blur = 1.0f;
|
||||
this.noise = 5.0f;
|
||||
this.brightness = 1.1f;
|
||||
this.contrast = 1.2f;
|
||||
this.resolution = 150;
|
||||
}
|
||||
}
|
@ -1569,3 +1569,38 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
|
||||
cookieBanner.preferencesModal.analytics.title=Analytics
|
||||
cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with.
|
||||
|
||||
#fakeScan
|
||||
fakeScan.title=Fake Scan
|
||||
fakeScan.header=Fake Scan
|
||||
fakeScan.description=Create a PDF that looks like it was scanned
|
||||
fakeScan.selectPDF=Select PDF:
|
||||
fakeScan.quality=Scan Quality
|
||||
fakeScan.quality.low=Low
|
||||
fakeScan.quality.medium=Medium
|
||||
fakeScan.quality.high=High
|
||||
fakeScan.rotation=Rotation Angle
|
||||
fakeScan.rotation.none=None
|
||||
fakeScan.rotation.slight=Slight
|
||||
fakeScan.rotation.moderate=Moderate
|
||||
fakeScan.rotation.severe=Severe
|
||||
fakeScan.submit=Create Fake Scan
|
||||
|
||||
#home.fakeScan
|
||||
home.fakeScan.title=Fake Scan
|
||||
home.fakeScan.desc=Create a PDF that looks like it was scanned
|
||||
fakeScan.tags=scan,simulate,realistic,convert
|
||||
|
||||
# FakeScan advanced settings (frontend)
|
||||
fakeScan.advancedSettings=Enable Advanced Scan Settings
|
||||
fakeScan.colorspace=Colorspace
|
||||
fakeScan.colorspace.grayscale=Grayscale
|
||||
fakeScan.colorspace.color=Color
|
||||
fakeScan.border=Border (px)
|
||||
fakeScan.rotate=Base Rotation (degrees)
|
||||
fakeScan.rotateVariance=Rotation Variance (degrees)
|
||||
fakeScan.brightness=Brightness
|
||||
fakeScan.contrast=Contrast
|
||||
fakeScan.blur=Blur
|
||||
fakeScan.noise=Noise
|
||||
fakeScan.yellowish=Yellowish (simulate old paper)
|
||||
fakeScan.resolution=Resolution (DPI)
|
||||
|
@ -264,6 +264,7 @@
|
||||
<div
|
||||
th:replace="~{fragments/navbarEntry :: navbarEntry('show-javascript', 'javascript', 'home.showJS.title', 'home.showJS.desc', 'showJS.tags', 'advance')}">
|
||||
</div>
|
||||
<div th:replace="~{fragments/navbarEntry :: navbarEntry('fake-scan', 'scanner', 'fakeScan.title', 'fakeScan.description', 'fakeScan.tags', 'advance')}"></div>
|
||||
<div
|
||||
th:replace="~{fragments/navbarEntryCustom :: navbarEntry('split-by-size-or-count', '/images/split-size.svg#icon-split-size', 'home.autoSizeSplitPDF.title', 'home.autoSizeSplitPDF.desc', 'autoSizeSplitPDF.tags', 'advance')}">
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{fakeScan.title}, header=#{fakeScan.header})}"></th:block>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{fakeScan.title}, header=#{fakeScan.header})}"></th:block>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@ -12,12 +12,85 @@
|
||||
<br><br>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<h2 th:text="#{fakeScan.header}"></h2>
|
||||
<form method="post" enctype="multipart/form-data" th:action="@{'/api/v1/misc/fake-scan'}">
|
||||
<div class="col-md-6 bg-card">
|
||||
<div class="tool-header">
|
||||
<span class="material-symbols-rounded tool-header-icon advance">scanner</span>
|
||||
<span class="tool-header-text" th:text="#{fakeScan.title}"></span>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" id="uploadForm" th:action="@{'/api/v1/misc/fake-scan'}">
|
||||
<input type="hidden" name="advancedEnabled" id="advancedEnabled" value="false">
|
||||
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
|
||||
<br>
|
||||
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{fakeScan.submit}"></button>
|
||||
<div class="mb-3">
|
||||
<label for="quality" class="form-label" th:text="#{fakeScan.quality}"></label>
|
||||
<select class="form-select" id="quality" name="quality">
|
||||
<option value="low" th:text="#{fakeScan.quality.low}"></option>
|
||||
<option value="medium" th:text="#{fakeScan.quality.medium}"></option>
|
||||
<option value="high" th:text="#{fakeScan.quality.high}" selected></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rotation" class="form-label" th:text="#{fakeScan.rotation}"></label>
|
||||
<select class="form-select" id="rotation" name="rotation">
|
||||
<option value="none" th:text="#{fakeScan.rotation.none}"></option>
|
||||
<option value="slight" th:text="#{fakeScan.rotation.slight}" selected></option>
|
||||
<option value="moderate" th:text="#{fakeScan.rotation.moderate}"></option>
|
||||
<option value="severe" th:text="#{fakeScan.rotation.severe}"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="advancedSettingsToggle">
|
||||
<label class="form-check-label" for="advancedSettingsToggle" th:text="#{fakeScan.advancedSettings}"></label>
|
||||
</div>
|
||||
<div id="advancedSettings" style="display:none; border:1px solid #eee; padding:1em; border-radius:8px; margin-bottom:1em;">
|
||||
<div class="mb-3">
|
||||
<label for="colorspace" class="form-label" th:text="#{fakeScan.colorspace}"></label>
|
||||
<select class="form-select" id="colorspace" name="colorspace">
|
||||
<option value="grayscale" th:text="#{fakeScan.colorspace.grayscale}" selected></option>
|
||||
<option value="color" th:text="#{fakeScan.colorspace.color}"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="border" class="form-label" th:text="#{fakeScan.border}"></label>
|
||||
<input type="number" class="form-control" id="border" name="border" min="0" max="100" value="20">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rotate" class="form-label" th:text="#{fakeScan.rotate}"></label>
|
||||
<input type="number" class="form-control" id="rotate" name="rotate" min="-15" max="15" value="0">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rotateVariance" class="form-label" th:text="#{fakeScan.rotateVariance}"></label>
|
||||
<input type="number" class="form-control" id="rotateVariance" name="rotateVariance" min="0" max="10" value="2">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="brightness" class="form-label" th:text="#{fakeScan.brightness}"></label>
|
||||
<input type="range" class="form-range" id="brightness" name="brightness" min="0.5" max="1.5" step="0.01" value="1.0">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="contrast" class="form-label" th:text="#{fakeScan.contrast}"></label>
|
||||
<input type="range" class="form-range" id="contrast" name="contrast" min="0.5" max="1.5" step="0.01" value="1.0">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="blur" class="form-label" th:text="#{fakeScan.blur}"></label>
|
||||
<input type="range" class="form-range" id="blur" name="blur" min="0" max="5" step="0.1" value="1.0">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="noise" class="form-label" th:text="#{fakeScan.noise}"></label>
|
||||
<input type="range" class="form-range" id="noise" name="noise" min="0" max="20" step="0.1" value="8.0">
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="yellowish" name="yellowish">
|
||||
<label class="form-check-label" for="yellowish" th:text="#{fakeScan.yellowish}"></label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="resolution" class="form-label" th:text="#{fakeScan.resolution}"></label>
|
||||
<input type="number" class="form-control" id="resolution" name="resolution" min="72" max="600" value="300">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 text-left">
|
||||
<button type="submit" class="btn btn-primary" th:text="#{fakeScan.submit}" id="submitBtn"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@ -25,5 +98,66 @@
|
||||
</div>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
<script th:src="@{'/js/fetch-utils.js'}"></script>
|
||||
<script th:inline="javascript">
|
||||
// Show/hide advanced settings
|
||||
document.getElementById('advancedSettingsToggle').addEventListener('change', function() {
|
||||
document.getElementById('advancedSettings').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Form submission handling
|
||||
const form = document.getElementById('uploadForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
// If advanced settings are not enabled, remove advanced fields
|
||||
if (!document.getElementById('advancedSettingsToggle').checked) {
|
||||
const formData = new FormData(form);
|
||||
formData.delete('colorspace');
|
||||
formData.delete('border');
|
||||
formData.delete('rotate');
|
||||
formData.delete('rotateVariance');
|
||||
formData.delete('brightness');
|
||||
formData.delete('contrast');
|
||||
formData.delete('blur');
|
||||
formData.delete('noise');
|
||||
formData.delete('yellowish');
|
||||
formData.delete('resolution');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize advanced settings state
|
||||
const advancedSettingsToggle = document.getElementById('advancedSettingsToggle');
|
||||
const advancedSettings = document.getElementById('advancedSettings');
|
||||
if (advancedSettingsToggle && advancedSettings) {
|
||||
// Helper: enable/disable all fields in advanced settings
|
||||
function setAdvancedFieldsEnabled(enabled) {
|
||||
const fields = advancedSettings.querySelectorAll('input, select');
|
||||
fields.forEach(field => {
|
||||
field.disabled = !enabled;
|
||||
// If enabling and value is empty, set to default
|
||||
if (enabled && (field.value === '' || field.value == null)) {
|
||||
if (field.type === 'number' || field.type === 'range') {
|
||||
field.value = field.defaultValue;
|
||||
} else if (field.type === 'checkbox') {
|
||||
field.checked = field.defaultChecked;
|
||||
} else if (field.tagName === 'SELECT') {
|
||||
field.value = field.querySelector('option[selected]')?.value || field.options[0].value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Set initial state
|
||||
setAdvancedFieldsEnabled(advancedSettingsToggle.checked);
|
||||
advancedSettings.style.display = advancedSettingsToggle.checked ? 'block' : 'none';
|
||||
document.getElementById('advancedEnabled').value = advancedSettingsToggle.checked ? 'true' : 'false';
|
||||
// Add change listener
|
||||
advancedSettingsToggle.addEventListener('change', function() {
|
||||
advancedSettings.style.display = this.checked ? 'block' : 'none';
|
||||
setAdvancedFieldsEnabled(this.checked);
|
||||
document.getElementById('advancedEnabled').value = this.checked ? 'true' : 'false';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user