From 2848ccd12e470a7eaed1fea37d4a076a33105529 Mon Sep 17 00:00:00 2001 From: Ludy Date: Fri, 14 Mar 2025 19:18:40 +0100 Subject: [PATCH 1/3] Update springdoc plugin to 1.9.0 & Improve SwaggerHub Configuration and Gradle Setup (#3175) eneration # Description of Changes Please provide a summary of the changes, including: - Refactored `SWAGGERHUB_USER` to use an environment variable instead of a hardcoded value, increasing flexibility. - Updated `.gitignore` to exclude `SwaggerDoc.json`, `node_modules/`, and `.mjs` files. - Upgraded `org.springdoc.openapi-gradle-plugin` from `1.8.0` to `1.9.0` for better compatibility. - Adjusted `openApi` configuration to increase `waitTimeInSeconds` to `60` for improved Swagger doc generation stability. - Ensured `swaggerhubUpload` task dynamically references `SWAGGERHUB_USER` from environment variables. - Improved `generateOpenApiDocs` task to disable state tracking, avoiding unnecessary rebuilds. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/swagger.yml | 4 +++- .gitignore | 6 ++++++ build.gradle | 24 ++++++++++++------------ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 95e2529e9..97ad11efc 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -35,6 +35,7 @@ jobs: run: ./gradlew swaggerhubUpload env: SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }} + SWAGGERHUB_USER: "Frooodle" - name: Get version number id: versionNumber @@ -42,6 +43,7 @@ jobs: - name: Set API version as published and default on SwaggerHub run: | - curl -X PUT -H "Authorization: ${SWAGGERHUB_API_KEY}" "https://api.swaggerhub.com/apis/Frooodle/Stirling-PDF/${{ steps.versionNumber.outputs.versionNumber }}/settings/lifecycle" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"published\":true,\"default\":true}" + curl -X PUT -H "Authorization: ${SWAGGERHUB_API_KEY}" "https://api.swaggerhub.com/apis/${SWAGGERHUB_USER}/Stirling-PDF/${{ steps.versionNumber.outputs.versionNumber }}/settings/lifecycle" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"published\":true,\"default\":true}" env: SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }} + SWAGGERHUB_USER: "Frooodle" diff --git a/.gitignore b/.gitignore index e5d8ad209..90d48ccea 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ clientWebUI/ !cucumber/exampleFiles/example_html.zip exampleYmlFiles/stirling/ /testing/file_snapshots +SwaggerDoc.json + # Gradle .gradle .lock @@ -188,3 +190,7 @@ id_ed25519.pub .ipynb_checkpoints **/jcef-bundle/ + +# node_modules +node_modules/ +*.mjs diff --git a/build.gradle b/build.gradle index bbd66361c..e225613db 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id "java" id "org.springframework.boot" version "3.4.3" id "io.spring.dependency-management" version "1.1.7" - id "org.springdoc.openapi-gradle-plugin" version "1.8.0" + id "org.springdoc.openapi-gradle-plugin" version "1.9.0" id "io.swagger.swaggerhub" version "1.3.2" id "edu.sc.seis.launch4j" version "3.0.6" id "com.diffplug.spotless" version "7.0.2" @@ -98,6 +98,7 @@ openApi { apiDocsUrl = "http://localhost:8080/v1/api-docs" outputDir = file("$projectDir") outputFileName = "SwaggerDoc.json" + waitTimeInSeconds = 60 // Increase the wait time to 60 seconds } //0.11.5 to 2024.11.5 @@ -284,6 +285,7 @@ sonar { // } tasks.wrapper { gradleVersion = "8.12" + distributionType = Wrapper.DistributionType.ALL } //tasks.withType(JavaCompile) { // options.compilerArgs << "-Xlint:deprecation" @@ -384,19 +386,13 @@ dependencies { //general PDF // https://mvnrepository.com/artifact/com.opencsv/opencsv - implementation ("com.opencsv:opencsv:5.10") { - exclude group: "commons-logging", module: "commons-logging" - } + implementation ("com.opencsv:opencsv:5.10") - implementation ("org.apache.pdfbox:pdfbox:$pdfboxVersion") { - exclude group: "commons-logging", module: "commons-logging" - } + implementation ("org.apache.pdfbox:pdfbox:$pdfboxVersion") implementation "org.apache.pdfbox:preflight:$pdfboxVersion" - implementation ("org.apache.pdfbox:xmpbox:$pdfboxVersion") { - exclude group: "commons-logging", module: "commons-logging" - } + implementation ("org.apache.pdfbox:xmpbox:$pdfboxVersion") // https://mvnrepository.com/artifact/technology.tabula/tabula implementation ('technology.tabula:tabula:1.0.5') { @@ -446,9 +442,9 @@ task writeVersion { swaggerhubUpload { // dependsOn = generateOpenApiDocs // Depends on your task generating Swagger docs api = "Stirling-PDF" // The name of your API on SwaggerHub - owner = "Frooodle" // Your SwaggerHub username (or organization name) + owner = "${System.getenv().getOrDefault('SWAGGERHUB_USER', 'Frooodle')}" // Your SwaggerHub username (or organization name) version = project.version // The version of your API - inputFile = "./SwaggerDoc.json" // The path to your Swagger docs + inputFile = file("SwaggerDoc.json") // The path to your Swagger docs token = "${System.getenv("SWAGGERHUB_API_KEY")}" // Your SwaggerHub API key, passed as an environment variable oas = "3.0.0" // The version of the OpenAPI Specification you"re using } @@ -476,3 +472,7 @@ task printMacVersion { println getMacVersion(project.version.toString()) } } + +tasks.named('generateOpenApiDocs') { + doNotTrackState("Tracking state is not supported for this task") +} From c7a8b9f0114de07dad678e08d7e79530d232c8a9 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 14 Mar 2025 21:00:06 +0000 Subject: [PATCH 2/3] Further compression fixes (#3177) # Description of Changes Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: a --- build.gradle | 2 +- .../api/misc/CompressController.java | 700 ++++++++++-------- .../service/CustomPDFDocumentFactory.java | 16 + .../resources/templates/fragments/common.html | 4 + .../api/RotationControllerTest.java | 19 +- 5 files changed, 427 insertions(+), 314 deletions(-) diff --git a/build.gradle b/build.gradle index e225613db..f883c55ea 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ ext { } group = "stirling.software" -version = "0.44.1" +version = "0.44.2" java { // 17 is lowest but we support and recommend 21 diff --git a/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java b/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java index 167e1cb1d..7d1985cec 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java @@ -3,13 +3,19 @@ package stirling.software.SPDF.controller.api.misc; import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; -import java.util.HashSet; +import java.util.Arrays; +import java.util.HashMap; import java.util.Iterator; import java.util.List; -import java.util.Set; +import java.util.Map; +import java.util.Map.Entry; import javax.imageio.IIOImage; import javax.imageio.ImageIO; @@ -36,11 +42,15 @@ import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.misc.OptimizePdfRequest; import stirling.software.SPDF.service.CustomPDFDocumentFactory; import stirling.software.SPDF.utils.GeneralUtils; +import stirling.software.SPDF.utils.ImageProcessingUtils; import stirling.software.SPDF.utils.ProcessExecutor; import stirling.software.SPDF.utils.ProcessExecutor.ProcessExecutorResult; import stirling.software.SPDF.utils.WebResponseUtils; @@ -58,303 +68,366 @@ public class CompressController { this.pdfDocumentFactory = pdfDocumentFactory; } - private void compressImagesInPDF(Path pdfFile, double scaleFactor, float jpegQuality) + @Data + @AllArgsConstructor + @NoArgsConstructor + private static class ImageReference { + int pageNum; // Page number where the image appears + COSName name; // The name used to reference this image + } + + public Path compressImagesInPDF( + Path pdfFile, double scaleFactor, float jpegQuality, boolean convertToGrayscale) throws Exception { - byte[] fileBytes = Files.readAllBytes(pdfFile); - long originalFileSize = fileBytes.length; + Path newCompressedPDF = Files.createTempFile("compressedPDF", ".pdf"); + long originalFileSize = Files.size(pdfFile); log.info( - "Starting image compression with scale factor: {} and JPEG quality: {} on file" - + " size: {}", + "Starting image compression with scale factor: {}, JPEG quality: {}, grayscale: {} on file size: {}", scaleFactor, jpegQuality, + convertToGrayscale, GeneralUtils.formatBytes(originalFileSize)); - // Track processed images to avoid recompression - Set processedImages = new HashSet<>(); + try (PDDocument doc = pdfDocumentFactory.load(pdfFile)) { + + // Collect all unique images by content hash + Map> uniqueImages = new HashMap<>(); + Map compressedVersions = new HashMap<>(); - try (PDDocument doc = pdfDocumentFactory.load(fileBytes)) { int totalImages = 0; + + for (int pageNum = 0; pageNum < doc.getNumberOfPages(); pageNum++) { + PDPage page = doc.getPage(pageNum); + PDResources res = page.getResources(); + if (res == null || res.getXObjectNames() == null) continue; + + for (COSName name : res.getXObjectNames()) { + PDXObject xobj = res.getXObject(name); + if (!(xobj instanceof PDImageXObject)) continue; + + totalImages++; + PDImageXObject image = (PDImageXObject) xobj; + String imageHash = generateImageHash(image); + + // Store only page number and name reference + ImageReference ref = new ImageReference(); + ref.pageNum = pageNum; + ref.name = name; + + uniqueImages.computeIfAbsent(imageHash, k -> new ArrayList<>()).add(ref); + } + } + + int uniqueImagesCount = uniqueImages.size(); + int duplicatedImages = totalImages - uniqueImagesCount; + log.info( + "Found {} unique images and {} duplicated instances across {} pages", + uniqueImagesCount, + duplicatedImages, + doc.getNumberOfPages()); + + // SECOND PASS: Process each unique image exactly once int compressedImages = 0; int skippedImages = 0; long totalOriginalBytes = 0; long totalCompressedBytes = 0; - // Minimum dimensions to preserve reasonable quality - int MIN_WIDTH = 400; // Higher minimum - int MIN_HEIGHT = 400; // Higher minimum + for (Entry> entry : uniqueImages.entrySet()) { + String imageHash = entry.getKey(); + List references = entry.getValue(); - log.info("PDF has {} pages", doc.getNumberOfPages()); + if (references.isEmpty()) continue; - for (int pageNum = 0; pageNum < doc.getNumberOfPages(); pageNum++) { - PDPage page = doc.getPage(pageNum); - PDResources res = page.getResources(); + // Get the first instance of this image + ImageReference firstRef = references.get(0); + PDPage firstPage = doc.getPage(firstRef.pageNum); + PDResources firstPageResources = firstPage.getResources(); + PDImageXObject originalImage = + (PDImageXObject) firstPageResources.getXObject(firstRef.name); - if (res == null || res.getXObjectNames() == null) { - continue; - } + // Track original size + int originalSize = (int) originalImage.getCOSObject().getLength(); + totalOriginalBytes += originalSize; - int pageImages = 0; + // Process this unique image once + BufferedImage processedImage = + processAndCompressImage( + originalImage, scaleFactor, jpegQuality, convertToGrayscale); - for (COSName name : res.getXObjectNames()) { - String imageName = name.getName(); + if (processedImage != null) { + // Convert to bytes for storage + byte[] compressedData = convertToBytes(processedImage, jpegQuality); - // Skip already processed images - if (processedImages.contains(imageName)) { - skippedImages++; - continue; - } + // Check if compression is beneficial + if (compressedData.length < originalSize || convertToGrayscale) { + // Create a single compressed version + PDImageXObject compressedImage = + PDImageXObject.createFromByteArray( + doc, + compressedData, + originalImage.getCOSObject().toString()); - PDXObject xobj = res.getXObject(name); - if (!(xobj instanceof PDImageXObject)) { - continue; - } + // Store the compressed version only once in our map + compressedVersions.put(imageHash, compressedImage); - totalImages++; - pageImages++; - PDImageXObject image = (PDImageXObject) xobj; - BufferedImage bufferedImage = image.getImage(); - - int originalWidth = bufferedImage.getWidth(); - int originalHeight = bufferedImage.getHeight(); - - log.info( - "Page {}, Image {}: Original dimensions: {}x{}", - pageNum + 1, - imageName, - originalWidth, - originalHeight); - - // Skip if already small enough - if (originalWidth <= MIN_WIDTH || originalHeight <= MIN_HEIGHT) { + // Report compression stats + double reductionPercentage = + 100.0 - ((compressedData.length * 100.0) / originalSize); log.info( - "Page {}, Image {}: Skipping - below minimum dimensions threshold", - pageNum + 1, - imageName); - skippedImages++; - processedImages.add(imageName); - continue; - } + "Image hash {}: Compressed from {} to {} (reduced by {}%)", + imageHash, + GeneralUtils.formatBytes(originalSize), + GeneralUtils.formatBytes(compressedData.length), + String.format("%.1f", reductionPercentage)); - // Adjust scale factor for very large or very small images - double adjustedScaleFactor = scaleFactor; - if (originalWidth > 3000 || originalHeight > 3000) { - // More aggressive for very large images - adjustedScaleFactor = Math.min(scaleFactor, 0.75); - log.info( - "Page {}, Image {}: Very large image, using more aggressive scale:" - + " {}", - pageNum + 1, - imageName, - adjustedScaleFactor); - } else if (originalWidth < 1000 || originalHeight < 1000) { - // More conservative for smaller images - adjustedScaleFactor = Math.max(scaleFactor, 0.9); - log.info( - "Page {}, Image {}: Smaller image, using conservative scale: {}", - pageNum + 1, - imageName, - adjustedScaleFactor); - } + // Replace ALL instances with the compressed version + for (ImageReference ref : references) { + // Get the page and resources when needed + PDPage page = doc.getPage(ref.pageNum); + PDResources resources = page.getResources(); + resources.put(ref.name, compressedImage); - int newWidth = (int) (originalWidth * adjustedScaleFactor); - int newHeight = (int) (originalHeight * adjustedScaleFactor); - - // Ensure minimum dimensions - newWidth = Math.max(newWidth, MIN_WIDTH); - newHeight = Math.max(newHeight, MIN_HEIGHT); - - // Skip if change is negligible - if ((double) newWidth / originalWidth > 0.95 - && (double) newHeight / originalHeight > 0.95) { - log.info( - "Page {}, Image {}: Change too small, skipping compression", - pageNum + 1, - imageName); - skippedImages++; - processedImages.add(imageName); - continue; - } - - log.info( - "Page {}, Image {}: Resizing to {}x{} ({}% of original)", - pageNum + 1, - imageName, - newWidth, - newHeight, - Math.round((newWidth * 100.0) / originalWidth)); - - // Use high quality scaling - BufferedImage scaledImage = - new BufferedImage( - newWidth, - newHeight, - bufferedImage.getColorModel().hasAlpha() - ? BufferedImage.TYPE_INT_ARGB - : BufferedImage.TYPE_INT_RGB); - - Graphics2D g2d = scaledImage.createGraphics(); - g2d.setRenderingHint( - RenderingHints.KEY_INTERPOLATION, - RenderingHints.VALUE_INTERPOLATION_BICUBIC); - g2d.setRenderingHint( - RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); - g2d.setRenderingHint( - RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - g2d.drawImage(bufferedImage, 0, 0, newWidth, newHeight, null); - g2d.dispose(); - - // Choose appropriate format and compression - String format = bufferedImage.getColorModel().hasAlpha() ? "png" : "jpeg"; - - // First get the actual size of the original image by encoding it to the chosen - // format - ByteArrayOutputStream originalImageStream = new ByteArrayOutputStream(); - if ("jpeg".equals(format)) { - // Get the best available JPEG writer (prioritizes TwelveMonkeys if - // available) - Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); - ImageWriter writer = null; - - // Prefer TwelveMonkeys writer if available - while (writers.hasNext()) { - ImageWriter candidate = writers.next(); - if (candidate.getClass().getName().contains("twelvemonkeys")) { - writer = candidate; - break; - } - } - if (writer == null) { - writer = ImageIO.getImageWritersByFormatName("jpeg").next(); + log.info( + "Replaced image on page {} with compressed version", + ref.pageNum + 1); } - JPEGImageWriteParam param = - (JPEGImageWriteParam) writer.getDefaultWriteParam(); - - // Set advanced compression parameters - param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); - param.setCompressionQuality(jpegQuality); - param.setOptimizeHuffmanTables(true); // Better compression - param.setProgressiveMode( - ImageWriteParam.MODE_DEFAULT); // Progressive scanning - - // Write compressed image - try (ImageOutputStream ios = - ImageIO.createImageOutputStream(originalImageStream)) { - writer.setOutput(ios); - writer.write(null, new IIOImage(scaledImage, null, null), param); - } - writer.dispose(); + totalCompressedBytes += compressedData.length * references.size(); + compressedImages++; } else { - ImageIO.write(bufferedImage, format, originalImageStream); - } - int originalEncodedSize = (int) image.getCOSObject().getLength(); - originalImageStream.close(); - - // Now compress the scaled image - ByteArrayOutputStream compressedImageStream = new ByteArrayOutputStream(); - if ("jpeg".equals(format)) { - Iterator writers = ImageIO.getImageWritersByFormatName(format); - if (writers.hasNext()) { - ImageWriter writer = writers.next(); - ImageWriteParam param = writer.getDefaultWriteParam(); - - if (param.canWriteCompressed()) { - param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); - param.setCompressionQuality(jpegQuality); - - ImageOutputStream imageOut = - ImageIO.createImageOutputStream(compressedImageStream); - writer.setOutput(imageOut); - writer.write(null, new IIOImage(scaledImage, null, null), param); - writer.dispose(); - imageOut.close(); - } else { - ImageIO.write(scaledImage, format, compressedImageStream); - } - } else { - ImageIO.write(scaledImage, format, compressedImageStream); - } - } else { - ImageIO.write(scaledImage, format, compressedImageStream); - } - byte[] imageBytes = compressedImageStream.toByteArray(); - compressedImageStream.close(); - - // Format sizes using our utility method - String originalSizeStr = GeneralUtils.formatBytes(originalEncodedSize); - String compressedSizeStr = GeneralUtils.formatBytes(imageBytes.length); - - // Calculate reduction percentage (how much smaller the new file is) - double reductionPercentage = - 100.0 - ((imageBytes.length * 100.0) / originalEncodedSize); - - if (imageBytes.length >= originalEncodedSize) { - log.info( - "Page {}, Image {}: Compressed size {} not smaller than original" - + " {}, skipping replacement", - pageNum + 1, - imageName, - GeneralUtils.formatBytes(imageBytes.length), - GeneralUtils.formatBytes(originalEncodedSize)); - - // Accumulate original size for both counters (no change) - totalOriginalBytes += originalEncodedSize; - totalCompressedBytes += originalEncodedSize; + log.info("Image hash {}: Compression not beneficial, skipping", imageHash); + totalCompressedBytes += originalSize * references.size(); skippedImages++; - processedImages.add(imageName); - continue; } - log.info( - "Page {}, Image {}: Compressed from {} to {} (reduced by {}%)", - pageNum + 1, - imageName, - originalSizeStr, - compressedSizeStr, - String.format("%.1f", reductionPercentage)); - - // Only replace if compressed size is smaller - PDImageXObject compressedImage = - PDImageXObject.createFromByteArray( - doc, imageBytes, image.getCOSObject().toString()); - res.put(name, compressedImage); - - // Update counters with compressed size - totalOriginalBytes += originalEncodedSize; - totalCompressedBytes += imageBytes.length; - compressedImages++; - processedImages.add(imageName); + } else { + log.info("Image hash {}: Not suitable for compression, skipping", imageHash); + totalCompressedBytes += originalSize * references.size(); + skippedImages++; } } - // Log overall image compression statistics + // Log compression statistics double overallImageReduction = totalOriginalBytes > 0 ? 100.0 - ((totalCompressedBytes * 100.0) / totalOriginalBytes) : 0; log.info( - "Image compression summary - Total: {}, Compressed: {}, Skipped: {}", - totalImages, + "Image compression summary - Total unique: {}, Compressed: {}, Skipped: {}, Duplicates: {}", + uniqueImagesCount, compressedImages, - skippedImages); + skippedImages, + duplicatedImages); log.info( "Total original image size: {}, compressed: {} (reduced by {}%)", GeneralUtils.formatBytes(totalOriginalBytes), GeneralUtils.formatBytes(totalCompressedBytes), String.format("%.1f", overallImageReduction)); + // Free memory before saving + compressedVersions.clear(); + uniqueImages.clear(); + // Save the document - log.info("Saving compressed PDF to {}", pdfFile.toString()); - doc.save(pdfFile.toString()); + log.info("Saving compressed PDF to {}", newCompressedPDF.toString()); + doc.save(newCompressedPDF.toString()); // Log overall file size reduction - long compressedFileSize = Files.size(pdfFile); + long compressedFileSize = Files.size(newCompressedPDF); double overallReduction = 100.0 - ((compressedFileSize * 100.0) / originalFileSize); log.info( "Overall PDF compression: {} → {} (reduced by {}%)", GeneralUtils.formatBytes(originalFileSize), GeneralUtils.formatBytes(compressedFileSize), String.format("%.1f", overallReduction)); + return newCompressedPDF; + } + + } + + private BufferedImage convertToGrayscale(BufferedImage image) { + BufferedImage grayImage = + new BufferedImage( + image.getWidth(), image.getHeight(), BufferedImage.TYPE_BYTE_GRAY); + + Graphics2D g = grayImage.createGraphics(); + g.drawImage(image, 0, 0, null); + g.dispose(); + + return grayImage; + } + + /** + * Processes and compresses an image if beneficial. Returns the processed image if compression + * is worthwhile, null otherwise. + */ + private BufferedImage processAndCompressImage( + PDImageXObject image, double scaleFactor, float jpegQuality, boolean convertToGrayscale) + throws IOException { + BufferedImage bufferedImage = image.getImage(); + int originalWidth = bufferedImage.getWidth(); + int originalHeight = bufferedImage.getHeight(); + + // Minimum dimensions to preserve reasonable quality + int MIN_WIDTH = 400; + int MIN_HEIGHT = 400; + + log.info("Original dimensions: {}x{}", originalWidth, originalHeight); + + // Skip if already small enough + if ((originalWidth <= MIN_WIDTH || originalHeight <= MIN_HEIGHT) && !convertToGrayscale) { + log.info("Skipping - below minimum dimensions threshold"); + return null; + } + + // Convert to grayscale first if requested (before resizing for better quality) + if (convertToGrayscale) { + bufferedImage = convertToGrayscale(bufferedImage); + log.info("Converted image to grayscale"); + } + + // Adjust scale factor for very large or very small images + double adjustedScaleFactor = scaleFactor; + if (originalWidth > 3000 || originalHeight > 3000) { + // More aggressive for very large images + adjustedScaleFactor = Math.min(scaleFactor, 0.75); + log.info("Very large image, using more aggressive scale: {}", adjustedScaleFactor); + } else if (originalWidth < 1000 || originalHeight < 1000) { + // More conservative for smaller images + adjustedScaleFactor = Math.max(scaleFactor, 0.9); + log.info("Smaller image, using conservative scale: {}", adjustedScaleFactor); + } + + int newWidth = (int) (originalWidth * adjustedScaleFactor); + int newHeight = (int) (originalHeight * adjustedScaleFactor); + + // Ensure minimum dimensions + newWidth = Math.max(newWidth, MIN_WIDTH); + newHeight = Math.max(newHeight, MIN_HEIGHT); + + // Skip if change is negligible + if ((double) newWidth / originalWidth > 0.95 + && (double) newHeight / originalHeight > 0.95 + && !convertToGrayscale) { + log.info("Change too small, skipping compression"); + return null; + } + + log.info( + "Resizing to {}x{} ({}% of original)", + newWidth, newHeight, Math.round((newWidth * 100.0) / originalWidth)); + + BufferedImage scaledImage; + if (convertToGrayscale) { + // If already grayscale, maintain the grayscale format + scaledImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_BYTE_GRAY); + } else { + // Otherwise use original color model + scaledImage = + new BufferedImage( + newWidth, + newHeight, + bufferedImage.getColorModel().hasAlpha() + ? BufferedImage.TYPE_INT_ARGB + : BufferedImage.TYPE_INT_RGB); + } + Graphics2D g2d = scaledImage.createGraphics(); + g2d.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.drawImage(bufferedImage, 0, 0, newWidth, newHeight, null); + g2d.dispose(); + + return scaledImage; + } + + /** + * Converts a BufferedImage to a byte array with specified JPEG quality. Checks if compression + * is beneficial compared to original. + */ + private byte[] convertToBytes(BufferedImage scaledImage, float jpegQuality) throws IOException { + String format = scaledImage.getColorModel().hasAlpha() ? "png" : "jpeg"; + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + if ("jpeg".equals(format)) { + // Get the best available JPEG writer + Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); + ImageWriter writer = writers.next(); + + JPEGImageWriteParam param = (JPEGImageWriteParam) writer.getDefaultWriteParam(); + + // Set compression parameters + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(jpegQuality); + param.setOptimizeHuffmanTables(true); // Better compression + param.setProgressiveMode(ImageWriteParam.MODE_DEFAULT); // Progressive scanning + + // Write compressed image + try (ImageOutputStream ios = ImageIO.createImageOutputStream(outputStream)) { + writer.setOutput(ios); + writer.write(null, new IIOImage(scaledImage, null, null), param); + } + writer.dispose(); + } else { + ImageIO.write(scaledImage, format, outputStream); + } + + return outputStream.toByteArray(); + } + + /** Modified hash function to consistently identify identical image content */ + private String generateImageHash(PDImageXObject image) { + try { + // Create a stream for the raw stream data + try (InputStream stream = image.getCOSObject().createRawInputStream()) { + // Read up to first 8KB of data for the hash + byte[] buffer = new byte[8192]; + int bytesRead = stream.read(buffer); + if (bytesRead > 0) { + byte[] dataToHash = + bytesRead == buffer.length ? buffer : Arrays.copyOf(buffer, bytesRead); + return bytesToHexString(generatMD5(dataToHash)); + } + return "empty-stream"; + } + } catch (Exception e) { + log.error("Error generating image hash", e); + return "fallback-" + System.identityHashCode(image); + } + } + + private String bytesToHexString(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private byte[] generatMD5(byte[] data) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + return md.digest(data); // Get the MD5 hash of the image bytes + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5 algorithm not available", e); + } + } + + private byte[] generateImageMD5(PDImageXObject image) throws IOException { + return generatMD5(ImageProcessingUtils.getImageData(image.getImage())); + } + + /** Generates a hash string from a byte array */ + private String generateHashFromBytes(byte[] data) { + try { + // Use the existing method to generate MD5 hash + byte[] hash = generatMD5(data); + return bytesToHexString(hash); + } catch (Exception e) { + log.error("Error generating hash from bytes", e); + // Return a unique string as fallback + return "fallback-" + System.identityHashCode(data); } } @@ -392,7 +465,7 @@ public class CompressController { MultipartFile inputFile = request.getFileInput(); Integer optimizeLevel = request.getOptimizeLevel(); String expectedOutputSizeString = request.getExpectedOutputSize(); - + Boolean convertToGrayscale = request.getGrayscale(); if (expectedOutputSizeString == null && optimizeLevel == null) { throw new Exception("Both expected output size and optimize level are not specified"); } @@ -404,48 +477,61 @@ public class CompressController { autoMode = true; } - Path tempInputFile = Files.createTempFile("input_", ".pdf"); - inputFile.transferTo(tempInputFile.toFile()); - - long inputFileSize = Files.size(tempInputFile); - - Path tempOutputFile = null; - byte[] pdfBytes; + // Create initial input file + Path originalFile = Files.createTempFile("input_", ".pdf"); + inputFile.transferTo(originalFile.toFile()); + long inputFileSize = Files.size(originalFile); + + // Start with original as current working file + Path currentFile = originalFile; + + // Keep track of all temporary files for cleanup + List tempFiles = new ArrayList<>(); + tempFiles.add(originalFile); + try { - tempOutputFile = Files.createTempFile("output_", ".pdf"); - if (autoMode) { double sizeReductionRatio = expectedOutputSize / (double) inputFileSize; optimizeLevel = determineOptimizeLevel(sizeReductionRatio); } boolean sizeMet = false; - boolean imageCompressionApplied = false; // Track if we've already compressed images + boolean imageCompressionApplied = false; boolean qpdfCompressionApplied = false; while (!sizeMet && optimizeLevel <= 9) { - // Apply appropriate compression based on level - - // Levels 4-9: Apply image compression - if (optimizeLevel >= 4 && !imageCompressionApplied) { + // Apply image compression for levels 4-9 + if ((optimizeLevel >= 4 || Boolean.TRUE.equals(convertToGrayscale)) + && !imageCompressionApplied) { double scaleFactor = getScaleFactorForLevel(optimizeLevel); float jpegQuality = getJpegQualityForLevel(optimizeLevel); - compressImagesInPDF(tempInputFile, scaleFactor, jpegQuality); - imageCompressionApplied = true; // Mark that we've compressed images + + // Use the returned path from compressImagesInPDF + Path compressedImageFile = compressImagesInPDF( + currentFile, + scaleFactor, + jpegQuality, + Boolean.TRUE.equals(convertToGrayscale)); + + // Add to temp files list and update current file + tempFiles.add(compressedImageFile); + currentFile = compressedImageFile; + imageCompressionApplied = true; } - // All levels (1-9): Apply QPDF compression + // Apply QPDF compression for all levels if (!qpdfCompressionApplied) { - long preQpdfSize = Files.size(tempInputFile); + long preQpdfSize = Files.size(currentFile); log.info("Pre-QPDF file size: {}", GeneralUtils.formatBytes(preQpdfSize)); - // For levels 1-3, map to qpdf compression levels 1-9 - int qpdfCompressionLevel = optimizeLevel; - if (optimizeLevel <= 3) { - qpdfCompressionLevel = optimizeLevel * 3; // Level 1->3, 2->6, 3->9 - } else { - qpdfCompressionLevel = 9; // Max QPDF compression for levels 4-9 - } + // Map optimization levels to QPDF compression levels + int qpdfCompressionLevel = optimizeLevel <= 3 + ? optimizeLevel * 3 // Level 1->3, 2->6, 3->9 + : 9; // Max compression for levels 4-9 + + // Create output file for QPDF + Path qpdfOutputFile = Files.createTempFile("qpdf_output_", ".pdf"); + tempFiles.add(qpdfOutputFile); // Run QPDF optimization List command = new ArrayList<>(); @@ -460,49 +546,50 @@ public class CompressController { command.add("--compression-level=" + qpdfCompressionLevel); command.add("--compress-streams=y"); command.add("--object-streams=generate"); - command.add(tempInputFile.toString()); - command.add(tempOutputFile.toString()); + command.add(currentFile.toString()); + command.add(qpdfOutputFile.toString()); ProcessExecutorResult returnCode = null; try { - returnCode = - ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF) - .runCommandWithOutputHandling(command); + returnCode = ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF) + .runCommandWithOutputHandling(command); qpdfCompressionApplied = true; + + // Update current file to the QPDF output + currentFile = qpdfOutputFile; + + long postQpdfSize = Files.size(currentFile); + double qpdfReduction = 100.0 - ((postQpdfSize * 100.0) / preQpdfSize); + log.info( + "Post-QPDF file size: {} (reduced by {}%)", + GeneralUtils.formatBytes(postQpdfSize), + String.format("%.1f", qpdfReduction)); + } catch (Exception e) { if (returnCode != null && returnCode.getRc() != 3) { throw e; } + // If QPDF fails, keep using the current file + log.warn("QPDF compression failed, continuing with current file"); } - long postQpdfSize = Files.size(tempOutputFile); - double qpdfReduction = 100.0 - ((postQpdfSize * 100.0) / preQpdfSize); - log.info( - "Post-QPDF file size: {} (reduced by {}%)", - GeneralUtils.formatBytes(postQpdfSize), String.format("%.1f", qpdfReduction)); - - } else { - tempOutputFile = tempInputFile; } // Check if file size is within expected size or not auto mode - long outputFileSize = Files.size(tempOutputFile); + long outputFileSize = Files.size(currentFile); if (outputFileSize <= expectedOutputSize || !autoMode) { sizeMet = true; } else { - int newOptimizeLevel = - incrementOptimizeLevel( - optimizeLevel, outputFileSize, expectedOutputSize); + int newOptimizeLevel = incrementOptimizeLevel( + optimizeLevel, outputFileSize, expectedOutputSize); // Check if we can't increase the level further if (newOptimizeLevel == optimizeLevel) { if (autoMode) { - log.info( - "Maximum optimization level reached without meeting target" - + " size."); + log.info("Maximum optimization level reached without meeting target size."); sizeMet = true; } } else { - // Reset image compression if moving to a new level + // Reset flags for next iteration with higher optimization level imageCompressionApplied = false; qpdfCompressionApplied = false; optimizeLevel = newOptimizeLevel; @@ -510,27 +597,30 @@ public class CompressController { } } - // Read the optimized PDF file - pdfBytes = Files.readAllBytes(tempOutputFile); - Path finalFile = tempOutputFile; - // Check if optimized file is larger than the original - if (pdfBytes.length > inputFileSize) { - log.warn( - "Optimized file is larger than the original. Returning the original file" - + " instead."); - finalFile = tempInputFile; + long finalFileSize = Files.size(currentFile); + if (finalFileSize > inputFileSize) { + log.warn("Optimized file is larger than the original. Using the original file instead."); + // Use the stored reference to the original file + currentFile = originalFile; } - String outputFilename = - Filenames.toSimpleFileName(inputFile.getOriginalFilename()) + String outputFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename()) .replaceFirst("[.][^.]+$", "") + "_Optimized.pdf"; + return WebResponseUtils.pdfDocToWebResponse( - pdfDocumentFactory.load(finalFile.toFile()), outputFilename); + pdfDocumentFactory.load(currentFile.toFile()), outputFilename); } finally { - Files.deleteIfExists(tempOutputFile); + // Clean up all temporary files + for (Path tempFile : tempFiles) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) { + log.warn("Failed to delete temporary file: " + tempFile, e); + } + } } } diff --git a/src/main/java/stirling/software/SPDF/service/CustomPDFDocumentFactory.java b/src/main/java/stirling/software/SPDF/service/CustomPDFDocumentFactory.java index 5aa6ee335..354324744 100644 --- a/src/main/java/stirling/software/SPDF/service/CustomPDFDocumentFactory.java +++ b/src/main/java/stirling/software/SPDF/service/CustomPDFDocumentFactory.java @@ -82,6 +82,21 @@ public class CustomPDFDocumentFactory { return loadAdaptively(file, fileSize); } + /** + * Main entry point for loading a PDF document from a Path. Automatically selects the most + * appropriate loading strategy. + */ + public PDDocument load(Path path) throws IOException { + if (path == null) { + throw new IllegalArgumentException("File cannot be null"); + } + + long fileSize = Files.size(path); + log.info("Loading PDF from file, size: {}MB", fileSize / (1024 * 1024)); + + return loadAdaptively(path.toFile(), fileSize); + } + /** Load a PDF from byte array with automatic optimization. */ public PDDocument load(byte[] input) throws IOException { if (input == null) { @@ -246,6 +261,7 @@ public class CustomPDFDocumentFactory { removePassword(doc); } + private PDDocument loadFromFile(File file, long size, StreamCacheCreateFunction cache) throws IOException { return Loader.loadPDF(new DeletingRandomAccessFile(file), "", null, null, cache); diff --git a/src/main/resources/templates/fragments/common.html b/src/main/resources/templates/fragments/common.html index 28c7b0225..1e801d06f 100644 --- a/src/main/resources/templates/fragments/common.html +++ b/src/main/resources/templates/fragments/common.html @@ -34,11 +34,15 @@ + + + + diff --git a/src/test/java/stirling/software/SPDF/controller/api/RotationControllerTest.java b/src/test/java/stirling/software/SPDF/controller/api/RotationControllerTest.java index edd9cada1..ec84b0e4c 100644 --- a/src/test/java/stirling/software/SPDF/controller/api/RotationControllerTest.java +++ b/src/test/java/stirling/software/SPDF/controller/api/RotationControllerTest.java @@ -1,24 +1,27 @@ package stirling.software.SPDF.controller.api; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import java.io.IOException; -import org.apache.pdfbox.pdmodel.PDPageTree; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageTree; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; -import stirling.software.SPDF.service.CustomPDFDocumentFactory; + import stirling.software.SPDF.model.api.general.RotatePDFRequest; +import stirling.software.SPDF.service.CustomPDFDocumentFactory; @ExtendWith(MockitoExtension.class) public class RotationControllerTest { From a93e3698f9cbcd793f168c8014b405533598447e Mon Sep 17 00:00:00 2001 From: daenur Date: Mon, 17 Mar 2025 10:58:25 +0200 Subject: [PATCH 3/3] Fix Ukrainian translation (#3187) # Description of Changes Ukrainian translation has been improved Closes #(issue_number) --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details. --- src/main/resources/messages_uk_UA.properties | 64 ++++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/main/resources/messages_uk_UA.properties b/src/main/resources/messages_uk_UA.properties index dcec7158a..86199c485 100644 --- a/src/main/resources/messages_uk_UA.properties +++ b/src/main/resources/messages_uk_UA.properties @@ -144,7 +144,7 @@ navbar.language=Мови navbar.settings=Налаштування navbar.allTools=Інструменти navbar.multiTool=Мультіінструмент -navbar.search=Search +navbar.search=Пошук navbar.sections.organize=Організувати navbar.sections.convertTo=Конвертувати в PDF navbar.sections.convertFrom=Конвертувати з PDF @@ -362,7 +362,7 @@ PDFToWord.tags=doc,docx,odt,word,перетворення,формат,пере home.PDFToPresentation.title=PDF в презентацію home.PDFToPresentation.desc=Перетворення PDF в формати презентацій (PPT, PPTX та ODP) -PDFToPresentation.tags=слайди,шоу,офіс,майкрософт +PDFToPresentation.tags=слайди,презентація,офіс,майкрософт home.PDFToText.title=PDF в Text/RTF home.PDFToText.desc=Перетворення PDF в текстовий або RTF формат @@ -385,9 +385,9 @@ home.sign.title=Підпис home.sign.desc=Додає підпис до PDF за допомогою малюнка, тексту або зображення sign.tags=авторизувати,ініціали,намальований-підпис,текстовий-підпис,зображення-підпис -home.flatten.title=Згладжування +home.flatten.title=Знеактивування home.flatten.desc=Видалення всіх інтерактивних елементів та форм з PDF -flatten.tags=static,deactivate,non-interactive,streamline +flatten.tags=flatten,статичний,дезактивувати,неінтерактивний, упорядкувати home.repair.title=Ремонт home.repair.desc=Намагається відновити пошкоджений/зламаний PDF @@ -449,20 +449,20 @@ home.sanitizePdf.title=Санітарна обробка home.sanitizePdf.desc=Видалення скриптів та інших елементів з PDF-файлів sanitizePdf.tags=чистка,безпека,безпечні,віддалення загроз -home.URLToPDF.title=URL/сайт у PDF +home.URLToPDF.title=URL/сайт в PDF home.URLToPDF.desc=Конвертує будь-який http(s)URL у PDF URLToPDF.tags=веб-захоплення,збереження сторінки,веб-документ,архів -home.HTMLToPDF.title=HTML у PDF +home.HTMLToPDF.title=HTML в PDF home.HTMLToPDF.desc=Конвертує будь-який HTML-файл або zip-файл у PDF. HTMLToPDF.tags=розмітка,веб-контент,перетворення,конвертація -home.MarkdownToPDF.title=Markdown у PDF +home.MarkdownToPDF.title=Markdown в PDF home.MarkdownToPDF.desc=Конвертує будь-який файл Markdown у PDF MarkdownToPDF.tags=розмітка,веб-контент,перетворення,конвертація -home.PDFToMarkdown.title=PDF у Markdown +home.PDFToMarkdown.title=PDF в Markdown home.PDFToMarkdown.desc=Конвертує будь-який файл PDF у Markdown PDFToMarkdown.tags=розмітка,веб-вміст,трансформація,перетворення,md @@ -493,7 +493,7 @@ home.redact.title=Ручне редагування home.redact.desc=Редагує PDF-файл на основі виділеного тексту, намальованих форм і/або вибраних сторінок redact.tags=редагувати,приховати,затемнити,чорний,маркер,приховано,вручну -home.tableExtraxt.title=PDF у CSV +home.tableExtraxt.title=PDF в CSV home.tableExtraxt.desc=Видобуває таблиці з PDF та перетворює їх у CSV tableExtraxt.tags=csv,видобуток таблиці,вилучення,конвертація @@ -565,7 +565,7 @@ login.locked=Ваш обліковий запис заблоковано. login.signinTitle=Будь ласка, увійдіть login.ssoSignIn=Увійти через єдиний вхід login.oAuth2AutoCreateDisabled=Автоматичне створення користувача OAUTH2 ВИМКНЕНО -login.oAuth2AdminBlockedUser=Registration or logging in of non-registered users is currently blocked. Please contact the administrator. +login.oAuth2AdminBlockedUser=Реєстрація або вхід незареєстрованих користувачів наразі заборонено. Будь ласка, зв'яжіться з адміністратором. login.oauth2RequestNotFound=Запит на авторизація не знайдено login.oauth2InvalidUserInfoResponse=Недійсна відповідь з інформацією користувача login.oauth2invalidRequest=Недійсний запит @@ -645,17 +645,17 @@ getPdfInfo.downloadJson=Завантажити JSON #markdown-to-pdf -MarkdownToPDF.title=Markdown у PDF -MarkdownToPDF.header=Markdown у PDF +MarkdownToPDF.title=Markdown в PDF +MarkdownToPDF.header=Markdown в PDF MarkdownToPDF.submit=Конвертувати MarkdownToPDF.help=Робота в процесі MarkdownToPDF.credit=Використовує WeasyPrint #pdf-to-markdown -PDFToMarkdown.title=PDF To Markdown -PDFToMarkdown.header=PDF To Markdown -PDFToMarkdown.submit=Convert +PDFToMarkdown.title=PDF в Markdown +PDFToMarkdown.header=PDF в Markdown +PDFToMarkdown.submit=Конвертувати #url-to-pdf @@ -781,7 +781,7 @@ pageLayout.submit=Відправити scalePages.title=Відрегулювати масштаб сторінки scalePages.header=Відрегулювати масштаб сторінки scalePages.pageSize=Розмір сторінки документа. -scalePages.keepPageSize=Original Size +scalePages.keepPageSize=Оригінальний розмір scalePages.scaleFactor=Рівень масштабування (обрізки) сторінки. scalePages.submit=Відправити @@ -801,7 +801,7 @@ certSign.showSig=Показати підпис certSign.reason=Причина certSign.location=Місцезнаходження certSign.name=Ім'я -certSign.showLogo=Show Logo +certSign.showLogo=Показати логотип certSign.submit=Підписати PDF @@ -860,8 +860,8 @@ sign.last=Остання сторінка sign.next=Наступна сторінка sign.previous=Попередня сторінка sign.maintainRatio=Переключити збереження пропорцій -sign.undo=Undo -sign.redo=Redo +sign.undo=Скасувати +sign.redo=Повторити #repair repair.title=Ремонт @@ -914,7 +914,7 @@ ocr.submit=Обробка PDF з OCR extractImages.title=Витягнути зображення extractImages.header=Витягнути зображення extractImages.selectText=Виберіть формат зображення для перетворення витягнутих зображень у -extractImages.allowDuplicates=Save duplicate images +extractImages.allowDuplicates=Зберігати дублікати зображень extractImages.submit=Витягнути @@ -922,7 +922,7 @@ extractImages.submit=Витягнути fileToPDF.title=Файл у PDF fileToPDF.header=Конвертувати будь-який файл у PDF fileToPDF.credit=Цей сервіс використовує LibreOffice та Unoconv для перетворення файлів. -fileToPDF.supportedFileTypesInfo=Supported File types +fileToPDF.supportedFileTypesInfo=Підтримувані типи файлів fileToPDF.supportedFileTypes=Підтримувані типи файлів повинні включати нижченаведені, однак повний оновлений список підтримуваних форматів дивіться у документації LibreOffice. fileToPDF.submit=Перетворити у PDF @@ -932,8 +932,8 @@ compress.title=Стиснути compress.header=Стиснути PDF compress.credit=Ця служба використовує qpdf для стиснення/оптимізації PDF. compress.grayscale.label=Застосувати відтінки сірого для стиснення -compress.selectText.1=Compression Settings -compress.selectText.1.1=1-3 PDF compression,
4-6 lite image compression,
7-9 intense image compression Will dramatically reduce image quality +compress.selectText.1=Параметри стиснення +compress.selectText.1.1=1-3 стиснення PDF,
4-6 невелике стиснення зображень,
7-9 посилене стиснення зображень (різко знизить якість зображень) compress.selectText.2=Рівень оптимізації: compress.selectText.4=Автоматичний режим - автоматично налаштовує якість для отримання PDF точного розміру compress.selectText.5=Очікуваний розмір PDF (наприклад, 25 МБ, 10,8 МБ, 25 КБ) @@ -953,7 +953,7 @@ merge.title=Об'єднати merge.header=Об'єднання кількох PDF-файлів (2+) merge.sortByName=Сортування за ім'ям merge.sortByDate=Сортування за датою -merge.removeCertSign=Remove digital signature in the merged file? +merge.removeCertSign=Видалити цифровий підпис у об’єднаному файлі? merge.submit=Об'єднати @@ -971,8 +971,8 @@ pdfOrganiser.mode.6=Розділення на парні та непарні с pdfOrganiser.mode.7=Видалити першу pdfOrganiser.mode.8=Видалити останню pdfOrganiser.mode.9=Видалити першу та останню -pdfOrganiser.mode.10=Odd-Even Merge -pdfOrganiser.mode.11=Duplicate all pages +pdfOrganiser.mode.10=Об'єднання парних-непарних +pdfOrganiser.mode.11=Дублювати всі сторінки pdfOrganiser.placeholder=(наприклад, 1,3,2 або 4-8,2,10-12 або 2n-1) @@ -1074,7 +1074,7 @@ pdfToImage.color=Колір pdfToImage.grey=Відтінки сірого pdfToImage.blackwhite=Чорно-білий (може втратити дані!) pdfToImage.submit=Конвертувати -pdfToImage.info=Python is not installed. Required for WebP conversion. +pdfToImage.info=Python не встановлено. Необхідно для конвертації WebP. pdfToImage.placeholder=(наприклад 1,2,8 або 4,7,12-16 або 2n-1) @@ -1108,12 +1108,12 @@ watermark.selectText.1=Виберіть PDF, щоб додати водяний watermark.selectText.2=Текст водяного знаку: watermark.selectText.3=Розмір шрифту: watermark.selectText.4=Обертання (0-360): -watermark.selectText.5=Width Spacer (проміжок між кожним водяним знаком по горизонталі): -watermark.selectText.6=Height Spacer (проміжок між кожним водяним знаком по вертикалі): +watermark.selectText.5=Горизонтальний інтервал (проміжок між кожним водяним знаком по горизонталі): +watermark.selectText.6=Вертикальний інтервал (проміжок між кожним водяним знаком по вертикалі): watermark.selectText.7=Непрозорість (0% - 100%): watermark.selectText.8=Тип водяного знаку: watermark.selectText.9=Зображення водяного знаку: -watermark.selectText.10=Convert PDF to PDF-Image +watermark.selectText.10=Кевертувати PDF в PDF-Image watermark.submit=Додати водяний знак watermark.type.1=Текст watermark.type.2=Зображення @@ -1275,8 +1275,8 @@ licenses.license=Ліцензія survey.nav=Опитування survey.title=Опитування Stirling-PDF survey.description=Stirling-PDF не має аналітичних засобів для відслідковування, тому ми хочемо почути думку від користувачів, як покращити Stirling-PDF! -survey.changes=Stirling-PDF has changed since the last survey! To find out more please check our blog post here: -survey.changes2=With these changes we are getting paid business support and funding +survey.changes=Stirling-PDF змінився з часу останнього опитування! Щоб дізнатися більше, перегляньте допис у нашому блозі тут: +survey.changes2=Завдяки цим змінам ми отримуємо платну підтримку бізнесу та фінансування survey.please=Будь-ласка, пройдіть опитування! survey.disabled=(Вікно з опитування буде відключено у наступних оновленнях, але буде доступне внизу сторінки) survey.button=Пройти опитування