diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 35f68939c..ff48b5b2e 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -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); } diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 7e64ea9a7..e94a5f395 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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; } diff --git a/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java b/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java new file mode 100644 index 000000000..ab4f55d2e --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java @@ -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; +} diff --git a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java index 3b94fbfbc..269441813 100644 --- a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java +++ b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java @@ -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, diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 59c8825fc..8606cc2a9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -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) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java index 865a95c5c..9b0483da9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java @@ -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 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> uniqueImages = findImages(doc); + CompressionStats stats = new CompressionStats(); + stats.uniqueImagesCount = uniqueImages.size(); + calculateImageStats(uniqueImages, stats); + + Map 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 createLineArtImages( + PDDocument doc, + Map> uniqueImages, + CompressionStats stats, + double threshold, + int edgeLevel) + throws IOException { + + Map convertedImages = new HashMap<>(); + + for (Entry> entry : uniqueImages.entrySet()) { + String imageHash = entry.getKey(); + List 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 tempFiles) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 91aa9924d..95486ff9b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -230,4 +230,10 @@ public class ConfigController { } return ResponseEntity.ok(result); } + + @GetMapping("/group-enabled") + public ResponseEntity isGroupEnabled(@RequestParam(name = "group") String group) { + boolean enabled = endpointConfiguration.isGroupEnabled(group); + return ResponseEntity.ok(enabled); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 6373e0752..ab8f3b75b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -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 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 forwardNestedPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java index bf96dd217..d6e5c7021 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java @@ -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; } diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 64b4bd50e..a272c54cc 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java new file mode 100644 index 000000000..8ca6a83e7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java @@ -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 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); + } + } +} diff --git a/docker/Dockerfile.unified b/docker/Dockerfile.unified index 0ba7cfb3c..2968f569c 100644 --- a/docker/Dockerfile.unified +++ b/docker/Dockerfile.unified @@ -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 diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index f2421fa94..b946e1e61 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -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 \ diff --git a/docker/backend/Dockerfile.fat b/docker/backend/Dockerfile.fat index c54a162da..78d395d9d 100644 --- a/docker/backend/Dockerfile.fat +++ b/docker/backend/Dockerfile.fat @@ -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 \ diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index a38aee9b4..6b189f310 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -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 \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 67e648aee..462daa901 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -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 \ diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 3f269a52c..7743ca9b1 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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." diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index a7c3355d8..b10fec0e5 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -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 - } - } } diff --git a/frontend/src/core/components/tools/compress/CompressSettings.tsx b/frontend/src/core/components/tools/compress/CompressSettings.tsx index 398e0b7b4..f444da263 100644 --- a/frontend/src/core/components/tools/compress/CompressSettings.tsx +++ b/frontend/src/core/components/tools/compress/CompressSettings.tsx @@ -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(null); + + useEffect(() => { + const checkImageMagick = async () => { + try { + const response = await apiClient.get('/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 ( @@ -129,6 +144,62 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C disabled={disabled} label={t("compress.grayscale.label", "Apply Grayscale for compression")} /> + 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 && ( + + {t('compress.lineArt.detailLevel', 'Detail level')} + { + // 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 }, + ]} + /> + + {t('compress.lineArt.edgeEmphasis', 'Edge emphasis')} + onParameterChange('lineArtEdgeLevel', parseInt(value) as 1 | 2 | 3)} + /> + + )} ); diff --git a/frontend/src/core/components/tooltips/useCompressTips.ts b/frontend/src/core/components/tooltips/useCompressTips.ts index c49ceaca2..3b5299e08 100644 --- a/frontend/src/core/components/tooltips/useCompressTips.ts +++ b/frontend/src/core/components/tooltips/useCompressTips.ts @@ -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." + ) } ] }; diff --git a/frontend/src/core/hooks/tools/compress/useCompressOperation.ts b/frontend/src/core/hooks/tools/compress/useCompressOperation.ts index 5b1417b21..8e8f27b33 100644 --- a/frontend/src/core/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/src/core/hooks/tools/compress/useCompressOperation.ts @@ -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; }; diff --git a/frontend/src/core/hooks/tools/compress/useCompressParameters.ts b/frontend/src/core/hooks/tools/compress/useCompressParameters.ts index 1ae9298f5..16f80f1d1 100644 --- a/frontend/src/core/hooks/tools/compress/useCompressParameters.ts +++ b/frontend/src/core/hooks/tools/compress/useCompressParameters.ts @@ -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: '', diff --git a/frontend/src/core/testing/serverExperienceSimulations.ts b/frontend/src/core/testing/serverExperienceSimulations.ts index f136ce489..4bb372545 100644 --- a/frontend/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/src/core/testing/serverExperienceSimulations.ts @@ -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, diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts index ce6b60ab0..022910d0a 100644 --- a/frontend/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts @@ -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,