diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index 97cb440866..0000000000 --- a/.github/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# CI Configuration - -## CI Lite Mode - -Skip non-essential CI workflows by setting a repository variable: - -**Settings → Secrets and variables → Actions → Variables → New repository variable** - -- Name: `CI_PROFILE` -- Value: `lite` - -Skips resource-intensive builds, releases, and OSS-specific workflows. Useful for deployment-only forks or faster CI runs. diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index ad6d6f3dc4..c96d7d5b20 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -52,7 +52,6 @@ jobs: core.setOutput('repository', pr.head.repo.full_name); core.setOutput('ref', pr.head.ref); core.setOutput('is_fork', String(pr.head.repo.fork)); - core.setOutput('base_ref', pr.base.ref); core.setOutput('author', pr.user.login); core.setOutput('state', pr.state); @@ -65,10 +64,6 @@ jobs: IS_FORK: ${{ steps.resolve.outputs.is_fork }} # nur bei workflow_dispatch gesetzt: ALLOW_FORK_INPUT: ${{ inputs.allow_fork }} - # für Auto-PR-Logik: - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BRANCH: ${{ github.event.pull_request.head.ref }} - PR_BASE: ${{ steps.resolve.outputs.base_ref }} PR_AUTHOR: ${{ steps.resolve.outputs.author }} run: | set -e @@ -89,14 +84,8 @@ jobs: else auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done - if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then + if [ "$is_auth" = true ]; then should=true - else - title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true - branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true - if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then - should=true - fi fi fi @@ -174,7 +163,7 @@ jobs: owner, repo, issue_number: prNumber, - body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.` + body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment for approved V2 contributors._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.` }); return newComment.id; @@ -394,7 +383,7 @@ jobs: `🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` + `🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` + - `🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`; + `🔄 **Auto-deployed** for approved V2 contributors.`; await github.rest.issues.createComment({ owner, diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a049bb90c..a5df3e9ec6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -262,7 +262,13 @@ jobs: strategy: fail-fast: false matrix: - docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/Dockerfile.fat"] + include: + - docker-rev: docker/embedded/Dockerfile + artifact-suffix: Dockerfile + - docker-rev: docker/embedded/Dockerfile.ultra-lite + artifact-suffix: Dockerfile.ultra-lite + - docker-rev: docker/embedded/Dockerfile.fat + artifact-suffix: Dockerfile.fat steps: - name: Harden Runner uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 @@ -272,6 +278,13 @@ jobs: - name: Checkout Repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Free disk space on runner + run: | + echo "Disk space before cleanup:" && df -h + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost + docker system prune -af || true + echo "Disk space after cleanup:" && df -h + - name: Set up JDK 17 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 with: @@ -313,7 +326,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: reports-docker-${{ matrix.docker-rev }} + name: reports-docker-${{ matrix.artifact-suffix }} path: | build/reports/tests/ build/test-results/ 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 35f68939c3..ff48b5b2e7 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 56a9b05afd..b672298caa 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 @@ -398,6 +398,7 @@ public class ApplicationProperties { private Boolean enableAnalytics; private Boolean enablePosthog; private Boolean enableScarf; + private Boolean enableDesktopInstallSlide; private Datasource datasource; private Boolean disableSanitize; private int maxDPI; @@ -693,6 +694,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; @@ -730,6 +732,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; } @@ -759,6 +765,8 @@ public class ApplicationProperties { @JsonProperty("calibretimeoutMinutes") private long calibreTimeoutMinutes; + private long imageMagickTimeoutMinutes; + private long tesseractTimeoutMinutes; private long qpdfTimeoutMinutes; private long ghostscriptTimeoutMinutes; @@ -796,6 +804,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 0000000000..ab4f55d2e6 --- /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 3b94fbfbce..2694418135 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/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index f1763e4313..acad6f4a9d 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -26,6 +26,7 @@ public class RequestUriUtils { || normalizedUri.startsWith("/public/") || normalizedUri.startsWith("/pdfjs/") || normalizedUri.startsWith("/pdfjs-legacy/") + || normalizedUri.startsWith("/pdfium/") || normalizedUri.startsWith("/assets/") || normalizedUri.startsWith("/locales/") || normalizedUri.startsWith("/Login/") @@ -61,7 +62,8 @@ public class RequestUriUtils { || normalizedUri.endsWith(".css") || normalizedUri.endsWith(".mjs") || normalizedUri.endsWith(".html") - || normalizedUri.endsWith(".toml"); + || normalizedUri.endsWith(".toml") + || normalizedUri.endsWith(".wasm"); } public static boolean isFrontendRoute(String contextPath, String requestURI) { @@ -125,11 +127,13 @@ public class RequestUriUtils { || requestURI.endsWith("popularity.txt") || requestURI.endsWith(".js") || requestURI.endsWith(".toml") + || requestURI.endsWith(".wasm") || requestURI.contains("swagger") || requestURI.startsWith("/api/v1/info") || requestURI.startsWith("/site.webmanifest") || requestURI.startsWith("/fonts") - || requestURI.startsWith("/pdfjs")); + || requestURI.startsWith("/pdfjs") + || requestURI.startsWith("/pdfium")); } /** diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index b7c121ab30..409615e68b 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -24,6 +24,9 @@ public class RequestUriUtilsTest { assertTrue( RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"), "PDF.js files should be static"); + assertTrue( + RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"), + "PDFium wasm should be static"); assertTrue( RequestUriUtils.isStaticResource("/api/v1/info/status"), "API status should be static"); @@ -110,7 +113,8 @@ public class RequestUriUtilsTest { "/downloads/document.png", "/assets/brand.ico", "/any/path/with/image.svg", - "/deep/nested/folder/icon.png" + "/deep/nested/folder/icon.png", + "/pdfium/pdfium.wasm" }) void testIsStaticResourceWithFileExtensions(String path) { assertTrue( @@ -148,6 +152,9 @@ public class RequestUriUtilsTest { assertFalse( RequestUriUtils.isTrackableResource("/script.js"), "JS files should not be trackable"); + assertFalse( + RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"), + "PDFium wasm should not be trackable"); assertFalse( RequestUriUtils.isTrackableResource("/swagger/index.html"), "Swagger files should not be trackable"); @@ -224,7 +231,8 @@ public class RequestUriUtilsTest { "/api/v1/info/health", "/site.webmanifest", "/fonts/roboto.woff", - "/pdfjs/viewer.js" + "/pdfjs/viewer.js", + "/pdfium/pdfium.wasm" }) void testNonTrackableResources(String path) { assertFalse( 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 59c8825fc4..8606cc2a92 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/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 0703708f54..8eac8fa80d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -1,10 +1,14 @@ package stirling.software.SPDF.config; +import java.util.concurrent.TimeUnit; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import lombok.RequiredArgsConstructor; @@ -25,6 +29,20 @@ public class WebMvcConfig implements WebMvcConfigurer { registry.addInterceptor(endpointInterceptor); } + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + // Cache hashed assets (JS/CSS with content hashes) for 1 year + // These files have names like index-ChAS4tCC.js that change when content changes + registry.addResourceHandler("/assets/**") + .addResourceLocations("classpath:/static/assets/") + .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()); + + // Don't cache index.html - it needs to be fresh to reference latest hashed assets + registry.addResourceHandler("/index.html") + .addResourceLocations("classpath:/static/") + .setCacheControl(CacheControl.noCache().mustRevalidate()); + } + @Override public void addCorsMappings(CorsRegistry registry) { // Check if running in Tauri mode 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 865a95c5cb..9b0483da94 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 43aaecd9d7..95486ff9b8 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 @@ -124,6 +124,9 @@ public class ConfigController { "enableAnalytics", applicationProperties.getSystem().getEnableAnalytics()); configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog()); configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf()); + configData.put( + "enableDesktopInstallSlide", + applicationProperties.getSystem().getEnableDesktopInstallSlide()); // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); @@ -227,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/api/security/CertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index 3b27492a6e..893c9c54c8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -191,6 +191,12 @@ public class CertSignController { switch (certType) { case "PEM": + privateKeyFile = + validateFilePresent( + privateKeyFile, "PEM private key", "private key file is required"); + certFile = + validateFilePresent( + certFile, "PEM certificate", "certificate file is required"); ks = KeyStore.getInstance("JKS"); ks.load(null); PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password); @@ -200,10 +206,16 @@ public class CertSignController { break; case "PKCS12": case "PFX": + p12File = + validateFilePresent( + p12File, "PKCS12 keystore", "PKCS12/PFX keystore file is required"); ks = KeyStore.getInstance("PKCS12"); ks.load(p12File.getInputStream(), password.toCharArray()); break; case "JKS": + jksfile = + validateFilePresent( + jksfile, "JKS keystore", "JKS keystore file is required"); ks = KeyStore.getInstance("JKS"); ks.load(jksfile.getInputStream(), password.toCharArray()); break; @@ -251,6 +263,17 @@ public class CertSignController { GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_signed.pdf")); } + private MultipartFile validateFilePresent( + MultipartFile file, String argumentName, String errorDescription) { + if (file == null || file.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidArgument", + "Invalid argument: {0}", + argumentName + " - " + errorDescription); + } + return file; + } + private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password) throws IOException, OperatorCreationException, PKCSException { try (PEMParser pemParser = 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 6373e07520..ab8f3b75b6 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 bf96dd2171..d6e5c7021d 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 b5d10126b4..ad1ee4c8aa 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -126,6 +126,7 @@ system: customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored. enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch + enableDesktopInstallSlide: true # Set to 'false' to hide the desktop app installation slide in the onboarding flow enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally @@ -206,6 +207,7 @@ processExecutor: weasyPrintSessionLimit: 16 installAppSessionLimit: 1 calibreSessionLimit: 1 + imageMagickSessionLimit: 4 ghostscriptSessionLimit: 8 ocrMyPdfSessionLimit: 2 timeoutMinutes: # Process executor timeout in minutes @@ -215,6 +217,7 @@ processExecutor: weasyPrinttimeoutMinutes: 30 installApptimeoutMinutes: 60 calibretimeoutMinutes: 30 + imageMagickTimeoutMinutes: 30 tesseractTimeoutMinutes: 30 qpdfTimeoutMinutes: 30 ghostscriptTimeoutMinutes: 30 diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java index c9f02cd28d..5a5eff1f12 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java @@ -1,9 +1,10 @@ package stirling.software.SPDF.controller.api.security; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; import java.io.ByteArrayOutputStream; import java.io.InputStream; @@ -107,7 +108,8 @@ class CertSignControllerTest { derCertBytes = baos.toByteArray(); } - when(pdfDocumentFactory.load(any(MultipartFile.class))) + lenient() + .when(pdfDocumentFactory.load(any(MultipartFile.class))) .thenAnswer( invocation -> { MultipartFile file = invocation.getArgument(0); @@ -167,6 +169,31 @@ class CertSignControllerTest { assertTrue(response.getBody().length > 0); } + @Test + void testSignPdfWithMissingPkcs12FileThrowsError() { + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + + SignPDFWithCertRequest request = new SignPDFWithCertRequest(); + request.setFileInput(pdfFile); + request.setCertType("PFX"); + request.setPassword("password"); + request.setShowSignature(false); + request.setReason("test"); + request.setLocation("test"); + request.setName("tester"); + request.setPageNumber(1); + request.setShowLogo(false); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> certSignController.signPDFWithCert(request)); + + assertTrue(exception.getMessage().contains("PKCS12 keystore")); + } + @Test void testSignPdfWithJks() throws Exception { MockMultipartFile pdfFile = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java index a073c21371..53b3e1f388 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java @@ -22,7 +22,6 @@ import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.annotations.api.UserApi; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.proprietary.model.api.signature.SavedSignatureRequest; import stirling.software.proprietary.model.api.signature.SavedSignatureResponse; @@ -34,7 +33,6 @@ import stirling.software.proprietary.service.SignatureService; * authentication and enforces per-user storage limits. All endpoints require authentication * via @PreAuthorize("isAuthenticated()"). */ -@UserApi @Slf4j @RestController @RequestMapping("/api/v1/proprietary/signatures") 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 0000000000..8ca6a83e7c --- /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/build.gradle b/build.gradle index 64908ee4ac..90fde88e59 100644 --- a/build.gradle +++ b/build.gradle @@ -12,6 +12,8 @@ plugins { } import com.github.jk1.license.render.* +import groovy.json.JsonOutput +import groovy.json.JsonSlurper ext { springBootVersion = "3.5.6" @@ -57,7 +59,7 @@ repositories { allprojects { group = 'stirling.software' - version = '2.1.2' + version = '2.1.3' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' @@ -65,6 +67,51 @@ allprojects { } } +def writeIfChanged(File targetFile, String newContent) { + if (targetFile.getText('UTF-8') != newContent) { + targetFile.write(newContent, 'UTF-8') + } +} + +def updateTauriConfigVersion(String version) { + File tauriConfig = file('frontend/src-tauri/tauri.conf.json') + def parsed = new JsonSlurper().parse(tauriConfig) + parsed.version = version + + def formatted = JsonOutput.prettyPrint(JsonOutput.toJson(parsed)) + System.lineSeparator() + writeIfChanged(tauriConfig, formatted) +} + +def updateSimulationVersion(File fileToUpdate, String version) { + def content = fileToUpdate.getText('UTF-8') + def matcher = content =~ /(appVersion:\s*')([^']*)(')/ + + if (!matcher.find()) { + throw new GradleException("Could not locate appVersion in ${fileToUpdate} for synchronization") + } + + def updatedContent = matcher.replaceFirst("${matcher.group(1)}${version}${matcher.group(3)}") + writeIfChanged(fileToUpdate, updatedContent) +} + +tasks.register('syncAppVersion') { + group = 'versioning' + description = 'Synchronizes app version across desktop and simulation configs.' + + doLast { + def appVersion = project.version.toString() + println "Synchronizing application version to ${appVersion}" + updateTauriConfigVersion(appVersion) + + [ + 'frontend/src/core/testing/serverExperienceSimulations.ts', + 'frontend/src/proprietary/testing/serverExperienceSimulations.ts' + ].each { path -> + updateSimulationVersion(file(path), appVersion) + } + } +} + tasks.register('writeVersion', WriteProperties) { destinationFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties') println "Writing version.properties to ${destinationFile.get().asFile.path}" @@ -314,7 +361,7 @@ tasks.named('bootRun') { tasks.named('build') { group = 'build' description = 'Delegates to :stirling-pdf:bootJar' - dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper' + dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper', 'syncAppVersion' doFirst { println "Delegating to :stirling-pdf:bootJar" diff --git a/docker/Dockerfile.unified b/docker/Dockerfile.unified index 0ba7cfb3c2..2968f569ce 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 f2421fa943..b946e1e619 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 c54a162dae..78d395d9de 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 a38aee9b4a..6b189f3108 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 67e648aee0..462daa901b 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/package-lock.json b/frontend/package-lock.json index 8d90d96588..f788474bbb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -105,6 +105,7 @@ "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", + "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" } @@ -11093,6 +11094,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -14503,6 +14517,25 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-static-copy": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz", + "integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "p-map": "^7.0.3", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, "node_modules/vite-tsconfig-paths": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5489f6b46e..914b7bb1f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -152,6 +152,7 @@ "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", + "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" }, diff --git a/frontend/public/locales/de-DE/translation.toml b/frontend/public/locales/de-DE/translation.toml index 274de764ea..d48c424f42 100644 --- a/frontend/public/locales/de-DE/translation.toml +++ b/frontend/public/locales/de-DE/translation.toml @@ -6131,8 +6131,8 @@ tags = "text,anmerkung,beschriftung" applySignatures = "Text anwenden" [addText.text] -name = "Textinhalt" -placeholder = "Geben Sie den hinzuzufügenden Text ein" +name = "Text" +placeholder = "Text eingeben" fontLabel = "Schriftart" fontSizeLabel = "Schriftgröße" fontSizePlaceholder = "Schriftgröße eingeben oder wählen (8-200)" diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 9d9278922e..7743ca9b1e 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -312,10 +312,10 @@ yamlAdvert = "Stirling PDF Pro supports YAML configuration files and other SSO f ssoAdvert = "Looking for more user management features? Check out Stirling PDF Pro" [analytics] -title = "Do you want make Stirling PDF better?" -paragraph1 = "Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents." +title = "Do you want to help make Stirling PDF better?" +paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents." paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better." -learnMore = "Learn more" +learnMore = "Learn more about our analytics" enable = "Enable analytics" disable = "Disable analytics" settings = "You can change the settings for analytics in the config/settings.yml file" @@ -340,6 +340,10 @@ advance = "Advanced" edit = "View & Edit" popular = "Popular" +[footer] +discord = "Discord" +issues = "GitHub" + [settings.preferences] title = "Preferences" @@ -435,6 +439,24 @@ latestVersion = "Latest Version" checkForUpdates = "Check for Updates" viewDetails = "View Details" +[settings.security] +title = "Security" +description = "Update your password to keep your account secure." + +[settings.security.password] +subtitle = "Change your password. You will be logged out after updating." +required = "All fields are required." +mismatch = "New passwords do not match." +error = "Unable to update password. Please verify your current password and try again." +success = "Password updated successfully. Please sign in again." +current = "Current password" +currentPlaceholder = "Enter your current password" +new = "New password" +newPlaceholder = "Enter a new password" +confirm = "Confirm new password" +confirmPlaceholder = "Re-enter your new password" +update = "Update password" + [settings.hotkeys] title = "Keyboard Shortcuts" description = "Customize keyboard shortcuts for quick tool access. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel." @@ -493,6 +515,10 @@ oldPassword = "Current Password" newPassword = "New Password" confirmNewPassword = "Confirm New Password" submit = "Submit Changes" +credsUpdated = "Account updated" +description = "Changes saved. Please log in again." +error = "Unable to update username. Please verify your password and try again." +changeUsername = "Update your username. You will be logged out after updating." [account] title = "Account Settings" @@ -3703,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" @@ -3720,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." @@ -4038,12 +4078,20 @@ settings = "Settings" adminSettings = "Admin Settings" allTools = "Tools" reader = "Reader" +tours = "Tours" +showMeAround = "Show me around" + +[quickAccess.toursTooltip] +admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour." +user = "Watch walkthroughs here: Tools tour and the New V2 layout tour." [quickAccess.helpMenu] toolsTour = "Tools Tour" toolsTourDesc = "Learn what the tools can do" adminTour = "Admin Tour" adminTourDesc = "Explore admin settings & features" +whatsNewTour = "See what's new in V2" +whatsNewTourDesc = "Tour the updated layout" [admin] error = "Error" @@ -5070,6 +5118,7 @@ loading = "Loading..." back = "Back" continue = "Continue" error = "Error" +save = "Save" [config.overview] title = "Application Configuration" @@ -5236,6 +5285,16 @@ finish = "Finish" startTour = "Start Tour" startTourDescription = "Take a guided tour of Stirling PDF's key features" +[onboarding.whatsNew] +quickAccess = "Start at the Quick Access rail to jump between Reader, Automate, your files, and all the tours." +leftPanel = "The left Tools panel lists everything you can do. Browse categories or search to find a tool quickly." +fileUpload = "Use the Files button to upload or pick a recent PDF. We will load a sample so you can see the workspace." +rightRail = "The Right Rail holds quick actions to select files, change theme or language, and download results." +topBar = "The top bar lets you swap between Viewer, Page Editor, and Active Files." +pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages." +activeFilesView = "Use Active Files to see everything you have open and pick what to work on." +wrapUp = "That is what is new in V2. Open the Tours menu anytime to replay this, the Tools tour, or the Admin tour." + [onboarding.welcomeModal] title = "Welcome to Stirling PDF!" description = "Would you like to take a quick 1-minute tour to learn the key features and how to get started?" @@ -5256,6 +5315,10 @@ download = "Download →" showMeAround = "Show me around" skipTheTour = "Skip the tour" +[onboarding.tourOverview] +title = "Tour Overview" +body = "Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need." + [onboarding.serverLicense] skip = "Skip for now" seePlans = "See Plans →" @@ -5569,6 +5632,28 @@ contactSales = "Contact Sales" contactToUpgrade = "Contact us to upgrade or customize your plan" maxUsers = "Max Users" upTo = "Up to" +getLicense = "Get Server License" +upgradeToEnterprise = "Upgrade to Enterprise" +selectPeriod = "Select Billing Period" +monthlyBilling = "Monthly Billing" +yearlyBilling = "Yearly Billing" +checkoutOpened = "Checkout Opened" +checkoutInstructions = "Complete your purchase in the Stripe tab. After payment, return here and refresh the page to activate your license. You will also receive an email with your license key." +activateLicense = "Activate Your License" + +[plan.static.licenseActivation] +checkoutOpened = "Checkout Opened in New Tab" +instructions = "Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key." +enterKey = "Enter your license key below to activate your plan:" +keyDescription = "Paste the license key from your email" +activate = "Activate License" +doLater = "I'll do this later" +success = "License Activated!" +successMessage = "Your license has been successfully activated. You can now close this window." + +[plan.static.billingPortal] +title = "Email Verification Required" +message = "You will need to verify your email address in the Stripe billing portal. Check your email for a login link." [plan.period] month = "month" @@ -5772,6 +5857,8 @@ notAvailable = "Audit system not available" notAvailableMessage = "The audit system is not configured or not available." disabled = "Audit logging is disabled" disabledMessage = "Enable audit logging in your application configuration to track system events." +enterpriseRequired = "Enterprise License Required" +enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics." [audit.error] title = "Error loading audit system" diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index cb8faf04bb..a08587cc17 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -152,15 +152,27 @@ pub fn run() { } #[cfg(target_os = "macos")] RunEvent::Opened { urls } => { + use urlencoding::decode; + add_log(format!("📂 Tauri file opened event: {:?}", urls)); let mut added_files = false; for url in urls { let url_str = url.as_str(); if url_str.starts_with("file://") { - let file_path = url_str.strip_prefix("file://").unwrap_or(url_str); + let encoded_path = url_str.strip_prefix("file://").unwrap_or(url_str); + + // Decode URL-encoded characters (%20 -> space, etc.) + let file_path = match decode(encoded_path) { + Ok(decoded) => decoded.into_owned(), + Err(e) => { + add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded_path, e)); + encoded_path.to_string() // Fallback to encoded path + } + }; + add_log(format!("📂 Processing opened file: {}", file_path)); - add_opened_file(file_path.to_string()); + add_opened_file(file_path); added_files = true; } } diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index a7c3355d89..b10fec0e54 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/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index c93a89be00..f3fecba7e6 100644 --- a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from 'react'; import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; interface TextInputWithFontProps { @@ -13,8 +12,12 @@ interface TextInputWithFontProps { textColor?: string; onTextColorChange?: (color: string) => void; disabled?: boolean; - label?: string; - placeholder?: string; + label: string; + placeholder: string; + fontLabel: string; + fontSizeLabel: string; + fontSizePlaceholder: string; + colorLabel?: string; onAnyChange?: () => void; } @@ -30,9 +33,12 @@ export const TextInputWithFont: React.FC = ({ disabled = false, label, placeholder, + fontLabel, + fontSizeLabel, + fontSizePlaceholder, + colorLabel, onAnyChange }) => { - const { t } = useTranslation(); const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString()); const fontSizeCombobox = useCombobox(); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); @@ -66,8 +72,8 @@ export const TextInputWithFont: React.FC = ({ return ( { onTextChange(e.target.value); @@ -79,7 +85,7 @@ export const TextInputWithFont: React.FC = ({ {/* Font Selection */}