mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-12 17:52:13 +02:00
Merge remote-tracking branch 'origin/main' into addDefaultLanguages
This commit is contained in:
commit
4194efed90
4
.github/workflows/swagger.yml
vendored
4
.github/workflows/swagger.yml
vendored
@ -35,6 +35,7 @@ jobs:
|
|||||||
run: ./gradlew swaggerhubUpload
|
run: ./gradlew swaggerhubUpload
|
||||||
env:
|
env:
|
||||||
SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
|
SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
|
||||||
|
SWAGGERHUB_USER: "Frooodle"
|
||||||
|
|
||||||
- name: Get version number
|
- name: Get version number
|
||||||
id: versionNumber
|
id: versionNumber
|
||||||
@ -42,6 +43,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Set API version as published and default on SwaggerHub
|
- name: Set API version as published and default on SwaggerHub
|
||||||
run: |
|
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:
|
env:
|
||||||
SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
|
SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
|
||||||
|
SWAGGERHUB_USER: "Frooodle"
|
||||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -28,6 +28,8 @@ clientWebUI/
|
|||||||
testing/file_snapshots
|
testing/file_snapshots
|
||||||
exampleYmlFiles/stirling/
|
exampleYmlFiles/stirling/
|
||||||
/testing/file_snapshots
|
/testing/file_snapshots
|
||||||
|
SwaggerDoc.json
|
||||||
|
|
||||||
# Gradle
|
# Gradle
|
||||||
.gradle
|
.gradle
|
||||||
.lock
|
.lock
|
||||||
@ -189,3 +191,7 @@ id_ed25519.pub
|
|||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
|
||||||
**/jcef-bundle/
|
**/jcef-bundle/
|
||||||
|
|
||||||
|
# node_modules
|
||||||
|
node_modules/
|
||||||
|
*.mjs
|
||||||
|
26
build.gradle
26
build.gradle
@ -2,7 +2,7 @@ plugins {
|
|||||||
id "java"
|
id "java"
|
||||||
id "org.springframework.boot" version "3.4.3"
|
id "org.springframework.boot" version "3.4.3"
|
||||||
id "io.spring.dependency-management" version "1.1.7"
|
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 "io.swagger.swaggerhub" version "1.3.2"
|
||||||
id "edu.sc.seis.launch4j" version "3.0.6"
|
id "edu.sc.seis.launch4j" version "3.0.6"
|
||||||
id "com.diffplug.spotless" version "7.0.2"
|
id "com.diffplug.spotless" version "7.0.2"
|
||||||
@ -25,7 +25,7 @@ ext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "stirling.software"
|
group = "stirling.software"
|
||||||
version = "0.44.1"
|
version = "0.44.2"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
// 17 is lowest but we support and recommend 21
|
// 17 is lowest but we support and recommend 21
|
||||||
@ -98,6 +98,7 @@ openApi {
|
|||||||
apiDocsUrl = "http://localhost:8080/v1/api-docs"
|
apiDocsUrl = "http://localhost:8080/v1/api-docs"
|
||||||
outputDir = file("$projectDir")
|
outputDir = file("$projectDir")
|
||||||
outputFileName = "SwaggerDoc.json"
|
outputFileName = "SwaggerDoc.json"
|
||||||
|
waitTimeInSeconds = 60 // Increase the wait time to 60 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
//0.11.5 to 2024.11.5
|
//0.11.5 to 2024.11.5
|
||||||
@ -284,6 +285,7 @@ sonar {
|
|||||||
// }
|
// }
|
||||||
tasks.wrapper {
|
tasks.wrapper {
|
||||||
gradleVersion = "8.12"
|
gradleVersion = "8.12"
|
||||||
|
distributionType = Wrapper.DistributionType.ALL
|
||||||
}
|
}
|
||||||
//tasks.withType(JavaCompile) {
|
//tasks.withType(JavaCompile) {
|
||||||
// options.compilerArgs << "-Xlint:deprecation"
|
// options.compilerArgs << "-Xlint:deprecation"
|
||||||
@ -384,19 +386,13 @@ dependencies {
|
|||||||
//general PDF
|
//general PDF
|
||||||
|
|
||||||
// https://mvnrepository.com/artifact/com.opencsv/opencsv
|
// https://mvnrepository.com/artifact/com.opencsv/opencsv
|
||||||
implementation ("com.opencsv:opencsv:5.10") {
|
implementation ("com.opencsv:opencsv:5.10")
|
||||||
exclude group: "commons-logging", module: "commons-logging"
|
|
||||||
}
|
|
||||||
|
|
||||||
implementation ("org.apache.pdfbox:pdfbox:$pdfboxVersion") {
|
implementation ("org.apache.pdfbox:pdfbox:$pdfboxVersion")
|
||||||
exclude group: "commons-logging", module: "commons-logging"
|
|
||||||
}
|
|
||||||
implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
|
implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||||
|
|
||||||
|
|
||||||
implementation ("org.apache.pdfbox:xmpbox:$pdfboxVersion") {
|
implementation ("org.apache.pdfbox:xmpbox:$pdfboxVersion")
|
||||||
exclude group: "commons-logging", module: "commons-logging"
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||||
implementation ('technology.tabula:tabula:1.0.5') {
|
implementation ('technology.tabula:tabula:1.0.5') {
|
||||||
@ -446,9 +442,9 @@ task writeVersion {
|
|||||||
swaggerhubUpload {
|
swaggerhubUpload {
|
||||||
// dependsOn = generateOpenApiDocs // Depends on your task generating Swagger docs
|
// dependsOn = generateOpenApiDocs // Depends on your task generating Swagger docs
|
||||||
api = "Stirling-PDF" // The name of your API on SwaggerHub
|
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
|
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
|
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
|
oas = "3.0.0" // The version of the OpenAPI Specification you"re using
|
||||||
}
|
}
|
||||||
@ -476,3 +472,7 @@ task printMacVersion {
|
|||||||
println getMacVersion(project.version.toString())
|
println getMacVersion(project.version.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.named('generateOpenApiDocs') {
|
||||||
|
doNotTrackState("Tracking state is not supported for this task")
|
||||||
|
}
|
||||||
|
@ -4,15 +4,18 @@ import java.awt.*;
|
|||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Map;
|
||||||
|
import java.util.Map.Entry;
|
||||||
|
|
||||||
import javax.imageio.IIOImage;
|
import javax.imageio.IIOImage;
|
||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
@ -39,6 +42,9 @@ import io.github.pixee.security.Filenames;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
|
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
|
||||||
@ -62,92 +68,222 @@ public class CompressController {
|
|||||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
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 {
|
throws Exception {
|
||||||
byte[] fileBytes = Files.readAllBytes(pdfFile);
|
Path newCompressedPDF = Files.createTempFile("compressedPDF", ".pdf");
|
||||||
long originalFileSize = fileBytes.length;
|
long originalFileSize = Files.size(pdfFile);
|
||||||
log.info(
|
log.info(
|
||||||
"Starting image compression with scale factor: {} and JPEG quality: {} on file"
|
"Starting image compression with scale factor: {}, JPEG quality: {}, grayscale: {} on file size: {}",
|
||||||
+ " size: {}",
|
|
||||||
scaleFactor,
|
scaleFactor,
|
||||||
jpegQuality,
|
jpegQuality,
|
||||||
|
convertToGrayscale,
|
||||||
GeneralUtils.formatBytes(originalFileSize));
|
GeneralUtils.formatBytes(originalFileSize));
|
||||||
|
|
||||||
// Track both original and compressed image hashes
|
try (PDDocument doc = pdfDocumentFactory.load(pdfFile)) {
|
||||||
// This prevents both reprocessing the same original image
|
|
||||||
// and further compressing an already compressed image
|
// Collect all unique images by content hash
|
||||||
Set<String> processedImageHashes = new HashSet<>();
|
Map<String, List<ImageReference>> uniqueImages = new HashMap<>();
|
||||||
|
Map<String, PDImageXObject> compressedVersions = new HashMap<>();
|
||||||
|
|
||||||
try (PDDocument doc = pdfDocumentFactory.load(fileBytes)) {
|
|
||||||
int totalImages = 0;
|
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 compressedImages = 0;
|
||||||
int skippedImages = 0;
|
int skippedImages = 0;
|
||||||
long totalOriginalBytes = 0;
|
long totalOriginalBytes = 0;
|
||||||
long totalCompressedBytes = 0;
|
long totalCompressedBytes = 0;
|
||||||
|
|
||||||
|
for (Entry<String, List<ImageReference>> entry : uniqueImages.entrySet()) {
|
||||||
|
String imageHash = entry.getKey();
|
||||||
|
List<ImageReference> references = entry.getValue();
|
||||||
|
|
||||||
|
if (references.isEmpty()) continue;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Track original size
|
||||||
|
int originalSize = (int) originalImage.getCOSObject().getLength();
|
||||||
|
totalOriginalBytes += originalSize;
|
||||||
|
|
||||||
|
// Process this unique image once
|
||||||
|
BufferedImage processedImage =
|
||||||
|
processAndCompressImage(
|
||||||
|
originalImage, scaleFactor, jpegQuality, convertToGrayscale);
|
||||||
|
|
||||||
|
if (processedImage != null) {
|
||||||
|
// Convert to bytes for storage
|
||||||
|
byte[] compressedData = convertToBytes(processedImage, jpegQuality);
|
||||||
|
|
||||||
|
// Check if compression is beneficial
|
||||||
|
if (compressedData.length < originalSize || convertToGrayscale) {
|
||||||
|
// Create a single compressed version
|
||||||
|
PDImageXObject compressedImage =
|
||||||
|
PDImageXObject.createFromByteArray(
|
||||||
|
doc,
|
||||||
|
compressedData,
|
||||||
|
originalImage.getCOSObject().toString());
|
||||||
|
|
||||||
|
// Store the compressed version only once in our map
|
||||||
|
compressedVersions.put(imageHash, compressedImage);
|
||||||
|
|
||||||
|
// Report compression stats
|
||||||
|
double reductionPercentage =
|
||||||
|
100.0 - ((compressedData.length * 100.0) / originalSize);
|
||||||
|
log.info(
|
||||||
|
"Image hash {}: Compressed from {} to {} (reduced by {}%)",
|
||||||
|
imageHash,
|
||||||
|
GeneralUtils.formatBytes(originalSize),
|
||||||
|
GeneralUtils.formatBytes(compressedData.length),
|
||||||
|
String.format("%.1f", reductionPercentage));
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Replaced image on page {} with compressed version",
|
||||||
|
ref.pageNum + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCompressedBytes += compressedData.length * references.size();
|
||||||
|
compressedImages++;
|
||||||
|
} else {
|
||||||
|
log.info("Image hash {}: Compression not beneficial, skipping", imageHash);
|
||||||
|
totalCompressedBytes += originalSize * references.size();
|
||||||
|
skippedImages++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("Image hash {}: Not suitable for compression, skipping", imageHash);
|
||||||
|
totalCompressedBytes += originalSize * references.size();
|
||||||
|
skippedImages++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log compression statistics
|
||||||
|
double overallImageReduction =
|
||||||
|
totalOriginalBytes > 0
|
||||||
|
? 100.0 - ((totalCompressedBytes * 100.0) / totalOriginalBytes)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Image compression summary - Total unique: {}, Compressed: {}, Skipped: {}, Duplicates: {}",
|
||||||
|
uniqueImagesCount,
|
||||||
|
compressedImages,
|
||||||
|
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 {}", newCompressedPDF.toString());
|
||||||
|
doc.save(newCompressedPDF.toString());
|
||||||
|
|
||||||
|
// Log overall file size reduction
|
||||||
|
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
|
// Minimum dimensions to preserve reasonable quality
|
||||||
int MIN_WIDTH = 400;
|
int MIN_WIDTH = 400;
|
||||||
int MIN_HEIGHT = 400;
|
int MIN_HEIGHT = 400;
|
||||||
|
|
||||||
log.info("PDF has {} pages", doc.getNumberOfPages());
|
log.info("Original dimensions: {}x{}", originalWidth, originalHeight);
|
||||||
|
|
||||||
for (int pageNum = 0; pageNum < doc.getNumberOfPages(); pageNum++) {
|
|
||||||
PDPage page = doc.getPage(pageNum);
|
|
||||||
PDResources res = page.getResources();
|
|
||||||
|
|
||||||
if (res == null || res.getXObjectNames() == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
int pageImages = 0;
|
|
||||||
|
|
||||||
for (COSName name : res.getXObjectNames()) {
|
|
||||||
String imageName = name.getName();
|
|
||||||
PDXObject xobj = res.getXObject(name);
|
|
||||||
if (!(xobj instanceof PDImageXObject)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalImages++;
|
|
||||||
pageImages++;
|
|
||||||
PDImageXObject image = (PDImageXObject) xobj;
|
|
||||||
|
|
||||||
// Generate a hash for the image data
|
|
||||||
String imageHash = generateImageHash(image);
|
|
||||||
|
|
||||||
// Skip if this image has already been processed (by hash)
|
|
||||||
if (processedImageHashes.contains(imageHash)) {
|
|
||||||
skippedImages++;
|
|
||||||
log.info(
|
|
||||||
"Page {}, Image {}: Skipping - already processed (hash: {})",
|
|
||||||
pageNum + 1,
|
|
||||||
imageName,
|
|
||||||
imageHash);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// Skip if already small enough
|
||||||
if (originalWidth <= MIN_WIDTH || originalHeight <= MIN_HEIGHT) {
|
if ((originalWidth <= MIN_WIDTH || originalHeight <= MIN_HEIGHT) && !convertToGrayscale) {
|
||||||
log.info(
|
log.info("Skipping - below minimum dimensions threshold");
|
||||||
"Page {}, Image {}: Skipping - below minimum dimensions threshold",
|
return null;
|
||||||
pageNum + 1,
|
}
|
||||||
imageName);
|
|
||||||
skippedImages++;
|
// Convert to grayscale first if requested (before resizing for better quality)
|
||||||
// Add to processed set so we don't try to process it again
|
if (convertToGrayscale) {
|
||||||
processedImageHashes.add(imageHash);
|
bufferedImage = convertToGrayscale(bufferedImage);
|
||||||
continue;
|
log.info("Converted image to grayscale");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust scale factor for very large or very small images
|
// Adjust scale factor for very large or very small images
|
||||||
@ -155,20 +291,11 @@ public class CompressController {
|
|||||||
if (originalWidth > 3000 || originalHeight > 3000) {
|
if (originalWidth > 3000 || originalHeight > 3000) {
|
||||||
// More aggressive for very large images
|
// More aggressive for very large images
|
||||||
adjustedScaleFactor = Math.min(scaleFactor, 0.75);
|
adjustedScaleFactor = Math.min(scaleFactor, 0.75);
|
||||||
log.info(
|
log.info("Very large image, using more aggressive scale: {}", adjustedScaleFactor);
|
||||||
"Page {}, Image {}: Very large image, using more aggressive scale:"
|
|
||||||
+ " {}",
|
|
||||||
pageNum + 1,
|
|
||||||
imageName,
|
|
||||||
adjustedScaleFactor);
|
|
||||||
} else if (originalWidth < 1000 || originalHeight < 1000) {
|
} else if (originalWidth < 1000 || originalHeight < 1000) {
|
||||||
// More conservative for smaller images
|
// More conservative for smaller images
|
||||||
adjustedScaleFactor = Math.max(scaleFactor, 0.9);
|
adjustedScaleFactor = Math.max(scaleFactor, 0.9);
|
||||||
log.info(
|
log.info("Smaller image, using conservative scale: {}", adjustedScaleFactor);
|
||||||
"Page {}, Image {}: Smaller image, using conservative scale: {}",
|
|
||||||
pageNum + 1,
|
|
||||||
imageName,
|
|
||||||
adjustedScaleFactor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int newWidth = (int) (originalWidth * adjustedScaleFactor);
|
int newWidth = (int) (originalWidth * adjustedScaleFactor);
|
||||||
@ -180,210 +307,93 @@ public class CompressController {
|
|||||||
|
|
||||||
// Skip if change is negligible
|
// Skip if change is negligible
|
||||||
if ((double) newWidth / originalWidth > 0.95
|
if ((double) newWidth / originalWidth > 0.95
|
||||||
&& (double) newHeight / originalHeight > 0.95) {
|
&& (double) newHeight / originalHeight > 0.95
|
||||||
log.info(
|
&& !convertToGrayscale) {
|
||||||
"Page {}, Image {}: Change too small, skipping compression",
|
log.info("Change too small, skipping compression");
|
||||||
pageNum + 1,
|
return null;
|
||||||
imageName);
|
|
||||||
skippedImages++;
|
|
||||||
processedImageHashes.add(imageHash);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"Page {}, Image {}: Resizing to {}x{} ({}% of original)",
|
"Resizing to {}x{} ({}% of original)",
|
||||||
pageNum + 1,
|
newWidth, newHeight, Math.round((newWidth * 100.0) / originalWidth));
|
||||||
imageName,
|
|
||||||
newWidth,
|
|
||||||
newHeight,
|
|
||||||
Math.round((newWidth * 100.0) / originalWidth));
|
|
||||||
|
|
||||||
// Use high quality scaling
|
BufferedImage scaledImage;
|
||||||
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(
|
new BufferedImage(
|
||||||
newWidth,
|
newWidth,
|
||||||
newHeight,
|
newHeight,
|
||||||
bufferedImage.getColorModel().hasAlpha()
|
bufferedImage.getColorModel().hasAlpha()
|
||||||
? BufferedImage.TYPE_INT_ARGB
|
? BufferedImage.TYPE_INT_ARGB
|
||||||
: BufferedImage.TYPE_INT_RGB);
|
: BufferedImage.TYPE_INT_RGB);
|
||||||
|
}
|
||||||
Graphics2D g2d = scaledImage.createGraphics();
|
Graphics2D g2d = scaledImage.createGraphics();
|
||||||
g2d.setRenderingHint(
|
g2d.setRenderingHint(
|
||||||
RenderingHints.KEY_INTERPOLATION,
|
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||||
RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||||
g2d.setRenderingHint(
|
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
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.drawImage(bufferedImage, 0, 0, newWidth, newHeight, null);
|
||||||
g2d.dispose();
|
g2d.dispose();
|
||||||
|
|
||||||
// Choose appropriate format and compression
|
return scaledImage;
|
||||||
String format = bufferedImage.getColorModel().hasAlpha() ? "png" : "jpeg";
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
|
||||||
// First get the actual size of the original image by encoding it to the chosen
|
|
||||||
// format
|
|
||||||
ByteArrayOutputStream originalImageStream = new ByteArrayOutputStream();
|
|
||||||
if ("jpeg".equals(format)) {
|
if ("jpeg".equals(format)) {
|
||||||
// Get the best available JPEG writer (prioritizes TwelveMonkeys if
|
// Get the best available JPEG writer
|
||||||
// available)
|
|
||||||
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
|
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
|
||||||
ImageWriter writer = null;
|
ImageWriter writer = writers.next();
|
||||||
|
|
||||||
// Prefer TwelveMonkeys writer if available
|
JPEGImageWriteParam param = (JPEGImageWriteParam) writer.getDefaultWriteParam();
|
||||||
while (writers.hasNext()) {
|
|
||||||
ImageWriter candidate = writers.next();
|
|
||||||
if (candidate.getClass().getName().contains("twelvemonkeys")) {
|
|
||||||
writer = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (writer == null) {
|
|
||||||
writer = ImageIO.getImageWritersByFormatName("jpeg").next();
|
|
||||||
}
|
|
||||||
|
|
||||||
JPEGImageWriteParam param =
|
// Set compression parameters
|
||||||
(JPEGImageWriteParam) writer.getDefaultWriteParam();
|
|
||||||
|
|
||||||
// Set advanced compression parameters
|
|
||||||
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||||
param.setCompressionQuality(jpegQuality);
|
param.setCompressionQuality(jpegQuality);
|
||||||
param.setOptimizeHuffmanTables(true); // Better compression
|
param.setOptimizeHuffmanTables(true); // Better compression
|
||||||
param.setProgressiveMode(
|
param.setProgressiveMode(ImageWriteParam.MODE_DEFAULT); // Progressive scanning
|
||||||
ImageWriteParam.MODE_DEFAULT); // Progressive scanning
|
|
||||||
|
|
||||||
// Write compressed image
|
// Write compressed image
|
||||||
try (ImageOutputStream ios =
|
try (ImageOutputStream ios = ImageIO.createImageOutputStream(outputStream)) {
|
||||||
ImageIO.createImageOutputStream(originalImageStream)) {
|
|
||||||
writer.setOutput(ios);
|
writer.setOutput(ios);
|
||||||
writer.write(null, new IIOImage(scaledImage, null, null), param);
|
writer.write(null, new IIOImage(scaledImage, null, null), param);
|
||||||
}
|
}
|
||||||
writer.dispose();
|
writer.dispose();
|
||||||
} else {
|
} else {
|
||||||
ImageIO.write(bufferedImage, format, originalImageStream);
|
ImageIO.write(scaledImage, format, outputStream);
|
||||||
}
|
|
||||||
int originalEncodedSize = (int) image.getCOSObject().getLength();
|
|
||||||
originalImageStream.close();
|
|
||||||
|
|
||||||
// Now compress the scaled image
|
|
||||||
ByteArrayOutputStream compressedImageStream = new ByteArrayOutputStream();
|
|
||||||
if ("jpeg".equals(format)) {
|
|
||||||
Iterator<ImageWriter> 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;
|
|
||||||
skippedImages++;
|
|
||||||
processedImageHashes.add(imageHash);
|
|
||||||
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++;
|
|
||||||
|
|
||||||
// Add the hash of the original image to the processed set
|
|
||||||
processedImageHashes.add(imageHash);
|
|
||||||
|
|
||||||
// IMPORTANT: Also add the hash of the compressed image
|
|
||||||
// This prevents recompressing an already compressed image if it appears again
|
|
||||||
String compressedImageHash = generateHashFromBytes(imageBytes);
|
|
||||||
processedImageHashes.add(compressedImageHash);
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Page {}, Image {}: Added original hash {} and compressed hash {} to tracking set",
|
|
||||||
pageNum + 1,
|
|
||||||
imageName,
|
|
||||||
imageHash,
|
|
||||||
compressedImageHash);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log overall image compression statistics
|
return outputStream.toByteArray();
|
||||||
double overallImageReduction =
|
}
|
||||||
totalOriginalBytes > 0
|
|
||||||
? 100.0 - ((totalCompressedBytes * 100.0) / totalOriginalBytes)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
log.info(
|
/** Modified hash function to consistently identify identical image content */
|
||||||
"Image compression summary - Total: {}, Compressed: {}, Skipped: {}",
|
private String generateImageHash(PDImageXObject image) {
|
||||||
totalImages,
|
try {
|
||||||
compressedImages,
|
// Create a stream for the raw stream data
|
||||||
skippedImages);
|
try (InputStream stream = image.getCOSObject().createRawInputStream()) {
|
||||||
log.info(
|
// Read up to first 8KB of data for the hash
|
||||||
"Total original image size: {}, compressed: {} (reduced by {}%)",
|
byte[] buffer = new byte[8192];
|
||||||
GeneralUtils.formatBytes(totalOriginalBytes),
|
int bytesRead = stream.read(buffer);
|
||||||
GeneralUtils.formatBytes(totalCompressedBytes),
|
if (bytesRead > 0) {
|
||||||
String.format("%.1f", overallImageReduction));
|
byte[] dataToHash =
|
||||||
|
bytesRead == buffer.length ? buffer : Arrays.copyOf(buffer, bytesRead);
|
||||||
// Save the document
|
return bytesToHexString(generatMD5(dataToHash));
|
||||||
log.info("Saving compressed PDF to {}", pdfFile.toString());
|
}
|
||||||
doc.save(pdfFile.toString());
|
return "empty-stream";
|
||||||
|
}
|
||||||
// Log overall file size reduction
|
} catch (Exception e) {
|
||||||
long compressedFileSize = Files.size(pdfFile);
|
log.error("Error generating image hash", e);
|
||||||
double overallReduction = 100.0 - ((compressedFileSize * 100.0) / originalFileSize);
|
return "fallback-" + System.identityHashCode(image);
|
||||||
log.info(
|
|
||||||
"Overall PDF compression: {} → {} (reduced by {}%)",
|
|
||||||
GeneralUtils.formatBytes(originalFileSize),
|
|
||||||
GeneralUtils.formatBytes(compressedFileSize),
|
|
||||||
String.format("%.1f", overallReduction));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -408,19 +418,6 @@ public class CompressController {
|
|||||||
return generatMD5(ImageProcessingUtils.getImageData(image.getImage()));
|
return generatMD5(ImageProcessingUtils.getImageData(image.getImage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateImageHash(PDImageXObject image) {
|
|
||||||
try {
|
|
||||||
// Use the existing method to generate MD5 hash
|
|
||||||
byte[] hash = generateImageMD5(image);
|
|
||||||
return bytesToHexString(hash);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Error generating image hash", e);
|
|
||||||
// Return a unique string based on object identifier to ensure the image is
|
|
||||||
// still tracked
|
|
||||||
return "fallback-" + System.identityHashCode(image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generates a hash string from a byte array */
|
/** Generates a hash string from a byte array */
|
||||||
private String generateHashFromBytes(byte[] data) {
|
private String generateHashFromBytes(byte[] data) {
|
||||||
try {
|
try {
|
||||||
@ -468,7 +465,7 @@ public class CompressController {
|
|||||||
MultipartFile inputFile = request.getFileInput();
|
MultipartFile inputFile = request.getFileInput();
|
||||||
Integer optimizeLevel = request.getOptimizeLevel();
|
Integer optimizeLevel = request.getOptimizeLevel();
|
||||||
String expectedOutputSizeString = request.getExpectedOutputSize();
|
String expectedOutputSizeString = request.getExpectedOutputSize();
|
||||||
|
Boolean convertToGrayscale = request.getGrayscale();
|
||||||
if (expectedOutputSizeString == null && optimizeLevel == null) {
|
if (expectedOutputSizeString == null && optimizeLevel == null) {
|
||||||
throw new Exception("Both expected output size and optimize level are not specified");
|
throw new Exception("Both expected output size and optimize level are not specified");
|
||||||
}
|
}
|
||||||
@ -480,48 +477,61 @@ public class CompressController {
|
|||||||
autoMode = true;
|
autoMode = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Path tempInputFile = Files.createTempFile("input_", ".pdf");
|
// Create initial input file
|
||||||
inputFile.transferTo(tempInputFile.toFile());
|
Path originalFile = Files.createTempFile("input_", ".pdf");
|
||||||
|
inputFile.transferTo(originalFile.toFile());
|
||||||
|
long inputFileSize = Files.size(originalFile);
|
||||||
|
|
||||||
long inputFileSize = Files.size(tempInputFile);
|
// Start with original as current working file
|
||||||
|
Path currentFile = originalFile;
|
||||||
|
|
||||||
|
// Keep track of all temporary files for cleanup
|
||||||
|
List<Path> tempFiles = new ArrayList<>();
|
||||||
|
tempFiles.add(originalFile);
|
||||||
|
|
||||||
Path tempOutputFile = null;
|
|
||||||
byte[] pdfBytes;
|
|
||||||
try {
|
try {
|
||||||
tempOutputFile = Files.createTempFile("output_", ".pdf");
|
|
||||||
|
|
||||||
if (autoMode) {
|
if (autoMode) {
|
||||||
double sizeReductionRatio = expectedOutputSize / (double) inputFileSize;
|
double sizeReductionRatio = expectedOutputSize / (double) inputFileSize;
|
||||||
optimizeLevel = determineOptimizeLevel(sizeReductionRatio);
|
optimizeLevel = determineOptimizeLevel(sizeReductionRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean sizeMet = false;
|
boolean sizeMet = false;
|
||||||
boolean imageCompressionApplied = false; // Track if we've already compressed images
|
boolean imageCompressionApplied = false;
|
||||||
boolean qpdfCompressionApplied = false;
|
boolean qpdfCompressionApplied = false;
|
||||||
|
|
||||||
while (!sizeMet && optimizeLevel <= 9) {
|
while (!sizeMet && optimizeLevel <= 9) {
|
||||||
// Apply appropriate compression based on level
|
// Apply image compression for levels 4-9
|
||||||
|
if ((optimizeLevel >= 4 || Boolean.TRUE.equals(convertToGrayscale))
|
||||||
// Levels 4-9: Apply image compression
|
&& !imageCompressionApplied) {
|
||||||
if (optimizeLevel >= 4 && !imageCompressionApplied) {
|
|
||||||
double scaleFactor = getScaleFactorForLevel(optimizeLevel);
|
double scaleFactor = getScaleFactorForLevel(optimizeLevel);
|
||||||
float jpegQuality = getJpegQualityForLevel(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) {
|
if (!qpdfCompressionApplied) {
|
||||||
long preQpdfSize = Files.size(tempInputFile);
|
long preQpdfSize = Files.size(currentFile);
|
||||||
log.info("Pre-QPDF file size: {}", GeneralUtils.formatBytes(preQpdfSize));
|
log.info("Pre-QPDF file size: {}", GeneralUtils.formatBytes(preQpdfSize));
|
||||||
|
|
||||||
// For levels 1-3, map to qpdf compression levels 1-9
|
// Map optimization levels to QPDF compression levels
|
||||||
int qpdfCompressionLevel = optimizeLevel;
|
int qpdfCompressionLevel = optimizeLevel <= 3
|
||||||
if (optimizeLevel <= 3) {
|
? optimizeLevel * 3 // Level 1->3, 2->6, 3->9
|
||||||
qpdfCompressionLevel = optimizeLevel * 3; // Level 1->3, 2->6, 3->9
|
: 9; // Max compression for levels 4-9
|
||||||
} else {
|
|
||||||
qpdfCompressionLevel = 9; // Max QPDF compression for levels 4-9
|
// Create output file for QPDF
|
||||||
}
|
Path qpdfOutputFile = Files.createTempFile("qpdf_output_", ".pdf");
|
||||||
|
tempFiles.add(qpdfOutputFile);
|
||||||
|
|
||||||
// Run QPDF optimization
|
// Run QPDF optimization
|
||||||
List<String> command = new ArrayList<>();
|
List<String> command = new ArrayList<>();
|
||||||
@ -536,49 +546,50 @@ public class CompressController {
|
|||||||
command.add("--compression-level=" + qpdfCompressionLevel);
|
command.add("--compression-level=" + qpdfCompressionLevel);
|
||||||
command.add("--compress-streams=y");
|
command.add("--compress-streams=y");
|
||||||
command.add("--object-streams=generate");
|
command.add("--object-streams=generate");
|
||||||
command.add(tempInputFile.toString());
|
command.add(currentFile.toString());
|
||||||
command.add(tempOutputFile.toString());
|
command.add(qpdfOutputFile.toString());
|
||||||
|
|
||||||
ProcessExecutorResult returnCode = null;
|
ProcessExecutorResult returnCode = null;
|
||||||
try {
|
try {
|
||||||
returnCode =
|
returnCode = ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)
|
||||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)
|
|
||||||
.runCommandWithOutputHandling(command);
|
.runCommandWithOutputHandling(command);
|
||||||
qpdfCompressionApplied = true;
|
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) {
|
} catch (Exception e) {
|
||||||
if (returnCode != null && returnCode.getRc() != 3) {
|
if (returnCode != null && returnCode.getRc() != 3) {
|
||||||
throw e;
|
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
|
// 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) {
|
if (outputFileSize <= expectedOutputSize || !autoMode) {
|
||||||
sizeMet = true;
|
sizeMet = true;
|
||||||
} else {
|
} else {
|
||||||
int newOptimizeLevel =
|
int newOptimizeLevel = incrementOptimizeLevel(
|
||||||
incrementOptimizeLevel(
|
|
||||||
optimizeLevel, outputFileSize, expectedOutputSize);
|
optimizeLevel, outputFileSize, expectedOutputSize);
|
||||||
|
|
||||||
// Check if we can't increase the level further
|
// Check if we can't increase the level further
|
||||||
if (newOptimizeLevel == optimizeLevel) {
|
if (newOptimizeLevel == optimizeLevel) {
|
||||||
if (autoMode) {
|
if (autoMode) {
|
||||||
log.info(
|
log.info("Maximum optimization level reached without meeting target size.");
|
||||||
"Maximum optimization level reached without meeting target"
|
|
||||||
+ " size.");
|
|
||||||
sizeMet = true;
|
sizeMet = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Reset image compression if moving to a new level
|
// Reset flags for next iteration with higher optimization level
|
||||||
imageCompressionApplied = false;
|
imageCompressionApplied = false;
|
||||||
qpdfCompressionApplied = false;
|
qpdfCompressionApplied = false;
|
||||||
optimizeLevel = newOptimizeLevel;
|
optimizeLevel = newOptimizeLevel;
|
||||||
@ -586,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
|
// Check if optimized file is larger than the original
|
||||||
if (pdfBytes.length > inputFileSize) {
|
long finalFileSize = Files.size(currentFile);
|
||||||
log.warn(
|
if (finalFileSize > inputFileSize) {
|
||||||
"Optimized file is larger than the original. Returning the original file"
|
log.warn("Optimized file is larger than the original. Using the original file instead.");
|
||||||
+ " instead.");
|
// Use the stored reference to the original file
|
||||||
finalFile = tempInputFile;
|
currentFile = originalFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
String outputFilename =
|
String outputFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename())
|
||||||
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
|
|
||||||
.replaceFirst("[.][^.]+$", "")
|
.replaceFirst("[.][^.]+$", "")
|
||||||
+ "_Optimized.pdf";
|
+ "_Optimized.pdf";
|
||||||
|
|
||||||
return WebResponseUtils.pdfDocToWebResponse(
|
return WebResponseUtils.pdfDocToWebResponse(
|
||||||
pdfDocumentFactory.load(finalFile.toFile()), outputFilename);
|
pdfDocumentFactory.load(currentFile.toFile()), outputFilename);
|
||||||
|
|
||||||
} finally {
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,6 +82,21 @@ public class CustomPDFDocumentFactory {
|
|||||||
return loadAdaptively(file, fileSize);
|
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. */
|
/** Load a PDF from byte array with automatic optimization. */
|
||||||
public PDDocument load(byte[] input) throws IOException {
|
public PDDocument load(byte[] input) throws IOException {
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
@ -246,6 +261,7 @@ public class CustomPDFDocumentFactory {
|
|||||||
removePassword(doc);
|
removePassword(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private PDDocument loadFromFile(File file, long size, StreamCacheCreateFunction cache)
|
private PDDocument loadFromFile(File file, long size, StreamCacheCreateFunction cache)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
return Loader.loadPDF(new DeletingRandomAccessFile(file), "", null, null, cache);
|
return Loader.loadPDF(new DeletingRandomAccessFile(file), "", null, null, cache);
|
||||||
|
@ -144,7 +144,7 @@ navbar.language=Мови
|
|||||||
navbar.settings=Налаштування
|
navbar.settings=Налаштування
|
||||||
navbar.allTools=Інструменти
|
navbar.allTools=Інструменти
|
||||||
navbar.multiTool=Мультіінструмент
|
navbar.multiTool=Мультіінструмент
|
||||||
navbar.search=Search
|
navbar.search=Пошук
|
||||||
navbar.sections.organize=Організувати
|
navbar.sections.organize=Організувати
|
||||||
navbar.sections.convertTo=Конвертувати в PDF
|
navbar.sections.convertTo=Конвертувати в PDF
|
||||||
navbar.sections.convertFrom=Конвертувати з PDF
|
navbar.sections.convertFrom=Конвертувати з PDF
|
||||||
@ -362,7 +362,7 @@ PDFToWord.tags=doc,docx,odt,word,перетворення,формат,пере
|
|||||||
|
|
||||||
home.PDFToPresentation.title=PDF в презентацію
|
home.PDFToPresentation.title=PDF в презентацію
|
||||||
home.PDFToPresentation.desc=Перетворення PDF в формати презентацій (PPT, PPTX та ODP)
|
home.PDFToPresentation.desc=Перетворення PDF в формати презентацій (PPT, PPTX та ODP)
|
||||||
PDFToPresentation.tags=слайди,шоу,офіс,майкрософт
|
PDFToPresentation.tags=слайди,презентація,офіс,майкрософт
|
||||||
|
|
||||||
home.PDFToText.title=PDF в Text/RTF
|
home.PDFToText.title=PDF в Text/RTF
|
||||||
home.PDFToText.desc=Перетворення PDF в текстовий або RTF формат
|
home.PDFToText.desc=Перетворення PDF в текстовий або RTF формат
|
||||||
@ -385,9 +385,9 @@ home.sign.title=Підпис
|
|||||||
home.sign.desc=Додає підпис до PDF за допомогою малюнка, тексту або зображення
|
home.sign.desc=Додає підпис до PDF за допомогою малюнка, тексту або зображення
|
||||||
sign.tags=авторизувати,ініціали,намальований-підпис,текстовий-підпис,зображення-підпис
|
sign.tags=авторизувати,ініціали,намальований-підпис,текстовий-підпис,зображення-підпис
|
||||||
|
|
||||||
home.flatten.title=Згладжування
|
home.flatten.title=Знеактивування
|
||||||
home.flatten.desc=Видалення всіх інтерактивних елементів та форм з PDF
|
home.flatten.desc=Видалення всіх інтерактивних елементів та форм з PDF
|
||||||
flatten.tags=static,deactivate,non-interactive,streamline
|
flatten.tags=flatten,статичний,дезактивувати,неінтерактивний, упорядкувати
|
||||||
|
|
||||||
home.repair.title=Ремонт
|
home.repair.title=Ремонт
|
||||||
home.repair.desc=Намагається відновити пошкоджений/зламаний PDF
|
home.repair.desc=Намагається відновити пошкоджений/зламаний PDF
|
||||||
@ -449,20 +449,20 @@ home.sanitizePdf.title=Санітарна обробка
|
|||||||
home.sanitizePdf.desc=Видалення скриптів та інших елементів з PDF-файлів
|
home.sanitizePdf.desc=Видалення скриптів та інших елементів з PDF-файлів
|
||||||
sanitizePdf.tags=чистка,безпека,безпечні,віддалення загроз
|
sanitizePdf.tags=чистка,безпека,безпечні,віддалення загроз
|
||||||
|
|
||||||
home.URLToPDF.title=URL/сайт у PDF
|
home.URLToPDF.title=URL/сайт в PDF
|
||||||
home.URLToPDF.desc=Конвертує будь-який http(s)URL у PDF
|
home.URLToPDF.desc=Конвертує будь-який http(s)URL у PDF
|
||||||
URLToPDF.tags=веб-захоплення,збереження сторінки,веб-документ,архів
|
URLToPDF.tags=веб-захоплення,збереження сторінки,веб-документ,архів
|
||||||
|
|
||||||
home.HTMLToPDF.title=HTML у PDF
|
home.HTMLToPDF.title=HTML в PDF
|
||||||
home.HTMLToPDF.desc=Конвертує будь-який HTML-файл або zip-файл у PDF.
|
home.HTMLToPDF.desc=Конвертує будь-який HTML-файл або zip-файл у PDF.
|
||||||
HTMLToPDF.tags=розмітка,веб-контент,перетворення,конвертація
|
HTMLToPDF.tags=розмітка,веб-контент,перетворення,конвертація
|
||||||
|
|
||||||
|
|
||||||
home.MarkdownToPDF.title=Markdown у PDF
|
home.MarkdownToPDF.title=Markdown в PDF
|
||||||
home.MarkdownToPDF.desc=Конвертує будь-який файл Markdown у PDF
|
home.MarkdownToPDF.desc=Конвертує будь-який файл Markdown у PDF
|
||||||
MarkdownToPDF.tags=розмітка,веб-контент,перетворення,конвертація
|
MarkdownToPDF.tags=розмітка,веб-контент,перетворення,конвертація
|
||||||
|
|
||||||
home.PDFToMarkdown.title=PDF у Markdown
|
home.PDFToMarkdown.title=PDF в Markdown
|
||||||
home.PDFToMarkdown.desc=Конвертує будь-який файл PDF у Markdown
|
home.PDFToMarkdown.desc=Конвертує будь-який файл PDF у Markdown
|
||||||
PDFToMarkdown.tags=розмітка,веб-вміст,трансформація,перетворення,md
|
PDFToMarkdown.tags=розмітка,веб-вміст,трансформація,перетворення,md
|
||||||
|
|
||||||
@ -493,7 +493,7 @@ home.redact.title=Ручне редагування
|
|||||||
home.redact.desc=Редагує PDF-файл на основі виділеного тексту, намальованих форм і/або вибраних сторінок
|
home.redact.desc=Редагує PDF-файл на основі виділеного тексту, намальованих форм і/або вибраних сторінок
|
||||||
redact.tags=редагувати,приховати,затемнити,чорний,маркер,приховано,вручну
|
redact.tags=редагувати,приховати,затемнити,чорний,маркер,приховано,вручну
|
||||||
|
|
||||||
home.tableExtraxt.title=PDF у CSV
|
home.tableExtraxt.title=PDF в CSV
|
||||||
home.tableExtraxt.desc=Видобуває таблиці з PDF та перетворює їх у CSV
|
home.tableExtraxt.desc=Видобуває таблиці з PDF та перетворює їх у CSV
|
||||||
tableExtraxt.tags=csv,видобуток таблиці,вилучення,конвертація
|
tableExtraxt.tags=csv,видобуток таблиці,вилучення,конвертація
|
||||||
|
|
||||||
@ -565,7 +565,7 @@ login.locked=Ваш обліковий запис заблоковано.
|
|||||||
login.signinTitle=Будь ласка, увійдіть
|
login.signinTitle=Будь ласка, увійдіть
|
||||||
login.ssoSignIn=Увійти через єдиний вхід
|
login.ssoSignIn=Увійти через єдиний вхід
|
||||||
login.oAuth2AutoCreateDisabled=Автоматичне створення користувача OAUTH2 ВИМКНЕНО
|
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.oauth2RequestNotFound=Запит на авторизація не знайдено
|
||||||
login.oauth2InvalidUserInfoResponse=Недійсна відповідь з інформацією користувача
|
login.oauth2InvalidUserInfoResponse=Недійсна відповідь з інформацією користувача
|
||||||
login.oauth2invalidRequest=Недійсний запит
|
login.oauth2invalidRequest=Недійсний запит
|
||||||
@ -645,17 +645,17 @@ getPdfInfo.downloadJson=Завантажити JSON
|
|||||||
|
|
||||||
|
|
||||||
#markdown-to-pdf
|
#markdown-to-pdf
|
||||||
MarkdownToPDF.title=Markdown у PDF
|
MarkdownToPDF.title=Markdown в PDF
|
||||||
MarkdownToPDF.header=Markdown у PDF
|
MarkdownToPDF.header=Markdown в PDF
|
||||||
MarkdownToPDF.submit=Конвертувати
|
MarkdownToPDF.submit=Конвертувати
|
||||||
MarkdownToPDF.help=Робота в процесі
|
MarkdownToPDF.help=Робота в процесі
|
||||||
MarkdownToPDF.credit=Використовує WeasyPrint
|
MarkdownToPDF.credit=Використовує WeasyPrint
|
||||||
|
|
||||||
|
|
||||||
#pdf-to-markdown
|
#pdf-to-markdown
|
||||||
PDFToMarkdown.title=PDF To Markdown
|
PDFToMarkdown.title=PDF в Markdown
|
||||||
PDFToMarkdown.header=PDF To Markdown
|
PDFToMarkdown.header=PDF в Markdown
|
||||||
PDFToMarkdown.submit=Convert
|
PDFToMarkdown.submit=Конвертувати
|
||||||
|
|
||||||
|
|
||||||
#url-to-pdf
|
#url-to-pdf
|
||||||
@ -781,7 +781,7 @@ pageLayout.submit=Відправити
|
|||||||
scalePages.title=Відрегулювати масштаб сторінки
|
scalePages.title=Відрегулювати масштаб сторінки
|
||||||
scalePages.header=Відрегулювати масштаб сторінки
|
scalePages.header=Відрегулювати масштаб сторінки
|
||||||
scalePages.pageSize=Розмір сторінки документа.
|
scalePages.pageSize=Розмір сторінки документа.
|
||||||
scalePages.keepPageSize=Original Size
|
scalePages.keepPageSize=Оригінальний розмір
|
||||||
scalePages.scaleFactor=Рівень масштабування (обрізки) сторінки.
|
scalePages.scaleFactor=Рівень масштабування (обрізки) сторінки.
|
||||||
scalePages.submit=Відправити
|
scalePages.submit=Відправити
|
||||||
|
|
||||||
@ -801,7 +801,7 @@ certSign.showSig=Показати підпис
|
|||||||
certSign.reason=Причина
|
certSign.reason=Причина
|
||||||
certSign.location=Місцезнаходження
|
certSign.location=Місцезнаходження
|
||||||
certSign.name=Ім'я
|
certSign.name=Ім'я
|
||||||
certSign.showLogo=Show Logo
|
certSign.showLogo=Показати логотип
|
||||||
certSign.submit=Підписати PDF
|
certSign.submit=Підписати PDF
|
||||||
|
|
||||||
|
|
||||||
@ -860,8 +860,8 @@ sign.last=Остання сторінка
|
|||||||
sign.next=Наступна сторінка
|
sign.next=Наступна сторінка
|
||||||
sign.previous=Попередня сторінка
|
sign.previous=Попередня сторінка
|
||||||
sign.maintainRatio=Переключити збереження пропорцій
|
sign.maintainRatio=Переключити збереження пропорцій
|
||||||
sign.undo=Undo
|
sign.undo=Скасувати
|
||||||
sign.redo=Redo
|
sign.redo=Повторити
|
||||||
|
|
||||||
#repair
|
#repair
|
||||||
repair.title=Ремонт
|
repair.title=Ремонт
|
||||||
@ -914,7 +914,7 @@ ocr.submit=Обробка PDF з OCR
|
|||||||
extractImages.title=Витягнути зображення
|
extractImages.title=Витягнути зображення
|
||||||
extractImages.header=Витягнути зображення
|
extractImages.header=Витягнути зображення
|
||||||
extractImages.selectText=Виберіть формат зображення для перетворення витягнутих зображень у
|
extractImages.selectText=Виберіть формат зображення для перетворення витягнутих зображень у
|
||||||
extractImages.allowDuplicates=Save duplicate images
|
extractImages.allowDuplicates=Зберігати дублікати зображень
|
||||||
extractImages.submit=Витягнути
|
extractImages.submit=Витягнути
|
||||||
|
|
||||||
|
|
||||||
@ -922,7 +922,7 @@ extractImages.submit=Витягнути
|
|||||||
fileToPDF.title=Файл у PDF
|
fileToPDF.title=Файл у PDF
|
||||||
fileToPDF.header=Конвертувати будь-який файл у PDF
|
fileToPDF.header=Конвертувати будь-який файл у PDF
|
||||||
fileToPDF.credit=Цей сервіс використовує LibreOffice та Unoconv для перетворення файлів.
|
fileToPDF.credit=Цей сервіс використовує LibreOffice та Unoconv для перетворення файлів.
|
||||||
fileToPDF.supportedFileTypesInfo=Supported File types
|
fileToPDF.supportedFileTypesInfo=Підтримувані типи файлів
|
||||||
fileToPDF.supportedFileTypes=Підтримувані типи файлів повинні включати нижченаведені, однак повний оновлений список підтримуваних форматів дивіться у документації LibreOffice.
|
fileToPDF.supportedFileTypes=Підтримувані типи файлів повинні включати нижченаведені, однак повний оновлений список підтримуваних форматів дивіться у документації LibreOffice.
|
||||||
fileToPDF.submit=Перетворити у PDF
|
fileToPDF.submit=Перетворити у PDF
|
||||||
|
|
||||||
@ -932,8 +932,8 @@ compress.title=Стиснути
|
|||||||
compress.header=Стиснути PDF
|
compress.header=Стиснути PDF
|
||||||
compress.credit=Ця служба використовує qpdf для стиснення/оптимізації PDF.
|
compress.credit=Ця служба використовує qpdf для стиснення/оптимізації PDF.
|
||||||
compress.grayscale.label=Застосувати відтінки сірого для стиснення
|
compress.grayscale.label=Застосувати відтінки сірого для стиснення
|
||||||
compress.selectText.1=Compression Settings
|
compress.selectText.1=Параметри стиснення
|
||||||
compress.selectText.1.1=1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality
|
compress.selectText.1.1=1-3 стиснення PDF,</br> 4-6 невелике стиснення зображень,</br> 7-9 посилене стиснення зображень (різко знизить якість зображень)
|
||||||
compress.selectText.2=Рівень оптимізації:
|
compress.selectText.2=Рівень оптимізації:
|
||||||
compress.selectText.4=Автоматичний режим - автоматично налаштовує якість для отримання PDF точного розміру
|
compress.selectText.4=Автоматичний режим - автоматично налаштовує якість для отримання PDF точного розміру
|
||||||
compress.selectText.5=Очікуваний розмір PDF (наприклад, 25 МБ, 10,8 МБ, 25 КБ)
|
compress.selectText.5=Очікуваний розмір PDF (наприклад, 25 МБ, 10,8 МБ, 25 КБ)
|
||||||
@ -953,7 +953,7 @@ merge.title=Об'єднати
|
|||||||
merge.header=Об'єднання кількох PDF-файлів (2+)
|
merge.header=Об'єднання кількох PDF-файлів (2+)
|
||||||
merge.sortByName=Сортування за ім'ям
|
merge.sortByName=Сортування за ім'ям
|
||||||
merge.sortByDate=Сортування за датою
|
merge.sortByDate=Сортування за датою
|
||||||
merge.removeCertSign=Remove digital signature in the merged file?
|
merge.removeCertSign=Видалити цифровий підпис у об’єднаному файлі?
|
||||||
merge.submit=Об'єднати
|
merge.submit=Об'єднати
|
||||||
|
|
||||||
|
|
||||||
@ -971,8 +971,8 @@ pdfOrganiser.mode.6=Розділення на парні та непарні с
|
|||||||
pdfOrganiser.mode.7=Видалити першу
|
pdfOrganiser.mode.7=Видалити першу
|
||||||
pdfOrganiser.mode.8=Видалити останню
|
pdfOrganiser.mode.8=Видалити останню
|
||||||
pdfOrganiser.mode.9=Видалити першу та останню
|
pdfOrganiser.mode.9=Видалити першу та останню
|
||||||
pdfOrganiser.mode.10=Odd-Even Merge
|
pdfOrganiser.mode.10=Об'єднання парних-непарних
|
||||||
pdfOrganiser.mode.11=Duplicate all pages
|
pdfOrganiser.mode.11=Дублювати всі сторінки
|
||||||
pdfOrganiser.placeholder=(наприклад, 1,3,2 або 4-8,2,10-12 або 2n-1)
|
pdfOrganiser.placeholder=(наприклад, 1,3,2 або 4-8,2,10-12 або 2n-1)
|
||||||
|
|
||||||
|
|
||||||
@ -1074,7 +1074,7 @@ pdfToImage.color=Колір
|
|||||||
pdfToImage.grey=Відтінки сірого
|
pdfToImage.grey=Відтінки сірого
|
||||||
pdfToImage.blackwhite=Чорно-білий (може втратити дані!)
|
pdfToImage.blackwhite=Чорно-білий (може втратити дані!)
|
||||||
pdfToImage.submit=Конвертувати
|
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)
|
pdfToImage.placeholder=(наприклад 1,2,8 або 4,7,12-16 або 2n-1)
|
||||||
|
|
||||||
|
|
||||||
@ -1108,12 +1108,12 @@ watermark.selectText.1=Виберіть PDF, щоб додати водяний
|
|||||||
watermark.selectText.2=Текст водяного знаку:
|
watermark.selectText.2=Текст водяного знаку:
|
||||||
watermark.selectText.3=Розмір шрифту:
|
watermark.selectText.3=Розмір шрифту:
|
||||||
watermark.selectText.4=Обертання (0-360):
|
watermark.selectText.4=Обертання (0-360):
|
||||||
watermark.selectText.5=Width Spacer (проміжок між кожним водяним знаком по горизонталі):
|
watermark.selectText.5=Горизонтальний інтервал (проміжок між кожним водяним знаком по горизонталі):
|
||||||
watermark.selectText.6=Height Spacer (проміжок між кожним водяним знаком по вертикалі):
|
watermark.selectText.6=Вертикальний інтервал (проміжок між кожним водяним знаком по вертикалі):
|
||||||
watermark.selectText.7=Непрозорість (0% - 100%):
|
watermark.selectText.7=Непрозорість (0% - 100%):
|
||||||
watermark.selectText.8=Тип водяного знаку:
|
watermark.selectText.8=Тип водяного знаку:
|
||||||
watermark.selectText.9=Зображення водяного знаку:
|
watermark.selectText.9=Зображення водяного знаку:
|
||||||
watermark.selectText.10=Convert PDF to PDF-Image
|
watermark.selectText.10=Кевертувати PDF в PDF-Image
|
||||||
watermark.submit=Додати водяний знак
|
watermark.submit=Додати водяний знак
|
||||||
watermark.type.1=Текст
|
watermark.type.1=Текст
|
||||||
watermark.type.2=Зображення
|
watermark.type.2=Зображення
|
||||||
@ -1275,8 +1275,8 @@ licenses.license=Ліцензія
|
|||||||
survey.nav=Опитування
|
survey.nav=Опитування
|
||||||
survey.title=Опитування Stirling-PDF
|
survey.title=Опитування Stirling-PDF
|
||||||
survey.description=Stirling-PDF не має аналітичних засобів для відслідковування, тому ми хочемо почути думку від користувачів, як покращити 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.changes=Stirling-PDF змінився з часу останнього опитування! Щоб дізнатися більше, перегляньте допис у нашому блозі тут:
|
||||||
survey.changes2=With these changes we are getting paid business support and funding
|
survey.changes2=Завдяки цим змінам ми отримуємо платну підтримку бізнесу та фінансування
|
||||||
survey.please=Будь-ласка, пройдіть опитування!
|
survey.please=Будь-ласка, пройдіть опитування!
|
||||||
survey.disabled=(Вікно з опитування буде відключено у наступних оновленнях, але буде доступне внизу сторінки)
|
survey.disabled=(Вікно з опитування буде відключено у наступних оновленнях, але буде доступне внизу сторінки)
|
||||||
survey.button=Пройти опитування
|
survey.button=Пройти опитування
|
||||||
|
@ -34,11 +34,15 @@
|
|||||||
<!-- Bootstrap -->
|
<!-- Bootstrap -->
|
||||||
<script th:src="@{'/js/thirdParty/popper.min.js'}"></script>
|
<script th:src="@{'/js/thirdParty/popper.min.js'}"></script>
|
||||||
<script th:src="@{'/js/thirdParty/bootstrap.min.js'}"></script>
|
<script th:src="@{'/js/thirdParty/bootstrap.min.js'}"></script>
|
||||||
|
|
||||||
<link rel="stylesheet" th:href="@{'/css/bootstrap.min.css'}">
|
<link rel="stylesheet" th:href="@{'/css/bootstrap.min.css'}">
|
||||||
|
|
||||||
<!-- Bootstrap Icons -->
|
<!-- Bootstrap Icons -->
|
||||||
<link rel="stylesheet" th:href="@{'/css/bootstrap-icons.min.css'}">
|
<link rel="stylesheet" th:href="@{'/css/bootstrap-icons.min.css'}">
|
||||||
|
|
||||||
|
<!-- Pixel, doesn't collect any PII-->
|
||||||
|
<img referrerpolicy="no-referrer-when-downgrade" src="https://pixel.stirlingpdf.com/a.png?x-pxid=4f5fa02f-a065-4efb-bb2c-24509a4b6b92" style="position: absolute; visibility: hidden;"/>
|
||||||
|
|
||||||
<!-- Custom -->
|
<!-- Custom -->
|
||||||
<link rel="stylesheet" th:href="@{'/css/general.css'}">
|
<link rel="stylesheet" th:href="@{'/css/general.css'}">
|
||||||
<link rel="stylesheet" th:href="@{'/css/theme/theme.css'}">
|
<link rel="stylesheet" th:href="@{'/css/theme/theme.css'}">
|
||||||
|
@ -1,24 +1,27 @@
|
|||||||
package stirling.software.SPDF.controller.api;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
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.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
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.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
import stirling.software.SPDF.service.CustomPDFDocumentFactory;
|
|
||||||
import stirling.software.SPDF.model.api.general.RotatePDFRequest;
|
import stirling.software.SPDF.model.api.general.RotatePDFRequest;
|
||||||
|
import stirling.software.SPDF.service.CustomPDFDocumentFactory;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
public class RotationControllerTest {
|
public class RotationControllerTest {
|
||||||
|
Loading…
Reference in New Issue
Block a user