mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-22 23:08:53 +02:00
line art (#5052)
## Summary - introduce a shared line art conversion interface and proprietary ImageMagick-backed implementation - have the compress controller optionally autowire the enterprise service before running per-image line art processing - remove ImageMagick command details from core by delegating conversions through the proprietary service ## Testing - not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6928aecceaf083289a9269b1ca99307e) --------- Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
@@ -491,6 +491,9 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Ghostscript", "repair");
|
||||
addEndpointToGroup("Ghostscript", "compress-pdf");
|
||||
|
||||
/* ImageMagick */
|
||||
addEndpointToGroup("ImageMagick", "compress-pdf");
|
||||
|
||||
/* tesseract */
|
||||
addEndpointToGroup("tesseract", "ocr-pdf");
|
||||
|
||||
@@ -574,6 +577,7 @@ public class EndpointConfiguration {
|
||||
|| "Javascript".equals(group)
|
||||
|| "Weasyprint".equals(group)
|
||||
|| "Pdftohtml".equals(group)
|
||||
|| "ImageMagick".equals(group)
|
||||
|| "rar".equals(group);
|
||||
}
|
||||
|
||||
|
||||
@@ -653,6 +653,7 @@ public class ApplicationProperties {
|
||||
private int weasyPrintSessionLimit;
|
||||
private int installAppSessionLimit;
|
||||
private int calibreSessionLimit;
|
||||
private int imageMagickSessionLimit;
|
||||
private int qpdfSessionLimit;
|
||||
private int tesseractSessionLimit;
|
||||
private int ghostscriptSessionLimit;
|
||||
@@ -690,6 +691,10 @@ public class ApplicationProperties {
|
||||
return calibreSessionLimit > 0 ? calibreSessionLimit : 1;
|
||||
}
|
||||
|
||||
public int getImageMagickSessionLimit() {
|
||||
return imageMagickSessionLimit > 0 ? imageMagickSessionLimit : 4;
|
||||
}
|
||||
|
||||
public int getGhostscriptSessionLimit() {
|
||||
return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8;
|
||||
}
|
||||
@@ -719,6 +724,8 @@ public class ApplicationProperties {
|
||||
@JsonProperty("calibretimeoutMinutes")
|
||||
private long calibreTimeoutMinutes;
|
||||
|
||||
private long imageMagickTimeoutMinutes;
|
||||
|
||||
private long tesseractTimeoutMinutes;
|
||||
private long qpdfTimeoutMinutes;
|
||||
private long ghostscriptTimeoutMinutes;
|
||||
@@ -756,6 +763,10 @@ public class ApplicationProperties {
|
||||
return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
public long getImageMagickTimeoutMinutes() {
|
||||
return imageMagickTimeoutMinutes > 0 ? imageMagickTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
public long getGhostscriptTimeoutMinutes() {
|
||||
return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
|
||||
public interface LineArtConversionService {
|
||||
PDImageXObject convertImageToLineArt(
|
||||
PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
|
||||
throws IOException;
|
||||
}
|
||||
@@ -86,6 +86,11 @@ public class ProcessExecutor {
|
||||
.getProcessExecutor()
|
||||
.getSessionLimit()
|
||||
.getCalibreSessionLimit();
|
||||
case IMAGEMAGICK ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
.getSessionLimit()
|
||||
.getImageMagickSessionLimit();
|
||||
case GHOSTSCRIPT ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
@@ -141,6 +146,11 @@ public class ProcessExecutor {
|
||||
.getProcessExecutor()
|
||||
.getTimeoutMinutes()
|
||||
.getCalibreTimeoutMinutes();
|
||||
case IMAGEMAGICK ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
.getTimeoutMinutes()
|
||||
.getImageMagickTimeoutMinutes();
|
||||
case GHOSTSCRIPT ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
@@ -301,6 +311,7 @@ public class ProcessExecutor {
|
||||
WEASYPRINT,
|
||||
INSTALL_APP,
|
||||
CALIBRE,
|
||||
IMAGEMAGICK,
|
||||
TESSERACT,
|
||||
QPDF,
|
||||
GHOSTSCRIPT,
|
||||
|
||||
@@ -46,6 +46,7 @@ public class ExternalAppDepConfig {
|
||||
put("qpdf", List.of("qpdf"));
|
||||
put("tesseract", List.of("tesseract"));
|
||||
put("rar", List.of("rar")); // Required for real CBR output
|
||||
put("magick", List.of("ImageMagick"));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -128,6 +129,7 @@ public class ExternalAppDepConfig {
|
||||
checkDependencyAndDisableGroup("pdftohtml");
|
||||
checkDependencyAndDisableGroup(unoconvPath);
|
||||
checkDependencyAndDisableGroup("rar");
|
||||
checkDependencyAndDisableGroup("magick");
|
||||
// Special handling for Python/OpenCV dependencies
|
||||
boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python");
|
||||
if (!pythonAvailable) {
|
||||
|
||||
@@ -28,10 +28,13 @@ import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -44,6 +47,7 @@ import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.LineArtConversionService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
@@ -58,6 +62,9 @@ public class CompressController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@Autowired(required = false)
|
||||
private LineArtConversionService lineArtConversionService;
|
||||
|
||||
private boolean isQpdfEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("qpdf");
|
||||
}
|
||||
@@ -66,6 +73,10 @@ public class CompressController {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
|
||||
private boolean isImageMagickEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("ImageMagick");
|
||||
}
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@@ -660,6 +671,9 @@ public class CompressController {
|
||||
Integer optimizeLevel = request.getOptimizeLevel();
|
||||
String expectedOutputSizeString = request.getExpectedOutputSize();
|
||||
Boolean convertToGrayscale = request.getGrayscale();
|
||||
Boolean convertToLineArt = request.getLineArt();
|
||||
Double lineArtThreshold = request.getLineArtThreshold();
|
||||
Integer lineArtEdgeLevel = request.getLineArtEdgeLevel();
|
||||
if (expectedOutputSizeString == null && optimizeLevel == null) {
|
||||
throw new Exception("Both expected output size and optimize level are not specified");
|
||||
}
|
||||
@@ -689,6 +703,26 @@ public class CompressController {
|
||||
optimizeLevel = determineOptimizeLevel(sizeReductionRatio);
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(convertToLineArt)) {
|
||||
if (lineArtConversionService == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Line art conversion is unavailable - ImageMagick service not found");
|
||||
}
|
||||
if (!isImageMagickEnabled()) {
|
||||
throw new IOException(
|
||||
"ImageMagick is not enabled but line art conversion was requested");
|
||||
}
|
||||
double thresholdValue =
|
||||
lineArtThreshold == null
|
||||
? 55d
|
||||
: Math.min(100d, Math.max(0d, lineArtThreshold));
|
||||
int edgeLevel =
|
||||
lineArtEdgeLevel == null ? 1 : Math.min(3, Math.max(1, lineArtEdgeLevel));
|
||||
currentFile =
|
||||
applyLineArtConversion(currentFile, tempFiles, thresholdValue, edgeLevel);
|
||||
}
|
||||
|
||||
boolean sizeMet = false;
|
||||
boolean imageCompressionApplied = false;
|
||||
boolean externalCompressionApplied = false;
|
||||
@@ -810,6 +844,75 @@ public class CompressController {
|
||||
}
|
||||
}
|
||||
|
||||
private Path applyLineArtConversion(
|
||||
Path currentFile, List<Path> tempFiles, double threshold, int edgeLevel)
|
||||
throws IOException {
|
||||
|
||||
Path lineArtFile = Files.createTempFile("lineart_output_", ".pdf");
|
||||
tempFiles.add(lineArtFile);
|
||||
|
||||
try (PDDocument doc = pdfDocumentFactory.load(currentFile.toFile())) {
|
||||
Map<String, List<ImageReference>> uniqueImages = findImages(doc);
|
||||
CompressionStats stats = new CompressionStats();
|
||||
stats.uniqueImagesCount = uniqueImages.size();
|
||||
calculateImageStats(uniqueImages, stats);
|
||||
|
||||
Map<String, PDImageXObject> convertedImages =
|
||||
createLineArtImages(doc, uniqueImages, stats, threshold, edgeLevel);
|
||||
|
||||
replaceImages(doc, uniqueImages, convertedImages, stats);
|
||||
|
||||
log.info(
|
||||
"Applied line art conversion to {} unique images ({} total references)",
|
||||
stats.uniqueImagesCount,
|
||||
stats.totalImages);
|
||||
|
||||
doc.save(lineArtFile.toString());
|
||||
return lineArtFile;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, PDImageXObject> createLineArtImages(
|
||||
PDDocument doc,
|
||||
Map<String, List<ImageReference>> uniqueImages,
|
||||
CompressionStats stats,
|
||||
double threshold,
|
||||
int edgeLevel)
|
||||
throws IOException {
|
||||
|
||||
Map<String, PDImageXObject> convertedImages = new HashMap<>();
|
||||
|
||||
for (Entry<String, List<ImageReference>> entry : uniqueImages.entrySet()) {
|
||||
String imageHash = entry.getKey();
|
||||
List<ImageReference> references = entry.getValue();
|
||||
if (references.isEmpty()) continue;
|
||||
|
||||
PDImageXObject originalImage = getOriginalImage(doc, references.get(0));
|
||||
|
||||
int originalSize = (int) originalImage.getCOSObject().getLength();
|
||||
stats.totalOriginalBytes += originalSize;
|
||||
|
||||
PDImageXObject converted =
|
||||
lineArtConversionService.convertImageToLineArt(
|
||||
doc, originalImage, threshold, edgeLevel);
|
||||
convertedImages.put(imageHash, converted);
|
||||
stats.compressedImages++;
|
||||
|
||||
int convertedSize = (int) converted.getCOSObject().getLength();
|
||||
stats.totalCompressedBytes += convertedSize * references.size();
|
||||
|
||||
double reductionPercentage = 100.0 - ((convertedSize * 100.0) / originalSize);
|
||||
log.info(
|
||||
"Image hash {}: Line art conversion {} → {} (reduced by {}%)",
|
||||
imageHash,
|
||||
GeneralUtils.formatBytes(originalSize),
|
||||
GeneralUtils.formatBytes(convertedSize),
|
||||
String.format("%.1f", reductionPercentage));
|
||||
}
|
||||
|
||||
return convertedImages;
|
||||
}
|
||||
|
||||
// Run Ghostscript compression
|
||||
private void applyGhostscriptCompression(
|
||||
OptimizePdfRequest request, int optimizeLevel, Path currentFile, List<Path> tempFiles)
|
||||
|
||||
@@ -230,4 +230,10 @@ public class ConfigController {
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/group-enabled")
|
||||
public ResponseEntity<Boolean> isGroupEnabled(@RequestParam(name = "group") String group) {
|
||||
boolean enabled = endpointConfiguration.isGroupEnabled(group);
|
||||
return ResponseEntity.ok(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +74,13 @@ public class ReactRoutingController {
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
|
||||
@@ -45,4 +45,26 @@ public class OptimizePdfRequest extends PDFFile {
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
defaultValue = "false")
|
||||
private Boolean grayscale = false;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Whether to convert images to high-contrast line art using ImageMagick. Default is false.",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "false")
|
||||
private Boolean lineArt = false;
|
||||
|
||||
@Schema(
|
||||
description = "Threshold to use for line art conversion (0-100).",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "55")
|
||||
private Double lineArtThreshold = 55d;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Edge detection strength to use for line art conversion (1-3). This maps to"
|
||||
+ " ImageMagick's -edge radius.",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "1",
|
||||
allowableValues = {"1", "2", "3"})
|
||||
private Integer lineArtEdgeLevel = 1;
|
||||
}
|
||||
|
||||
@@ -224,6 +224,7 @@ processExecutor:
|
||||
weasyPrintSessionLimit: 16
|
||||
installAppSessionLimit: 1
|
||||
calibreSessionLimit: 1
|
||||
imageMagickSessionLimit: 4
|
||||
ghostscriptSessionLimit: 8
|
||||
ocrMyPdfSessionLimit: 2
|
||||
timeoutMinutes: # Process executor timeout in minutes
|
||||
@@ -233,6 +234,7 @@ processExecutor:
|
||||
weasyPrinttimeoutMinutes: 30
|
||||
installApptimeoutMinutes: 60
|
||||
calibretimeoutMinutes: 30
|
||||
imageMagickTimeoutMinutes: 30
|
||||
tesseractTimeoutMinutes: 30
|
||||
qpdfTimeoutMinutes: 30
|
||||
ghostscriptTimeoutMinutes: 30
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.LineArtConversionService;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ImageMagickLineArtConversionService implements LineArtConversionService {
|
||||
|
||||
@Override
|
||||
public PDImageXObject convertImageToLineArt(
|
||||
PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
|
||||
throws IOException {
|
||||
|
||||
Path inputImage = Files.createTempFile("lineart_image_input_", ".png");
|
||||
Path outputImage = Files.createTempFile("lineart_image_output_", ".tiff");
|
||||
|
||||
try {
|
||||
ImageIO.write(originalImage.getImage(), "png", inputImage.toFile());
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("magick");
|
||||
command.add(inputImage.toString());
|
||||
command.add("-colorspace");
|
||||
command.add("Gray");
|
||||
|
||||
// Edge-aware line art conversion using ImageMagick's built-in operators.
|
||||
// -edge/-negate/-normalize are standard convert options (IM v6+/v7) that
|
||||
// accentuate outlines before thresholding to a bilevel image.
|
||||
command.add("-edge");
|
||||
command.add(String.valueOf(edgeLevel));
|
||||
command.add("-negate");
|
||||
command.add("-normalize");
|
||||
|
||||
command.add("-type");
|
||||
command.add("Bilevel");
|
||||
command.add("-threshold");
|
||||
command.add(String.format(Locale.ROOT, "%.1f%%", threshold));
|
||||
command.add("-compress");
|
||||
command.add("Group4");
|
||||
command.add(outputImage.toString());
|
||||
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.IMAGEMAGICK)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.warn(
|
||||
"ImageMagick line art conversion failed with return code: {}",
|
||||
result.getRc());
|
||||
throw new IOException("ImageMagick line art conversion failed");
|
||||
}
|
||||
|
||||
byte[] convertedBytes = Files.readAllBytes(outputImage);
|
||||
return PDImageXObject.createFromByteArray(
|
||||
doc, convertedBytes, originalImage.getCOSObject().toString());
|
||||
} catch (Exception e) {
|
||||
log.warn("ImageMagick line art conversion failed", e);
|
||||
throw new IOException("ImageMagick line art conversion failed", e);
|
||||
} finally {
|
||||
Files.deleteIfExists(inputImage);
|
||||
Files.deleteIfExists(outputImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
gcompat \
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
imagemagick \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF
|
||||
|
||||
@@ -81,6 +81,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
imagemagick \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
|
||||
@@ -74,6 +74,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
imagemagick \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
|
||||
@@ -99,6 +99,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
imagemagick \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
|
||||
@@ -101,6 +101,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
imagemagick \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
|
||||
@@ -3729,6 +3729,16 @@ filesize = "File Size"
|
||||
[compress.grayscale]
|
||||
label = "Apply Grayscale for Compression"
|
||||
|
||||
[compress.lineArt]
|
||||
label = "Convert images to line art"
|
||||
description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction."
|
||||
unavailable = "ImageMagick is not installed or enabled on this server"
|
||||
detailLevel = "Detail level"
|
||||
edgeEmphasis = "Edge emphasis"
|
||||
edgeLow = "Gentle"
|
||||
edgeMedium = "Balanced"
|
||||
edgeHigh = "Strong"
|
||||
|
||||
[compress.tooltip.header]
|
||||
title = "Compress Settings Overview"
|
||||
|
||||
@@ -3746,6 +3756,10 @@ bullet2 = "Higher values reduce file size"
|
||||
title = "Grayscale"
|
||||
text = "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents."
|
||||
|
||||
[compress.tooltip.lineArt]
|
||||
title = "Line Art"
|
||||
text = "Convert pages to high-contrast black and white using ImageMagick. Use detail level to control how much content becomes black, and edge emphasis to control how aggressively edges are detected."
|
||||
|
||||
[compress.error]
|
||||
failed = "An error occurred while compressing the PDF."
|
||||
|
||||
|
||||
@@ -1,74 +1,82 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.0.0",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev -- --mode desktop",
|
||||
"beforeBuildCommand": "npm run build -- --mode desktop"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Stirling-PDF",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "dmg", "app", "msi"],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
"icons/16x16.png",
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/192x192.png"
|
||||
],
|
||||
"resources": [
|
||||
"libs/*.jar",
|
||||
"runtime/jre/**/*"
|
||||
],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["pdf"],
|
||||
"name": "PDF Document",
|
||||
"description": "Open PDF files with Stirling-PDF",
|
||||
"role": "Editor",
|
||||
"mimeType": "application/pdf"
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"desktopTemplate": "stirling-pdf.desktop"
|
||||
}
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.1.3",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev -- --mode desktop",
|
||||
"beforeBuildCommand": "npm run build -- --mode desktop"
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Stirling-PDF",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null,
|
||||
"providerShortName": null
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"deb",
|
||||
"rpm",
|
||||
"dmg",
|
||||
"app",
|
||||
"msi"
|
||||
],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
"icons/16x16.png",
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/192x192.png"
|
||||
],
|
||||
"resources": [
|
||||
"libs/*.jar",
|
||||
"runtime/jre/**/*"
|
||||
],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": [
|
||||
"pdf"
|
||||
],
|
||||
"name": "PDF Document",
|
||||
"description": "Open PDF files with Stirling-PDF",
|
||||
"role": "Editor",
|
||||
"mimeType": "application/pdf"
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"desktopTemplate": "stirling-pdf.desktop"
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null,
|
||||
"providerShortName": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
|
||||
import ButtonSelector from "@app/components/shared/ButtonSelector";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
interface CompressSettingsProps {
|
||||
parameters: CompressParameters;
|
||||
@@ -13,6 +14,20 @@ interface CompressSettingsProps {
|
||||
const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isSliding, setIsSliding] = useState(false);
|
||||
const [imageMagickAvailable, setImageMagickAvailable] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkImageMagick = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<boolean>('/api/v1/config/group-enabled?group=ImageMagick');
|
||||
setImageMagickAvailable(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to check ImageMagick availability:', error);
|
||||
setImageMagickAvailable(true); // Optimistic fallback
|
||||
}
|
||||
};
|
||||
checkImageMagick();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -129,6 +144,62 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
||||
disabled={disabled}
|
||||
label={t("compress.grayscale.label", "Apply Grayscale for compression")}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={parameters.lineArt}
|
||||
onChange={(event) => onParameterChange('lineArt', event.currentTarget.checked)}
|
||||
disabled={disabled || imageMagickAvailable === false}
|
||||
label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
|
||||
description={
|
||||
imageMagickAvailable === false
|
||||
? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")
|
||||
: t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
|
||||
}
|
||||
/>
|
||||
{parameters.lineArt && (
|
||||
<Stack gap="xs" style={{ opacity: (disabled || imageMagickAvailable === false) ? 0.6 : 1 }}>
|
||||
<Text size="sm" fw={600}>{t('compress.lineArt.detailLevel', 'Detail level')}</Text>
|
||||
<Slider
|
||||
min={1}
|
||||
max={5}
|
||||
step={1}
|
||||
value={(() => {
|
||||
// Map threshold to slider position
|
||||
const thresholdMap = [20, 35, 50, 65, 80];
|
||||
const closest = thresholdMap.reduce((prev, curr, idx) =>
|
||||
Math.abs(curr - parameters.lineArtThreshold) < Math.abs(thresholdMap[prev] - parameters.lineArtThreshold)
|
||||
? idx : prev, 0);
|
||||
return closest + 1;
|
||||
})()}
|
||||
onChange={(value) => {
|
||||
// Map slider position to threshold: 1=20%, 2=35%, 3=50%, 4=65%, 5=80%
|
||||
const thresholdMap = [20, 35, 50, 65, 80];
|
||||
onParameterChange('lineArtThreshold', thresholdMap[value - 1]);
|
||||
}}
|
||||
disabled={disabled || imageMagickAvailable === false}
|
||||
label={null}
|
||||
marks={[
|
||||
{ value: 1 },
|
||||
{ value: 2 },
|
||||
{ value: 3 },
|
||||
{ value: 4 },
|
||||
{ value: 5 },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={600}>{t('compress.lineArt.edgeEmphasis', 'Edge emphasis')}</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
disabled={disabled || imageMagickAvailable === false}
|
||||
data={[
|
||||
{ value: '1', label: t('compress.lineArt.edgeLow', 'Gentle') },
|
||||
{ value: '2', label: t('compress.lineArt.edgeMedium', 'Balanced') },
|
||||
{ value: '3', label: t('compress.lineArt.edgeHigh', 'Strong') },
|
||||
]}
|
||||
value={parameters.lineArtEdgeLevel.toString()}
|
||||
onChange={(value) => onParameterChange('lineArtEdgeLevel', parseInt(value) as 1 | 2 | 3)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,13 @@ export const useCompressTips = (): TooltipContent => {
|
||||
{
|
||||
title: t("compress.tooltip.grayscale.title", "Grayscale"),
|
||||
description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.")
|
||||
},
|
||||
{
|
||||
title: t("compress.tooltip.lineArt.title", "Line Art"),
|
||||
description: t(
|
||||
"compress.tooltip.lineArt.text",
|
||||
"Convert pages to high-contrast black and white using ImageMagick. Use line thickness to control the threshold percentage and detection strength to choose how aggressively edges are outlined."
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -19,6 +19,11 @@ export const buildCompressFormData = (parameters: CompressParameters, file: File
|
||||
}
|
||||
|
||||
formData.append("grayscale", parameters.grayscale.toString());
|
||||
formData.append("lineArt", parameters.lineArt.toString());
|
||||
if (parameters.lineArt) {
|
||||
formData.append("lineArtThreshold", parameters.lineArtThreshold.toString());
|
||||
formData.append("lineArtEdgeLevel", parameters.lineArtEdgeLevel.toString());
|
||||
}
|
||||
return formData;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/u
|
||||
export interface CompressParameters extends BaseParameters {
|
||||
compressionLevel: number;
|
||||
grayscale: boolean;
|
||||
lineArt: boolean;
|
||||
lineArtThreshold: number;
|
||||
lineArtEdgeLevel: 1 | 2 | 3;
|
||||
expectedSize: string;
|
||||
compressionMethod: 'quality' | 'filesize';
|
||||
fileSizeValue: string;
|
||||
@@ -13,6 +16,9 @@ export interface CompressParameters extends BaseParameters {
|
||||
export const defaultParameters: CompressParameters = {
|
||||
compressionLevel: 5,
|
||||
grayscale: false,
|
||||
lineArt: false,
|
||||
lineArtThreshold: 50,
|
||||
lineArtEdgeLevel: 3,
|
||||
expectedSize: '',
|
||||
compressionMethod: 'quality',
|
||||
fileSizeValue: '',
|
||||
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.0.0',
|
||||
appVersion: '2.1.3',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.0.0',
|
||||
appVersion: '2.1.3',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
enableDesktopInstallSlide: true,
|
||||
|
||||
Reference in New Issue
Block a user