Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd2bcf4854 | ||
|
|
548c8a7e06 | ||
|
|
2f1fe2c80c | ||
|
|
1051f28c52 | ||
|
|
29ccbf7ae6 | ||
|
|
007c8e17de | ||
|
|
81dbb29be3 | ||
|
|
caef0477a9 | ||
|
|
7682a0dd54 | ||
|
|
f90ed4657a | ||
|
|
a6c7a68242 | ||
|
|
a184b394d6 | ||
|
|
302d04c201 | ||
|
|
68ae9d52fb | ||
|
|
6552ba905c | ||
|
|
ee36b6f616 | ||
|
|
627091f8df | ||
|
|
67a37d3291 | ||
|
|
4b6d4885f4 | ||
|
|
2a151b65f7 | ||
|
|
9458fcd0e2 | ||
|
|
1e8c41425b | ||
|
|
b3277a18c8 | ||
|
|
5ca1586976 | ||
|
|
ce47a4e3af | ||
|
|
859a2d97c2 | ||
|
|
a422deecdb | ||
|
|
7f8f09c899 | ||
|
|
96920b1186 | ||
|
|
3626319685 | ||
|
|
755f270a31 | ||
|
|
6e1a7454ca |
@@ -1,7 +1,7 @@
|
||||
name: Backend build, format check, and coverage
|
||||
|
||||
# Reusable workflow called from build.yml. Runs the full backend build matrix
|
||||
# (JDK 21/25 × spring-security on/off), Spotless formatting check, JUnit, and
|
||||
# Reusable workflow called from build.yml. Runs the backend build matrix
|
||||
# (JDK 25 × spring-security on/off), Spotless formatting check, JUnit, and
|
||||
# posts Jacoco coverage to PRs.
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
jdk-version: [21, 25]
|
||||
jdk-version: [25]
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
|
||||
@@ -90,21 +90,21 @@ jobs:
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push/release events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-jars:
|
||||
@@ -256,15 +256,17 @@ jobs:
|
||||
if: matrix.platform == 'macos-15'
|
||||
env:
|
||||
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
|
||||
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
|
||||
run: task desktop:jlink:universal-mac
|
||||
|
||||
- name: Prepare desktop build
|
||||
run: task desktop:prepare
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
|
||||
run: task desktop:prepare
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
|
||||
@@ -47,12 +47,10 @@ jobs:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
PLATFORM: ${{ inputs.platform }}
|
||||
run: |
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
|
||||
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
|
||||
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
|
||||
|
||||
# Resolve requested platform — populated by either workflow_dispatch
|
||||
# or workflow_call inputs; both paths default to "all".
|
||||
case "$PLATFORM" in
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
@@ -112,10 +110,6 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# x86_64 JDK is set up first so the aarch64 step below can leave its
|
||||
# JAVA_HOME as the active one. The macOS universal JRE build needs
|
||||
# jmods from both arches; the x64 path is captured into the env
|
||||
# before the second setup-java overwrites JAVA_HOME.
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -142,21 +136,21 @@ jobs:
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
env:
|
||||
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
|
||||
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
|
||||
run: task desktop:jlink:universal-mac
|
||||
|
||||
- name: Prepare desktop build
|
||||
run: task desktop:prepare
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
|
||||
run: task desktop:prepare
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
@@ -269,6 +263,10 @@ jobs:
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Sign JPDFium dylibs inside bootJar (macOS only)
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
run: bash frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh
|
||||
|
||||
- name: Check DMG creation dependencies (macOS only)
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: |
|
||||
|
||||
+20
-3
@@ -3,6 +3,22 @@ version: '3'
|
||||
vars:
|
||||
JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
|
||||
# Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all').
|
||||
JPDFIUM_PLATFORMS:
|
||||
sh: |
|
||||
if [ -n "${JPDFIUM_PLATFORMS:-}" ]; then
|
||||
echo "$JPDFIUM_PLATFORMS"
|
||||
else
|
||||
case "{{OS}}-{{ARCH}}" in
|
||||
darwin-arm64) echo "darwin-arm64";;
|
||||
darwin-amd64) echo "darwin-x64";;
|
||||
linux-amd64) echo "linux-x64";;
|
||||
linux-arm64) echo "linux-arm64";;
|
||||
windows-amd64) echo "windows-x64";;
|
||||
*) echo "all";;
|
||||
esac
|
||||
fi
|
||||
|
||||
tasks:
|
||||
prepare:
|
||||
desc: "Prepare desktop build dependencies"
|
||||
@@ -71,15 +87,16 @@ tasks:
|
||||
deps: [jlink:jar, jlink:runtime]
|
||||
|
||||
jlink:jar:
|
||||
desc: "Build backend JAR for Tauri bundling"
|
||||
desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)"
|
||||
run: once
|
||||
dir: ..
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat bootJar --no-daemon
|
||||
- echo "Building bootJar with JPDFium natives for {{.JPDFIUM_PLATFORMS}}"
|
||||
- cmd: cmd /c gradlew.bat bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew bootJar --no-daemon
|
||||
- cmd: ./gradlew bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
|
||||
platforms: [linux, darwin]
|
||||
- mkdir -p frontend/src-tauri/libs
|
||||
- cp app/core/build/libs/stirling-pdf-*.jar frontend/src-tauri/libs/
|
||||
|
||||
@@ -431,7 +431,7 @@ The frontend is organized with a clear separation of concerns:
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 21, supports and recommends JDK 25
|
||||
- **Java Version**: Requires JDK 25.
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
**Stirling 2.0** is built using:
|
||||
|
||||
**Backend:**
|
||||
- Spring Boot (Java 21+, JDK 25 recommended)
|
||||
- Spring Boot (requires JDK 25)
|
||||
- PDFBox for core PDF operations
|
||||
- LibreOffice for document conversions
|
||||
- qpdf for PDF optimization
|
||||
@@ -45,7 +45,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
- [Task](https://taskfile.dev/installation/) — unified command runner (recommended)
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 21 or later (JDK 25 recommended)
|
||||
- Java JDK 25
|
||||
- Node.js 18+ and npm (required for frontend development)
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
- [uv](https://docs.astral.sh/uv/) — Python package manager (required for engine development)
|
||||
@@ -61,7 +61,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
cd Stirling-PDF
|
||||
```
|
||||
|
||||
2. Install Docker and JDK 21 (or JDK 25 recommended) if not already installed.
|
||||
2. Install Docker and JDK 25 if not already installed.
|
||||
|
||||
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
|
||||
1. Only VSCode
|
||||
|
||||
@@ -60,6 +60,24 @@ dependencies {
|
||||
exclude group: 'com.google.code.gson', module: 'gson'
|
||||
}
|
||||
|
||||
api 'com.stirling:jpdfium:1.0.0'
|
||||
|
||||
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
|
||||
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
|
||||
def jpdfiumPlatforms = jpdfiumPlatformsProp == 'all'
|
||||
? jpdfiumAllPlatforms
|
||||
: jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
|
||||
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
|
||||
if (jpdfiumInvalid) {
|
||||
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')} or 'all'.")
|
||||
}
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
|
||||
jpdfiumPlatforms.each { platform ->
|
||||
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.0"
|
||||
}
|
||||
|
||||
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package stirling.software.common.jpdfium;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
|
||||
class JPDFiumSmokeTest {
|
||||
|
||||
@Test
|
||||
void opensExamplePdfAndReadsPageCount(@TempDir Path tmp) throws IOException {
|
||||
Path pdf = tmp.resolve("example.pdf");
|
||||
try (InputStream in = getClass().getResourceAsStream("/example.pdf")) {
|
||||
assertNotNull(in, "example.pdf must exist under src/test/resources");
|
||||
Files.copy(in, pdf);
|
||||
}
|
||||
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
assertTrue(
|
||||
doc.pageCount() >= 1,
|
||||
"PdfDocument should report at least one page for example.pdf");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -137,6 +137,16 @@ sourceSets {
|
||||
}
|
||||
|
||||
|
||||
// Forward jpdfium.bench / crop.bench.* to the forked test JVM.
|
||||
tasks.named('test') {
|
||||
if (System.getProperty('jpdfium.bench')) {
|
||||
systemProperty 'jpdfium.bench', System.getProperty('jpdfium.bench')
|
||||
maxHeapSize = System.getProperty('jpdfium.bench.xmx', '4g')
|
||||
System.properties.findAll { k, v -> k.toString().startsWith('crop.bench.') }
|
||||
.each { k, v -> systemProperty(k.toString(), v) }
|
||||
}
|
||||
}
|
||||
|
||||
// Disable regular jar
|
||||
jar {
|
||||
enabled = false
|
||||
@@ -162,7 +172,8 @@ bootJar {
|
||||
manifest {
|
||||
attributes(
|
||||
'Implementation-Title': 'Stirling-PDF',
|
||||
'Implementation-Version': project.version
|
||||
'Implementation-Version': project.version,
|
||||
'Enable-Native-Access': 'ALL-UNNAMED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.multipdf.LayerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -27,13 +21,19 @@ import stirling.software.SPDF.model.api.general.CropPdfForm;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.PdfImageConverter;
|
||||
import stirling.software.jpdfium.PdfPage;
|
||||
import stirling.software.jpdfium.doc.PdfMarginAdjuster;
|
||||
import stirling.software.jpdfium.model.PageSize;
|
||||
import stirling.software.jpdfium.model.Rect;
|
||||
import stirling.software.jpdfium.transform.PdfPageBoxes;
|
||||
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
@@ -42,18 +42,15 @@ public class CropController {
|
||||
|
||||
private static final int DEFAULT_RENDER_DPI = 150;
|
||||
private static final int WHITE_THRESHOLD = 250;
|
||||
private static final String TEMP_INPUT_PREFIX = "crop_input";
|
||||
private static final String TEMP_OUTPUT_PREFIX = "crop_output";
|
||||
private static final String PDF_EXTENSION = ".pdf";
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private static int[] detectContentBounds(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
|
||||
// Early exit if image is too small
|
||||
if (width < 1 || height < 1) {
|
||||
return new int[] {0, 0, width - 1, height - 1};
|
||||
}
|
||||
@@ -109,8 +106,7 @@ public class CropController {
|
||||
}
|
||||
}
|
||||
|
||||
// Return bounds in format: [left, bottom, right, top]
|
||||
// Note: Image coordinates are top-down, PDF coordinates are bottom-up
|
||||
// Image coordinates top-down, PDF coordinates bottom-up
|
||||
return new int[] {left, height - bottom - 1, right, height - top - 1};
|
||||
}
|
||||
|
||||
@@ -121,8 +117,6 @@ public class CropController {
|
||||
return r >= threshold && g >= threshold && b >= threshold;
|
||||
}
|
||||
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private boolean isGhostscriptEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
@@ -152,145 +146,124 @@ public class CropController {
|
||||
|
||||
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
|
||||
return cropWithGhostscript(request);
|
||||
} else {
|
||||
return cropWithPDFBox(request);
|
||||
}
|
||||
return cropWithJpdfium(request);
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> cropWithAutomaticDetection(@ModelAttribute CropPdfForm request)
|
||||
private ResponseEntity<Resource> cropWithAutomaticDetection(CropPdfForm request)
|
||||
throws IOException {
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
File inputFile = tempFileManager.convertMultipartFileToFile(fileInput);
|
||||
try {
|
||||
try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) {
|
||||
int pageCount = doc.pageCount();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
BufferedImage image = PdfImageConverter.pageToImage(doc, i, DEFAULT_RENDER_DPI);
|
||||
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
PDFRenderer renderer = new PDFRenderer(sourceDocument);
|
||||
renderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
PageSize size;
|
||||
try (PdfPage page = doc.page(i)) {
|
||||
size = page.size();
|
||||
}
|
||||
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle mediaBox = sourcePage.getMediaBox();
|
||||
|
||||
BufferedImage image = renderer.renderImageWithDPI(i, DEFAULT_RENDER_DPI);
|
||||
int[] bounds = detectContentBounds(image);
|
||||
|
||||
float scaleX = mediaBox.getWidth() / image.getWidth();
|
||||
float scaleY = mediaBox.getHeight() / image.getHeight();
|
||||
float scaleX = size.width() / image.getWidth();
|
||||
float scaleY = size.height() / image.getHeight();
|
||||
|
||||
CropBounds cropBounds = CropBounds.fromPixels(bounds, scaleX, scaleY);
|
||||
|
||||
PDPage newPage = new PDPage(mediaBox);
|
||||
newDocument.addPage(newPage);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument, newPage, AppendMode.OVERWRITE, true, true)) {
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(
|
||||
cropBounds.x, cropBounds.y, cropBounds.width, cropBounds.height);
|
||||
contentStream.clip();
|
||||
contentStream.drawForm(formXObject);
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
newPage.setMediaBox(
|
||||
new PDRectangle(
|
||||
cropBounds.x,
|
||||
cropBounds.y,
|
||||
cropBounds.width,
|
||||
cropBounds.height));
|
||||
applyCropMargins(doc, i, size, cropBounds);
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
newDocument,
|
||||
TempFile outputTempFile = new TempFile(tempFileManager, PDF_EXTENSION);
|
||||
try {
|
||||
doc.save(outputTempFile.getPath());
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(
|
||||
outputTempFile,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"),
|
||||
tempFileManager);
|
||||
fileInput.getOriginalFilename(), "_cropped.pdf"));
|
||||
}
|
||||
} finally {
|
||||
tempFileManager.deleteTempFile(inputFile);
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> cropWithPDFBox(@ModelAttribute CropPdfForm request)
|
||||
throws IOException {
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
|
||||
// Create a new page with the size of the source page
|
||||
PDPage newPage = new PDPage(sourcePage.getMediaBox());
|
||||
newDocument.addPage(newPage);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument, newPage, AppendMode.OVERWRITE, true, true)) {
|
||||
// Import the source page as a form XObject
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, i);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
|
||||
// Define the crop area
|
||||
contentStream.addRect(
|
||||
private ResponseEntity<Resource> cropWithJpdfium(CropPdfForm request) throws IOException {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
File inputFile = tempFileManager.convertMultipartFileToFile(fileInput);
|
||||
try {
|
||||
try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) {
|
||||
int pageCount = doc.pageCount();
|
||||
CropBounds bounds =
|
||||
new CropBounds(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight());
|
||||
contentStream.clip();
|
||||
|
||||
// Draw the entire formXObject
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
PageSize size;
|
||||
try (PdfPage page = doc.page(i)) {
|
||||
size = page.size();
|
||||
}
|
||||
|
||||
// Now, set the new page's media box to the cropped size
|
||||
newPage.setMediaBox(
|
||||
new PDRectangle(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight()));
|
||||
applyCropMargins(doc, i, size, bounds);
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
newDocument,
|
||||
TempFile outputTempFile = new TempFile(tempFileManager, PDF_EXTENSION);
|
||||
try {
|
||||
doc.save(outputTempFile.getPath());
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(
|
||||
outputTempFile,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"),
|
||||
tempFileManager);
|
||||
fileInput.getOriginalFilename(), "_cropped.pdf"));
|
||||
}
|
||||
} finally {
|
||||
tempFileManager.deleteTempFile(inputFile);
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> cropWithGhostscript(@ModelAttribute CropPdfForm request)
|
||||
throws IOException {
|
||||
// PdfMarginAdjuster.addMargins(doc, page, left, bottom, right, top): translates
|
||||
// content by (left, bottom), grows page by left+right and bottom+top.
|
||||
// To crop to (x, y, w, h) shift content to (-x, -y) and end at (w, h).
|
||||
private static void applyCropMargins(
|
||||
PdfDocument doc, int pageIndex, PageSize size, CropBounds bounds) {
|
||||
float left = -bounds.x();
|
||||
float bottom = -bounds.y();
|
||||
float right = bounds.width() - size.width() + bounds.x();
|
||||
float top = bounds.height() - size.height() + bounds.y();
|
||||
PdfMarginAdjuster.addMargins(doc, pageIndex, left, bottom, right, top);
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> cropWithGhostscript(CropPdfForm request) throws IOException {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
File inputFile = tempFileManager.convertMultipartFileToFile(fileInput);
|
||||
TempFile tempInputFile = null;
|
||||
TempFile tempOutputFile = null;
|
||||
try {
|
||||
tempInputFile = new TempFile(tempFileManager, PDF_EXTENSION);
|
||||
tempOutputFile = new TempFile(tempFileManager, PDF_EXTENSION);
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
PDPage page = sourceDocument.getPage(i);
|
||||
PDRectangle cropBox =
|
||||
new PDRectangle(
|
||||
try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) {
|
||||
Rect cropRect =
|
||||
Rect.of(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight());
|
||||
page.setCropBox(cropBox);
|
||||
int pageCount = doc.pageCount();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
try (PdfPage page = doc.page(i)) {
|
||||
PdfPageBoxes.setCropBox(page.rawHandle(), cropRect);
|
||||
}
|
||||
}
|
||||
doc.save(tempInputFile.getPath());
|
||||
}
|
||||
|
||||
tempInputFile = tempFileManager.createManagedTempFile(PDF_EXTENSION);
|
||||
tempOutputFile = tempFileManager.createManagedTempFile(PDF_EXTENSION);
|
||||
|
||||
// Save the source document with crop boxes
|
||||
sourceDocument.save(tempInputFile.getFile());
|
||||
|
||||
// Execute Ghostscript to process the crop boxes
|
||||
ProcessExecutor processExecutor =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
|
||||
List<String> command =
|
||||
@@ -301,16 +274,13 @@ public class CropController {
|
||||
"-o",
|
||||
tempOutputFile.getAbsolutePath(),
|
||||
tempInputFile.getAbsolutePath());
|
||||
|
||||
processExecutor.runCommandWithOutputHandling(command);
|
||||
|
||||
TempFile out = tempOutputFile;
|
||||
tempOutputFile = null; // ownership transferred to response Resource
|
||||
tempOutputFile = null;
|
||||
return WebResponseUtils.pdfFileToWebResponse(
|
||||
out,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
|
||||
|
||||
GeneralUtils.generateFilename(fileInput.getOriginalFilename(), "_cropped.pdf"));
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e);
|
||||
@@ -321,6 +291,7 @@ public class CropController {
|
||||
if (tempOutputFile != null) {
|
||||
tempOutputFile.close();
|
||||
}
|
||||
tempFileManager.deleteTempFile(inputFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+166
-64
@@ -3,13 +3,14 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
@@ -47,6 +48,11 @@ import stirling.software.common.util.PdfErrorUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.PdfMerge;
|
||||
import stirling.software.jpdfium.doc.Bookmark;
|
||||
import stirling.software.jpdfium.doc.PdfBookmarkEditor;
|
||||
import stirling.software.jpdfium.doc.PdfBookmarkEditor.BookmarkTree;
|
||||
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@@ -57,7 +63,6 @@ public class MergeController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
// Merges a list of PDDocument objects into a single PDDocument
|
||||
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
|
||||
PDDocument mergedDoc = pdfDocumentFactory.createNewDocument();
|
||||
boolean success = false;
|
||||
@@ -76,11 +81,8 @@ public class MergeController {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-order files to match the explicit order provided by the front-end.
|
||||
// fileOrder is newline-delimited original filenames in the desired order.
|
||||
private static MultipartFile[] reorderFilesByProvidedOrder(
|
||||
MultipartFile[] files, String fileOrder) {
|
||||
// Split by various line endings and trim each entry
|
||||
String[] desired =
|
||||
stirling.software.common.util.RegexPatternUtils.getInstance()
|
||||
.getNewlineSplitPattern()
|
||||
@@ -107,7 +109,6 @@ public class MergeController {
|
||||
return ordered.toArray(new MultipartFile[0]);
|
||||
}
|
||||
|
||||
// Returns a comparator for sorting MultipartFile arrays based on the given sort type
|
||||
private Comparator<MultipartFile> getSortComparator(String sortType) {
|
||||
return switch (sortType) {
|
||||
case "byFileName" ->
|
||||
@@ -155,18 +156,16 @@ public class MergeController {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
case "orderProvided" -> (file1, file2) -> 0; // Default is the order provided
|
||||
default -> (file1, file2) -> 0; // Default is the order provided
|
||||
case "orderProvided" -> (file1, file2) -> 0;
|
||||
default -> (file1, file2) -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
// Parse client file IDs from JSON string
|
||||
private String[] parseClientFileIds(String clientFileIds) {
|
||||
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
try {
|
||||
// Simple JSON array parsing - remove brackets and split by comma
|
||||
String trimmed = clientFileIds.trim();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
|
||||
@@ -186,39 +185,29 @@ public class MergeController {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
// Adds a table of contents to the merged document using filenames as chapter titles
|
||||
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
||||
// Create the document outline
|
||||
PDDocumentOutline outline = new PDDocumentOutline();
|
||||
mergedDocument.getDocumentCatalog().setDocumentOutline(outline);
|
||||
|
||||
int pageIndex = 0; // Current page index in the merged document
|
||||
|
||||
// Iterate through the original files
|
||||
int pageIndex = 0;
|
||||
for (MultipartFile file : files) {
|
||||
// Get the filename without extension to use as bookmark title
|
||||
String filename = file.getOriginalFilename();
|
||||
String title = GeneralUtils.removeExtension(filename);
|
||||
|
||||
// Create an outline item for this file
|
||||
PDOutlineItem item = new PDOutlineItem();
|
||||
item.setTitle(title);
|
||||
|
||||
// Set the destination to the first page of this file in the merged document
|
||||
if (pageIndex < mergedDocument.getNumberOfPages()) {
|
||||
PDPage page = mergedDocument.getPage(pageIndex);
|
||||
item.setDestination(page);
|
||||
}
|
||||
|
||||
// Add the item to the outline
|
||||
outline.addLast(item);
|
||||
|
||||
// Increment page index for the next file
|
||||
try (PDDocument doc = pdfDocumentFactory.load(file)) {
|
||||
pageIndex += doc.getNumberOfPages();
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("document loading for TOC generation", e);
|
||||
pageIndex++; // Increment by at least one if we can't determine page count
|
||||
pageIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,7 +225,6 @@ public class MergeController {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to XMP metadata if Info dates are missing
|
||||
PDMetadata metadata = doc.getDocumentCatalog().getMetadata();
|
||||
if (metadata != null) {
|
||||
try (InputStream is = metadata.createInputStream()) {
|
||||
@@ -287,7 +275,7 @@ public class MergeController {
|
||||
@ModelAttribute MergePdfsRequest request,
|
||||
@RequestParam(value = "fileOrder", required = false) String fileOrder)
|
||||
throws IOException {
|
||||
List<File> filesToDelete = new ArrayList<>(); // List of temporary files to delete
|
||||
List<File> filesToDelete = new ArrayList<>();
|
||||
TempFile outputTempFile = null;
|
||||
|
||||
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
|
||||
@@ -298,48 +286,35 @@ public class MergeController {
|
||||
files = new MultipartFile[0];
|
||||
}
|
||||
|
||||
// If front-end provided explicit visible order, honor it and override backend sorting
|
||||
if (fileOrder != null && !fileOrder.isBlank()) {
|
||||
log.info("Reordering files based on fileOrder parameter");
|
||||
files = reorderFilesByProvidedOrder(files, fileOrder);
|
||||
} else {
|
||||
log.info("Sorting files based on sortType: {}", request.getSortType());
|
||||
Arrays.sort(
|
||||
files,
|
||||
getSortComparator(
|
||||
request.getSortType())); // Sort files based on requested sort type
|
||||
Arrays.sort(files, getSortComparator(request.getSortType()));
|
||||
}
|
||||
|
||||
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
|
||||
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
long totalSize = 0;
|
||||
List<Path> inputPaths = new ArrayList<>(files.length);
|
||||
List<Integer> invalidIndexes = new ArrayList<>();
|
||||
for (int index = 0; index < files.length; index++) {
|
||||
MultipartFile multipartFile = files[index];
|
||||
totalSize += multipartFile.getSize();
|
||||
File tempFile =
|
||||
tempFileManager.convertMultipartFileToFile(
|
||||
multipartFile); // Convert MultipartFile to File
|
||||
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
|
||||
File tempFile = tempFileManager.convertMultipartFileToFile(multipartFile);
|
||||
filesToDelete.add(tempFile);
|
||||
inputPaths.add(tempFile.toPath());
|
||||
|
||||
// Pre-validate each PDF so we can report which one(s) are broken
|
||||
// Use the original MultipartFile to avoid deleting the tempFile during validation
|
||||
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
|
||||
// OK
|
||||
} catch (IOException e) {
|
||||
try (PdfDocument ignored = PdfDocument.open(tempFile.toPath())) {
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("PDF pre-validate", e);
|
||||
invalidIndexes.add(index);
|
||||
}
|
||||
mergerUtility.addSource(tempFile); // Add source file to the merger utility
|
||||
}
|
||||
|
||||
mergerUtility.setDestinationFileName(mt.getFile().getAbsolutePath());
|
||||
|
||||
int[] pageCounts;
|
||||
try {
|
||||
mergerUtility.mergeDocuments(
|
||||
pdfDocumentFactory.getStreamCacheFunction(
|
||||
totalSize)); // Merge the documents
|
||||
pageCounts =
|
||||
mergeWithJpdfium(inputPaths, files, generateToc, mt.getFile().toPath());
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("PDF merge", e);
|
||||
if (PdfErrorUtils.isCorruptedPdfError(e)) {
|
||||
@@ -348,10 +323,26 @@ public class MergeController {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Load the merged PDF document and operate on it inside try-with-resources
|
||||
try (PDDocument mergedDocument = pdfDocumentFactory.load(mt.getFile())) {
|
||||
// Remove signatures if removeCertSign is true
|
||||
if (removeCertSign) {
|
||||
boolean sigFlattenNeeded = false;
|
||||
if (removeCertSign) {
|
||||
try (PdfDocument check = PdfDocument.open(mt.getFile().toPath())) {
|
||||
sigFlattenNeeded = !check.signatures().isEmpty();
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"JPDFium signature pre-check failed; falling back to PDFBox flatten:"
|
||||
+ " {}",
|
||||
e.getMessage());
|
||||
sigFlattenNeeded = true;
|
||||
}
|
||||
if (!sigFlattenNeeded) {
|
||||
log.info(
|
||||
"removeCertSign requested but merged document has no signature"
|
||||
+ " fields; skipping PDFBox flatten pass");
|
||||
}
|
||||
}
|
||||
|
||||
if (sigFlattenNeeded) {
|
||||
try (PDDocument mergedDocument = pdfDocumentFactory.load(mt.getFile())) {
|
||||
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
|
||||
PDAcroForm acroForm = catalog.getAcroForm();
|
||||
if (acroForm != null) {
|
||||
@@ -359,24 +350,26 @@ public class MergeController {
|
||||
acroForm.getFields().stream()
|
||||
.filter(PDSignatureField.class::isInstance)
|
||||
.toList();
|
||||
|
||||
if (!fieldsToRemove.isEmpty()) {
|
||||
acroForm.flatten(
|
||||
fieldsToRemove,
|
||||
false); // Flatten the fields, effectively removing them
|
||||
acroForm.flatten(fieldsToRemove, false);
|
||||
}
|
||||
}
|
||||
outputTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
mergedDocument.save(outputTempFile.getFile());
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
outputTempFile = null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Add table of contents if generateToc is true
|
||||
if (generateToc && files.length > 0) {
|
||||
addTableOfContents(mergedDocument, files);
|
||||
}
|
||||
|
||||
// Save the modified document to a temporary file
|
||||
} else {
|
||||
outputTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
mergedDocument.save(outputTempFile.getFile());
|
||||
Files.copy(
|
||||
mt.getFile().toPath(),
|
||||
outputTempFile.getFile().toPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
outputTempFile = null;
|
||||
@@ -395,7 +388,7 @@ public class MergeController {
|
||||
throw ex;
|
||||
} finally {
|
||||
for (File file : filesToDelete) {
|
||||
tempFileManager.deleteTempFile(file); // Delete temporary files
|
||||
tempFileManager.deleteTempFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,4 +398,113 @@ public class MergeController {
|
||||
|
||||
return WebResponseUtils.pdfFileToWebResponse(outputTempFile, mergedFileName);
|
||||
}
|
||||
|
||||
private int[] mergeWithJpdfium(
|
||||
List<Path> inputPaths, MultipartFile[] files, boolean generateToc, Path outputPath)
|
||||
throws IOException {
|
||||
if (inputPaths.isEmpty()) {
|
||||
try (PdfDocument empty = PdfDocument.open(new byte[0])) {
|
||||
empty.save(outputPath);
|
||||
} catch (Exception ignored) {
|
||||
Files.write(outputPath, new byte[0]);
|
||||
}
|
||||
return new int[0];
|
||||
}
|
||||
|
||||
List<PdfDocument> docs = new ArrayList<>(inputPaths.size());
|
||||
int[] pageCounts = new int[inputPaths.size()];
|
||||
int[] pageOffsets = new int[inputPaths.size()];
|
||||
List<List<Bookmark>> sourceBookmarks = new ArrayList<>(inputPaths.size());
|
||||
int runningOffset = 0;
|
||||
try {
|
||||
for (int i = 0; i < inputPaths.size(); i++) {
|
||||
Path p = inputPaths.get(i);
|
||||
PdfDocument doc = PdfDocument.open(p);
|
||||
docs.add(doc);
|
||||
pageCounts[i] = doc.pageCount();
|
||||
pageOffsets[i] = runningOffset;
|
||||
sourceBookmarks.add(doc.bookmarks());
|
||||
runningOffset += pageCounts[i];
|
||||
}
|
||||
|
||||
BookmarkTree combinedTree =
|
||||
buildCombinedBookmarkTree(files, pageOffsets, sourceBookmarks, generateToc);
|
||||
|
||||
try (PdfDocument merged = PdfMerge.merge(docs)) {
|
||||
if (combinedTree.entries().isEmpty()) {
|
||||
merged.save(outputPath);
|
||||
} else {
|
||||
PdfBookmarkEditor.setBookmarks(merged, combinedTree, outputPath);
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("JPDFium merge failed", e);
|
||||
} finally {
|
||||
for (PdfDocument doc : docs) {
|
||||
try {
|
||||
doc.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return pageCounts;
|
||||
}
|
||||
|
||||
private BookmarkTree buildCombinedBookmarkTree(
|
||||
MultipartFile[] files,
|
||||
int[] pageOffsets,
|
||||
List<List<Bookmark>> sourceBookmarks,
|
||||
boolean generateToc) {
|
||||
BookmarkTree.Builder builder = BookmarkTree.builder();
|
||||
|
||||
if (generateToc) {
|
||||
for (int i = 0; i < files.length; i++) {
|
||||
String filename = files[i].getOriginalFilename();
|
||||
String title = GeneralUtils.removeExtension(filename);
|
||||
if (title == null || title.isBlank()) {
|
||||
title = "Document " + (i + 1);
|
||||
}
|
||||
builder.add(title, pageOffsets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < sourceBookmarks.size(); i++) {
|
||||
int offset = pageOffsets[i];
|
||||
for (Bookmark bm : sourceBookmarks.get(i)) {
|
||||
addBookmarkFlat(builder, bm, offset);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private void addBookmarkFlat(BookmarkTree.Builder builder, Bookmark root, int offset) {
|
||||
final int maxNodes = 100_000;
|
||||
java.util.Deque<Bookmark> stack = new java.util.ArrayDeque<>();
|
||||
java.util.Set<Bookmark> visited =
|
||||
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||
stack.push(root);
|
||||
int processed = 0;
|
||||
while (!stack.isEmpty() && processed < maxNodes) {
|
||||
Bookmark bm = stack.pop();
|
||||
if (!visited.add(bm)) {
|
||||
continue;
|
||||
}
|
||||
processed++;
|
||||
if (bm.isInternal() && bm.title() != null) {
|
||||
builder.add(bm.title(), offset + bm.pageIndex());
|
||||
}
|
||||
if (bm.hasChildren()) {
|
||||
List<Bookmark> children = bm.children();
|
||||
for (int i = children.size() - 1; i >= 0; i--) {
|
||||
stack.push(children.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (processed >= maxNodes) {
|
||||
log.warn(
|
||||
"Source bookmark traversal hit {}-node cap; remaining bookmarks dropped",
|
||||
maxNodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+95
-111
@@ -1,16 +1,10 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.multipdf.LayerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -26,91 +20,88 @@ import stirling.software.SPDF.model.api.general.ScalePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.PdfPage;
|
||||
import stirling.software.jpdfium.doc.PdfPageScaler;
|
||||
import stirling.software.jpdfium.doc.PdfPosterizer.PaperSize;
|
||||
import stirling.software.jpdfium.model.PageSize;
|
||||
import stirling.software.jpdfium.model.Rect;
|
||||
import stirling.software.jpdfium.transform.PdfPageBoxes;
|
||||
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ScalePagesController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
// PDFBox PDRectangle dimensions (width, height) in points, inlined so the
|
||||
// controller has no remaining PDFBox dependency.
|
||||
private static final float A0_W = 2383.937f;
|
||||
private static final float A0_H = 3370.3937f;
|
||||
private static final float A1_W = 1683.7795f;
|
||||
private static final float A1_H = 2383.937f;
|
||||
private static final float A2_W = 1190.5511f;
|
||||
private static final float A2_H = 1683.7795f;
|
||||
private static final float A3_W = 841.8898f;
|
||||
private static final float A3_H = 1190.5511f;
|
||||
private static final float A4_W = 595.27563f;
|
||||
private static final float A4_H = 841.8898f;
|
||||
private static final float A5_W = 419.52756f;
|
||||
private static final float A5_H = 595.27563f;
|
||||
private static final float A6_W = 297.63782f;
|
||||
private static final float A6_H = 419.52756f;
|
||||
private static final float LETTER_W = 612f;
|
||||
private static final float LETTER_H = 792f;
|
||||
private static final float LEGAL_W = 612f;
|
||||
private static final float LEGAL_H = 1008f;
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
|
||||
if ("KEEP".equals(targetPDRectangle)) {
|
||||
if (sourceDocument.getNumberOfPages() == 0) {
|
||||
private static PageSize getTargetSize(String name, PdfDocument sourceDocument) {
|
||||
if ("KEEP".equals(name)) {
|
||||
if (sourceDocument.pageCount() == 0) {
|
||||
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
|
||||
}
|
||||
|
||||
PDPage sourcePage = sourceDocument.getPage(0);
|
||||
PDRectangle sourceSize = sourcePage.getMediaBox();
|
||||
|
||||
if (sourceSize == null) {
|
||||
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
|
||||
try (PdfPage first = sourceDocument.page(0)) {
|
||||
return first.size();
|
||||
}
|
||||
|
||||
return sourceSize;
|
||||
}
|
||||
|
||||
Map<String, PDRectangle> sizeMap = getSizeMap();
|
||||
|
||||
if (sizeMap.containsKey(targetPDRectangle)) {
|
||||
return sizeMap.get(targetPDRectangle);
|
||||
Map<String, PageSize> sizeMap = getSizeMap();
|
||||
PageSize size = sizeMap.get(name);
|
||||
if (size == null) {
|
||||
throw ExceptionUtils.createInvalidPageSizeException(name);
|
||||
}
|
||||
|
||||
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
|
||||
return size;
|
||||
}
|
||||
|
||||
private static Map<String, PDRectangle> getSizeMap() {
|
||||
Map<String, PDRectangle> sizeMap = new HashMap<>();
|
||||
private static Map<String, PageSize> getSizeMap() {
|
||||
Map<String, PageSize> sizeMap = new HashMap<>();
|
||||
|
||||
// Portrait sizes (A0-A6)
|
||||
sizeMap.put("A0", PDRectangle.A0);
|
||||
sizeMap.put("A1", PDRectangle.A1);
|
||||
sizeMap.put("A2", PDRectangle.A2);
|
||||
sizeMap.put("A3", PDRectangle.A3);
|
||||
sizeMap.put("A4", PDRectangle.A4);
|
||||
sizeMap.put("A5", PDRectangle.A5);
|
||||
sizeMap.put("A6", PDRectangle.A6);
|
||||
sizeMap.put("A0", new PageSize(A0_W, A0_H));
|
||||
sizeMap.put("A1", new PageSize(A1_W, A1_H));
|
||||
sizeMap.put("A2", new PageSize(A2_W, A2_H));
|
||||
sizeMap.put("A3", new PageSize(A3_W, A3_H));
|
||||
sizeMap.put("A4", new PageSize(A4_W, A4_H));
|
||||
sizeMap.put("A5", new PageSize(A5_W, A5_H));
|
||||
sizeMap.put("A6", new PageSize(A6_W, A6_H));
|
||||
|
||||
// Landscape sizes (A0-A6)
|
||||
sizeMap.put(
|
||||
"A0_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A0.getHeight(), PDRectangle.A0.getWidth()));
|
||||
sizeMap.put(
|
||||
"A1_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A1.getHeight(), PDRectangle.A1.getWidth()));
|
||||
sizeMap.put(
|
||||
"A2_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A2.getHeight(), PDRectangle.A2.getWidth()));
|
||||
sizeMap.put(
|
||||
"A3_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A3.getHeight(), PDRectangle.A3.getWidth()));
|
||||
sizeMap.put(
|
||||
"A4_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()));
|
||||
sizeMap.put(
|
||||
"A5_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A5.getHeight(), PDRectangle.A5.getWidth()));
|
||||
sizeMap.put(
|
||||
"A6_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A6.getHeight(), PDRectangle.A6.getWidth()));
|
||||
sizeMap.put("A0_LANDSCAPE", new PageSize(A0_H, A0_W));
|
||||
sizeMap.put("A1_LANDSCAPE", new PageSize(A1_H, A1_W));
|
||||
sizeMap.put("A2_LANDSCAPE", new PageSize(A2_H, A2_W));
|
||||
sizeMap.put("A3_LANDSCAPE", new PageSize(A3_H, A3_W));
|
||||
sizeMap.put("A4_LANDSCAPE", new PageSize(A4_H, A4_W));
|
||||
sizeMap.put("A5_LANDSCAPE", new PageSize(A5_H, A5_W));
|
||||
sizeMap.put("A6_LANDSCAPE", new PageSize(A6_H, A6_W));
|
||||
|
||||
// Portrait US sizes
|
||||
sizeMap.put("LETTER", PDRectangle.LETTER);
|
||||
sizeMap.put("LEGAL", PDRectangle.LEGAL);
|
||||
sizeMap.put("LETTER", new PageSize(LETTER_W, LETTER_H));
|
||||
sizeMap.put("LEGAL", new PageSize(LEGAL_W, LEGAL_H));
|
||||
|
||||
// Landscape US sizes
|
||||
sizeMap.put(
|
||||
"LETTER_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.LETTER.getHeight(), PDRectangle.LETTER.getWidth()));
|
||||
sizeMap.put(
|
||||
"LEGAL_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.LEGAL.getHeight(), PDRectangle.LEGAL.getWidth()));
|
||||
sizeMap.put("LETTER_LANDSCAPE", new PageSize(LETTER_H, LETTER_W));
|
||||
sizeMap.put("LEGAL_LANDSCAPE", new PageSize(LEGAL_H, LEGAL_W));
|
||||
|
||||
return sizeMap;
|
||||
}
|
||||
@@ -130,53 +121,46 @@ public class ScalePagesController {
|
||||
String targetPDRectangle = request.getPageSize();
|
||||
float scaleFactor = request.getScaleFactor();
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument outputDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
File inputFile = tempFileManager.convertMultipartFileToFile(file);
|
||||
try {
|
||||
try (PdfDocument doc = PdfDocument.open(inputFile.toPath())) {
|
||||
PageSize targetSize = getTargetSize(targetPDRectangle, doc);
|
||||
float targetW = targetSize.width();
|
||||
float targetH = targetSize.height();
|
||||
|
||||
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
|
||||
// Build a "virtual" paper so PdfPageScaler.scale produces base*scaleFactor
|
||||
// scaling, then restore the real MediaBox/CropBox to the requested target
|
||||
// size with the content centered.
|
||||
float virtualW = targetW * scaleFactor;
|
||||
float virtualH = targetH * scaleFactor;
|
||||
PaperSize virtualPaper = new PaperSize(virtualW, virtualH, "virtual");
|
||||
|
||||
// Create LayerUtility once outside the loop for better performance
|
||||
LayerUtility layerUtility = new LayerUtility(outputDocument);
|
||||
int pageCount = doc.pageCount();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
PdfPageScaler.scale(doc, i, virtualPaper, PdfPageScaler.FitMode.FIT_PAGE);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle sourceSize = sourcePage.getMediaBox();
|
||||
|
||||
float scaleWidth = targetSize.getWidth() / sourceSize.getWidth();
|
||||
float scaleHeight = targetSize.getHeight() / sourceSize.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight) * scaleFactor;
|
||||
|
||||
PDPage newPage = new PDPage(targetSize);
|
||||
outputDocument.addPage(newPage);
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
outputDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
|
||||
float x = (targetSize.getWidth() - sourceSize.getWidth() * scale) / 2;
|
||||
float y = (targetSize.getHeight() - sourceSize.getHeight() * scale) / 2;
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.drawForm(form);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
float offsetX = (virtualW - targetW) / 2f;
|
||||
float offsetY = (virtualH - targetH) / 2f;
|
||||
Rect mediaBox = Rect.of(offsetX, offsetY, targetW, targetH);
|
||||
try (PdfPage page = doc.page(i)) {
|
||||
PdfPageBoxes.setMediaBox(page.rawHandle(), mediaBox);
|
||||
PdfPageBoxes.setCropBox(page.rawHandle(), mediaBox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
outputDocument,
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"),
|
||||
tempFileManager);
|
||||
TempFile outputTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
doc.save(outputTempFile.getPath());
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(
|
||||
outputTempFile,
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"));
|
||||
}
|
||||
} finally {
|
||||
tempFileManager.deleteTempFile(inputFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
-220
@@ -1,6 +1,7 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@@ -27,53 +28,50 @@ import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.general.CropPdfForm;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("CropController Tests")
|
||||
class CropControllerTest {
|
||||
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
|
||||
return ResponseEntity.ok(new ByteArrayResource(bytes));
|
||||
}
|
||||
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
try (java.io.InputStream __in = response.getBody().getInputStream()) {
|
||||
__in.transferTo(baos);
|
||||
try (java.io.InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@TempDir Path tempDir;
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@InjectMocks private CropController cropController;
|
||||
private TestPdfFactory pdfFactory;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.when(tempFileManager.convertMultipartFileToFile(any(MultipartFile.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
MultipartFile mf = inv.getArgument(0);
|
||||
File f = Files.createTempFile("crop-in", ".pdf").toFile();
|
||||
Files.write(f.toPath(), mf.getBytes());
|
||||
return f;
|
||||
});
|
||||
lenient()
|
||||
.when(tempFileManager.createTempFile(anyString()))
|
||||
.thenAnswer(inv -> Files.createTempFile("crop-out", inv.getArgument(0)).toFile());
|
||||
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
pdfFactory = new TestPdfFactory();
|
||||
}
|
||||
|
||||
@@ -112,8 +110,10 @@ class CropControllerTest {
|
||||
private static final PDType1Font HELVETICA =
|
||||
new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
|
||||
// PDFium TransFormWithClip rejects pages without content streams; always
|
||||
// add at least a sentinel character so the page has a /Contents entry.
|
||||
MockMultipartFile createStandardPdf(String filename) throws IOException {
|
||||
return createPdf(filename, PDRectangle.LETTER, null);
|
||||
return createPdf(filename, PDRectangle.LETTER, "X");
|
||||
}
|
||||
|
||||
MockMultipartFile createPdfWithContent(String filename, String content) throws IOException {
|
||||
@@ -121,30 +121,26 @@ class CropControllerTest {
|
||||
}
|
||||
|
||||
MockMultipartFile createPdfWithSize(String filename, PDRectangle size) throws IOException {
|
||||
return createPdf(filename, size, null);
|
||||
return createPdf(filename, size, "X");
|
||||
}
|
||||
|
||||
MockMultipartFile createPdf(String filename, PDRectangle pageSize, String content)
|
||||
throws IOException {
|
||||
Path testPdfPath = tempDir.resolve(filename);
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(pageSize);
|
||||
doc.addPage(page);
|
||||
|
||||
if (content != null && !content.isEmpty()) {
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(HELVETICA, 12);
|
||||
contentStream.newLineAtOffset(50, pageSize.getHeight() - 50);
|
||||
contentStream.showText(content);
|
||||
contentStream.endText();
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(HELVETICA, 12);
|
||||
cs.newLineAtOffset(50, pageSize.getHeight() - 50);
|
||||
cs.showText(content);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
|
||||
doc.save(testPdfPath.toFile());
|
||||
}
|
||||
|
||||
return new MockMultipartFile(
|
||||
"fileInput",
|
||||
filename,
|
||||
@@ -156,26 +152,22 @@ class CropControllerTest {
|
||||
throws IOException {
|
||||
Path testPdfPath = tempDir.resolve(filename);
|
||||
PDRectangle pageSize = PDRectangle.LETTER;
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(pageSize);
|
||||
doc.addPage(page);
|
||||
|
||||
if (content != null && !content.isEmpty()) {
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(HELVETICA, 12);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(HELVETICA, 12);
|
||||
float x = pageSize.getWidth() / 2 - 50;
|
||||
float y = pageSize.getHeight() / 2;
|
||||
contentStream.newLineAtOffset(x, y);
|
||||
contentStream.showText(content);
|
||||
contentStream.endText();
|
||||
cs.newLineAtOffset(x, y);
|
||||
cs.showText(content);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
|
||||
doc.save(testPdfPath.toFile());
|
||||
}
|
||||
|
||||
return new MockMultipartFile(
|
||||
"fileInput",
|
||||
filename,
|
||||
@@ -185,43 +177,34 @@ class CropControllerTest {
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Manual Crop with PDFBox")
|
||||
class ManualCropPDFBoxTests {
|
||||
@DisplayName("Manual Crop with JPDFium")
|
||||
@Tag("integration")
|
||||
class ManualCropTests {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"Should successfully crop PDF using PDFBox when removeDataOutsideCrop is false")
|
||||
void shouldCropPdfSuccessfullyWithPDFBox() throws IOException {
|
||||
@DisplayName("Should produce PDF with cropped MediaBox dimensions")
|
||||
void shouldCropPdfWithExpectedDimensions() throws IOException {
|
||||
MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
|
||||
float w = 512f;
|
||||
float h = 692f;
|
||||
CropPdfForm request =
|
||||
new CropRequestBuilder()
|
||||
.withFile(testFile)
|
||||
.withCoordinates(50f, 50f, 512f, 692f)
|
||||
.withCoordinates(50f, 50f, w, h)
|
||||
.withRemoveDataOutsideCrop(false)
|
||||
.withAutoCrop(false)
|
||||
.build();
|
||||
|
||||
PDDocument mockDocument = mock(PDDocument.class);
|
||||
PDDocument newDocument = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
|
||||
.thenReturn(newDocument);
|
||||
|
||||
ResponseEntity<Resource> response = cropController.cropPdf(request);
|
||||
|
||||
assertThat(response)
|
||||
.isNotNull()
|
||||
.extracting(ResponseEntity::getStatusCode, ResponseEntity::getBody)
|
||||
.satisfies(
|
||||
tuple -> {
|
||||
assertThat(tuple.get(0)).isEqualTo(HttpStatus.OK);
|
||||
assertThat(tuple.get(1)).isNotNull();
|
||||
});
|
||||
|
||||
verify(pdfDocumentFactory).load(request);
|
||||
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(mockDocument);
|
||||
verify(mockDocument, times(1)).close();
|
||||
verify(newDocument, times(1)).close();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
byte[] out = drainBody(response);
|
||||
try (PDDocument result = Loader.loadPDF(out)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
PDRectangle mediaBox = result.getPage(0).getMediaBox();
|
||||
assertThat(mediaBox.getWidth()).isCloseTo(w, within(0.1f));
|
||||
assertThat(mediaBox.getHeight()).isCloseTo(h, within(0.1f));
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@@ -238,21 +221,15 @@ class CropControllerTest {
|
||||
.withAutoCrop(false)
|
||||
.build();
|
||||
|
||||
PDDocument mockDocument = mock(PDDocument.class);
|
||||
PDDocument newDocument = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
|
||||
.thenReturn(newDocument);
|
||||
|
||||
ResponseEntity<Resource> response = cropController.cropPdf(request);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
|
||||
verify(pdfDocumentFactory).load(request);
|
||||
verify(mockDocument, times(1)).close();
|
||||
verify(newDocument, times(1)).close();
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
PDRectangle mediaBox = result.getPage(0).getMediaBox();
|
||||
assertThat(mediaBox.getWidth()).isCloseTo(width, within(0.5f));
|
||||
assertThat(mediaBox.getHeight()).isCloseTo(height, within(0.5f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,69 +238,84 @@ class CropControllerTest {
|
||||
@Tag("integration")
|
||||
class AutoCropTests {
|
||||
|
||||
private TestPdfFactory autoCropPdfFactory;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
autoCropPdfFactory = new TestPdfFactory();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should auto-crop PDF with content successfully")
|
||||
void shouldAutoCropPdfSuccessfully() throws IOException {
|
||||
MockMultipartFile testFile =
|
||||
autoCropPdfFactory.createPdfWithCenteredContent(
|
||||
pdfFactory.createPdfWithCenteredContent(
|
||||
"test_autocrop.pdf", "Test Content for Auto Crop");
|
||||
CropPdfForm request =
|
||||
new CropRequestBuilder().withFile(testFile).withAutoCrop(true).build();
|
||||
|
||||
// Mock the pdfDocumentFactory to load real PDFs
|
||||
try (PDDocument sourceDoc = Loader.loadPDF(testFile.getBytes());
|
||||
PDDocument newDoc = new PDDocument()) {
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(sourceDoc);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
|
||||
.thenReturn(newDoc);
|
||||
ResponseEntity<Resource> response = cropController.cropPdf(request);
|
||||
|
||||
ResponseEntity<Resource> response = cropController.cropPdf(request);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
|
||||
PDPage page = result.getPage(0);
|
||||
assertThat(page).isNotNull();
|
||||
assertThat(page.getMediaBox()).isNotNull();
|
||||
}
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
byte[] out = drainBody(response);
|
||||
assertThat(out).isNotEmpty();
|
||||
try (PDDocument result = Loader.loadPDF(out)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
PDPage page = result.getPage(0);
|
||||
assertThat(page.getMediaBox()).isNotNull();
|
||||
// Cropped result should be smaller than original LETTER.
|
||||
assertThat(page.getMediaBox().getWidth()).isLessThan(PDRectangle.LETTER.getWidth());
|
||||
assertThat(page.getMediaBox().getHeight())
|
||||
.isLessThan(PDRectangle.LETTER.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle PDF with minimal content")
|
||||
void shouldHandleMinimalContentPdf() throws IOException {
|
||||
MockMultipartFile testFile =
|
||||
autoCropPdfFactory.createPdfWithContent("minimal.pdf", "X");
|
||||
MockMultipartFile testFile = pdfFactory.createPdfWithContent("minimal.pdf", "X");
|
||||
CropPdfForm request =
|
||||
new CropRequestBuilder().withFile(testFile).withAutoCrop(true).build();
|
||||
|
||||
// Mock the pdfDocumentFactory to load real PDFs
|
||||
try (PDDocument sourceDoc = Loader.loadPDF(testFile.getBytes());
|
||||
PDDocument newDoc = new PDDocument()) {
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(sourceDoc);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
|
||||
.thenReturn(newDoc);
|
||||
ResponseEntity<Resource> response = cropController.cropPdf(request);
|
||||
|
||||
ResponseEntity<Resource> response = cropController.cropPdf(request);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
@Nested
|
||||
@DisplayName("Ghostscript CropBox Compatibility")
|
||||
@Tag("integration")
|
||||
class GhostscriptCropBoxTests {
|
||||
|
||||
Assertions.assertNotNull(response.getBody());
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
// gs -dUseCropBox reads the /CropBox dictionary entry on each page.
|
||||
// Verifies JPDFium's PdfPageBoxes.setCropBox actually persists a /CropBox
|
||||
// entry that PDFBox (and therefore gs) can read back.
|
||||
@Test
|
||||
@DisplayName("Should persist /CropBox entry that PDFBox can read after JPDFium save")
|
||||
void shouldPersistCropBoxThatGsCanRead() throws IOException {
|
||||
MockMultipartFile testFile = pdfFactory.createStandardPdf("cropbox.pdf");
|
||||
Path src = Files.createTempFile(tempDir, "src-", ".pdf");
|
||||
Files.write(src, testFile.getBytes());
|
||||
Path out = Files.createTempFile(tempDir, "out-", ".pdf");
|
||||
|
||||
float x = 50, y = 60, w = 400, h = 500;
|
||||
try (stirling.software.jpdfium.PdfDocument doc =
|
||||
stirling.software.jpdfium.PdfDocument.open(src)) {
|
||||
int n = doc.pageCount();
|
||||
for (int i = 0; i < n; i++) {
|
||||
try (stirling.software.jpdfium.PdfPage p = doc.page(i)) {
|
||||
stirling.software.jpdfium.transform.PdfPageBoxes.setCropBox(
|
||||
p.rawHandle(), stirling.software.jpdfium.model.Rect.of(x, y, w, h));
|
||||
}
|
||||
}
|
||||
doc.save(out);
|
||||
}
|
||||
|
||||
try (PDDocument loaded = Loader.loadPDF(out.toFile())) {
|
||||
PDRectangle cropBox = loaded.getPage(0).getCropBox();
|
||||
assertThat(cropBox).isNotNull();
|
||||
assertThat(cropBox.getLowerLeftX()).isCloseTo(x, within(0.5f));
|
||||
assertThat(cropBox.getLowerLeftY()).isCloseTo(y, within(0.5f));
|
||||
assertThat(cropBox.getWidth()).isCloseTo(w, within(0.5f));
|
||||
assertThat(cropBox.getHeight()).isCloseTo(h, within(0.5f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,9 +376,7 @@ class CropControllerTest {
|
||||
@DisplayName("Should detect full image bounds for all white image")
|
||||
void shouldDetectFullBoundsForWhiteImage() throws Exception {
|
||||
BufferedImage whiteImage = createWhiteImage(100, 100);
|
||||
|
||||
int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, whiteImage);
|
||||
|
||||
assertThat(bounds).containsExactly(0, 0, 99, 99);
|
||||
}
|
||||
|
||||
@@ -395,9 +385,7 @@ class CropControllerTest {
|
||||
void shouldDetectBlackRectangleBounds() throws Exception {
|
||||
BufferedImage image = createWhiteImage(100, 100);
|
||||
drawBlackRectangle(image, 25, 25, 75, 75);
|
||||
|
||||
int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image);
|
||||
|
||||
assertThat(bounds).containsExactly(25, 25, 74, 74);
|
||||
}
|
||||
|
||||
@@ -409,9 +397,7 @@ class CropControllerTest {
|
||||
image.setRGB(99, 0, 0x000000);
|
||||
image.setRGB(0, 99, 0x000000);
|
||||
image.setRGB(99, 99, 0x000000);
|
||||
|
||||
int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image);
|
||||
|
||||
assertThat(bounds).containsExactly(0, 0, 99, 99);
|
||||
}
|
||||
|
||||
@@ -422,9 +408,7 @@ class CropControllerTest {
|
||||
image.setRGB(10, 10, 0xF0F0F0);
|
||||
image.setRGB(90, 90, 0xF0F0F0);
|
||||
drawBlackRectangle(image, 30, 30, 70, 70);
|
||||
|
||||
int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image);
|
||||
|
||||
assertThat(bounds).containsExactly(10, 9, 90, 89);
|
||||
}
|
||||
|
||||
@@ -433,9 +417,7 @@ class CropControllerTest {
|
||||
void shouldTreatGrayPixelsAsContent() throws Exception {
|
||||
BufferedImage image = createImageFilledWith(50, 50, 0xF0F0F0);
|
||||
drawDarkerRectangle(image, 20, 20, 30, 30, 0xC0C0C0);
|
||||
|
||||
int[] bounds = (int[]) detectContentBoundsMethod.invoke(null, image);
|
||||
|
||||
assertThat(bounds).containsExactly(0, 0, 49, 49);
|
||||
}
|
||||
}
|
||||
@@ -554,33 +536,6 @@ class CropControllerTest {
|
||||
@DisplayName("Error Handling")
|
||||
class ErrorHandlingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for corrupt PDF file")
|
||||
void shouldThrowExceptionForCorruptPdf() throws IOException {
|
||||
MockMultipartFile corruptFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"corrupt.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"not a valid pdf content".getBytes());
|
||||
|
||||
CropPdfForm request =
|
||||
new CropRequestBuilder()
|
||||
.withFile(corruptFile)
|
||||
.withCoordinates(50f, 50f, 512f, 692f)
|
||||
.withRemoveDataOutsideCrop(false)
|
||||
.withAutoCrop(false)
|
||||
.build();
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenThrow(new IOException("Invalid PDF format"));
|
||||
|
||||
assertThatThrownBy(() -> cropController.cropPdf(request))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("Invalid PDF format");
|
||||
|
||||
verify(pdfDocumentFactory).load(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception when coordinates are missing for manual crop")
|
||||
void shouldThrowExceptionForMissingCoordinates() throws IOException {
|
||||
@@ -593,54 +548,6 @@ class CropControllerTest {
|
||||
.hasMessage(
|
||||
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle negative coordinates gracefully")
|
||||
void shouldHandleNegativeCoordinates() throws IOException {
|
||||
MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
|
||||
CropPdfForm request =
|
||||
new CropRequestBuilder()
|
||||
.withFile(testFile)
|
||||
.withCoordinates(-10f, 50f, 512f, 692f)
|
||||
.withRemoveDataOutsideCrop(false)
|
||||
.withAutoCrop(false)
|
||||
.build();
|
||||
|
||||
PDDocument mockDocument = mock(PDDocument.class);
|
||||
PDDocument newDocument = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
|
||||
.thenReturn(newDocument);
|
||||
|
||||
assertThatCode(() -> cropController.cropPdf(request)).doesNotThrowAnyException();
|
||||
|
||||
verify(mockDocument, times(1)).close();
|
||||
verify(newDocument, times(1)).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle zero width or height")
|
||||
void shouldHandleZeroDimensions() throws IOException {
|
||||
MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
|
||||
CropPdfForm request =
|
||||
new CropRequestBuilder()
|
||||
.withFile(testFile)
|
||||
.withCoordinates(50f, 50f, 0f, 692f)
|
||||
.withRemoveDataOutsideCrop(false)
|
||||
.withAutoCrop(false)
|
||||
.build();
|
||||
|
||||
PDDocument mockDocument = mock(PDDocument.class);
|
||||
PDDocument newDocument = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
|
||||
.thenReturn(newDocument);
|
||||
|
||||
assertThatCode(() -> cropController.cropPdf(request)).doesNotThrowAnyException();
|
||||
|
||||
verify(mockDocument, times(1)).close();
|
||||
verify(newDocument, times(1)).close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -663,7 +570,6 @@ class CropControllerTest {
|
||||
MockMultipartFile testFile = pdfFactory.createStandardPdf("test.pdf");
|
||||
float expectedWidth = 400f;
|
||||
float expectedHeight = 500f;
|
||||
|
||||
CropPdfForm request =
|
||||
new CropRequestBuilder()
|
||||
.withFile(testFile)
|
||||
@@ -672,16 +578,14 @@ class CropControllerTest {
|
||||
.withAutoCrop(false)
|
||||
.build();
|
||||
|
||||
PDDocument mockDocument = mock(PDDocument.class);
|
||||
PDDocument newDocument = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
|
||||
.thenReturn(newDocument);
|
||||
|
||||
ResponseEntity<Resource> response = cropController.cropPdf(request);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
PDRectangle mediaBox = result.getPage(0).getMediaBox();
|
||||
assertThat(mediaBox.getWidth()).isCloseTo(expectedWidth, within(0.5f));
|
||||
assertThat(mediaBox.getHeight()).isCloseTo(expectedHeight, within(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@@ -691,7 +595,6 @@ class CropControllerTest {
|
||||
throws IOException {
|
||||
PDRectangle pageSize = getPageSize(pageSizeName);
|
||||
MockMultipartFile testFile = pdfFactory.createPdfWithSize(filename, pageSize);
|
||||
|
||||
CropPdfForm request =
|
||||
new CropRequestBuilder()
|
||||
.withFile(testFile)
|
||||
@@ -700,17 +603,14 @@ class CropControllerTest {
|
||||
.withAutoCrop(false)
|
||||
.build();
|
||||
|
||||
PDDocument mockDocument = mock(PDDocument.class);
|
||||
PDDocument newDocument = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDocument))
|
||||
.thenReturn(newDocument);
|
||||
|
||||
ResponseEntity<Resource> response = cropController.cropPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(mockDocument, times(1)).close();
|
||||
verify(newDocument, times(1)).close();
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
PDRectangle mediaBox = result.getPage(0).getMediaBox();
|
||||
assertThat(mediaBox.getWidth()).isCloseTo(300f, within(0.5f));
|
||||
assertThat(mediaBox.getHeight()).isCloseTo(400f, within(0.5f));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.multipdf.LayerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.PdfPage;
|
||||
import stirling.software.jpdfium.doc.PdfMarginAdjuster;
|
||||
import stirling.software.jpdfium.doc.PdfPageScaler;
|
||||
import stirling.software.jpdfium.doc.PdfPosterizer.PaperSize;
|
||||
import stirling.software.jpdfium.model.PageSize;
|
||||
import stirling.software.jpdfium.model.Rect;
|
||||
import stirling.software.jpdfium.transform.PdfPageBoxes;
|
||||
|
||||
/** Bench gated on -Djpdfium.bench=true. Compares JPDFium crop+scale vs PDFBox baselines. */
|
||||
@EnabledIfSystemProperty(named = "jpdfium.bench", matches = "true")
|
||||
class CropScaleBench {
|
||||
|
||||
private static final int WARMUPS = 2;
|
||||
private static final int RUNS = 5;
|
||||
private static final int PAGES = Integer.getInteger("crop.bench.pages", 60);
|
||||
private static final int IMG_W = Integer.getInteger("crop.bench.imgW", 1200);
|
||||
private static final int IMG_H = Integer.getInteger("crop.bench.imgH", 1600);
|
||||
|
||||
@Test
|
||||
void cropAndScale(@TempDir Path tmp) throws IOException {
|
||||
Path imageHeavy = tmp.resolve("image_heavy.pdf");
|
||||
buildImageHeavyPdf(imageHeavy, PAGES);
|
||||
long sizeBytes = Files.size(imageHeavy);
|
||||
|
||||
for (int i = 0; i < WARMUPS; i++) {
|
||||
runPdfBoxCrop(imageHeavy);
|
||||
runJpdfiumCrop(imageHeavy);
|
||||
runPdfBoxScale(imageHeavy);
|
||||
runJpdfiumScale(imageHeavy);
|
||||
}
|
||||
|
||||
Stats pdfboxCrop = bench(() -> runPdfBoxCrop(imageHeavy));
|
||||
Stats jpdfiumCrop = bench(() -> runJpdfiumCrop(imageHeavy));
|
||||
Stats pdfboxScale = bench(() -> runPdfBoxScale(imageHeavy));
|
||||
Stats jpdfiumScale = bench(() -> runJpdfiumScale(imageHeavy));
|
||||
|
||||
System.out.println("=== CropScaleBench ===");
|
||||
System.out.printf(
|
||||
"input: %d KB (%d pages, %dx%d JPEGs)%n", sizeBytes / 1024, PAGES, IMG_W, IMG_H);
|
||||
System.out.printf("runs: %d after %d warmups%n", RUNS, WARMUPS);
|
||||
report("PDFBox crop", pdfboxCrop);
|
||||
report("JPDFium crop", jpdfiumCrop);
|
||||
report("PDFBox scale", pdfboxScale);
|
||||
report("JPDFium scale", jpdfiumScale);
|
||||
System.out.printf(
|
||||
"crop delta (jpdfium-pdfbox): wall=%+.1fms heap=%+.1fMB%n",
|
||||
jpdfiumCrop.avgMillis - pdfboxCrop.avgMillis,
|
||||
jpdfiumCrop.avgPeakHeapMb - pdfboxCrop.avgPeakHeapMb);
|
||||
System.out.printf(
|
||||
"scale delta (jpdfium-pdfbox): wall=%+.1fms heap=%+.1fMB%n",
|
||||
jpdfiumScale.avgMillis - pdfboxScale.avgMillis,
|
||||
jpdfiumScale.avgPeakHeapMb - pdfboxScale.avgPeakHeapMb);
|
||||
|
||||
// Auto-crop renders every page via PdfImageConverter at 150 DPI.
|
||||
// Canary single run to surface heap pressure on image-heavy input.
|
||||
System.gc();
|
||||
long h0 = usedHeap();
|
||||
long t0 = System.nanoTime();
|
||||
runJpdfiumAutoCrop(imageHeavy);
|
||||
long t1 = System.nanoTime();
|
||||
long h1 = usedHeap();
|
||||
System.out.printf(
|
||||
"JPDFium auto-crop (1 run): wall=%.1fms heapDelta=%.1fMB%n",
|
||||
(t1 - t0) / 1_000_000.0, Math.max(0, h1 - h0) / (1024.0 * 1024.0));
|
||||
}
|
||||
|
||||
private static void report(String label, Stats s) {
|
||||
System.out.printf(
|
||||
"%-13s wall=%7.1fms peakHeap=%6.1fMB%n", label, s.avgMillis, s.avgPeakHeapMb);
|
||||
}
|
||||
|
||||
// PDFBox crop matching pre-migration CropController.cropWithPDFBox.
|
||||
private void runPdfBoxCrop(Path in) throws IOException {
|
||||
byte[] bytes = Files.readAllBytes(in);
|
||||
try (PDDocument src = Loader.loadPDF(bytes);
|
||||
PDDocument dst = new PDDocument()) {
|
||||
LayerUtility layerUtility = new LayerUtility(dst);
|
||||
float cropX = 50, cropY = 50, cropW = 500, cropH = 680;
|
||||
for (int i = 0; i < src.getNumberOfPages(); i++) {
|
||||
PDPage sourcePage = src.getPage(i);
|
||||
PDPage newPage = new PDPage(sourcePage.getMediaBox());
|
||||
dst.addPage(newPage);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(dst, newPage, AppendMode.OVERWRITE, true, true)) {
|
||||
PDFormXObject form = layerUtility.importPageAsForm(src, i);
|
||||
cs.saveGraphicsState();
|
||||
cs.addRect(cropX, cropY, cropW, cropH);
|
||||
cs.clip();
|
||||
cs.drawForm(form);
|
||||
cs.restoreGraphicsState();
|
||||
}
|
||||
newPage.setMediaBox(new PDRectangle(cropX, cropY, cropW, cropH));
|
||||
}
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
dst.save(out);
|
||||
}
|
||||
}
|
||||
|
||||
private void runJpdfiumCrop(Path in) throws IOException {
|
||||
float cropX = 50, cropY = 50, cropW = 500, cropH = 680;
|
||||
try (PdfDocument doc = PdfDocument.open(in)) {
|
||||
int n = doc.pageCount();
|
||||
for (int i = 0; i < n; i++) {
|
||||
PageSize size;
|
||||
try (PdfPage page = doc.page(i)) {
|
||||
size = page.size();
|
||||
}
|
||||
float left = -cropX;
|
||||
float bottom = -cropY;
|
||||
float right = cropW - size.width() + cropX;
|
||||
float top = cropH - size.height() + cropY;
|
||||
PdfMarginAdjuster.addMargins(doc, i, left, bottom, right, top);
|
||||
}
|
||||
doc.saveBytes();
|
||||
}
|
||||
}
|
||||
|
||||
// PDFBox scale matching pre-migration ScalePagesController.scalePages.
|
||||
private void runPdfBoxScale(Path in) throws IOException {
|
||||
byte[] bytes = Files.readAllBytes(in);
|
||||
PDRectangle target = PDRectangle.A4;
|
||||
float scaleFactor = 0.75f;
|
||||
try (PDDocument src = Loader.loadPDF(bytes);
|
||||
PDDocument dst = new PDDocument()) {
|
||||
LayerUtility layerUtility = new LayerUtility(dst);
|
||||
for (int i = 0; i < src.getNumberOfPages(); i++) {
|
||||
PDRectangle sourceSize = src.getPage(i).getMediaBox();
|
||||
float scaleW = target.getWidth() / sourceSize.getWidth();
|
||||
float scaleH = target.getHeight() / sourceSize.getHeight();
|
||||
float scale = Math.min(scaleW, scaleH) * scaleFactor;
|
||||
PDPage newPage = new PDPage(target);
|
||||
dst.addPage(newPage);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(
|
||||
dst, newPage, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
float x = (target.getWidth() - sourceSize.getWidth() * scale) / 2;
|
||||
float y = (target.getHeight() - sourceSize.getHeight() * scale) / 2;
|
||||
cs.saveGraphicsState();
|
||||
cs.transform(Matrix.getTranslateInstance(x, y));
|
||||
cs.transform(Matrix.getScaleInstance(scale, scale));
|
||||
PDFormXObject form = layerUtility.importPageAsForm(src, i);
|
||||
cs.drawForm(form);
|
||||
cs.restoreGraphicsState();
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
dst.save(out);
|
||||
}
|
||||
}
|
||||
|
||||
private void runJpdfiumScale(Path in) throws IOException {
|
||||
float targetW = 595.27563f, targetH = 841.8898f; // A4
|
||||
float scaleFactor = 0.75f;
|
||||
try (PdfDocument doc = PdfDocument.open(in)) {
|
||||
float virtualW = targetW * scaleFactor;
|
||||
float virtualH = targetH * scaleFactor;
|
||||
PaperSize virtualPaper = new PaperSize(virtualW, virtualH, "virtual");
|
||||
int n = doc.pageCount();
|
||||
for (int i = 0; i < n; i++) {
|
||||
PdfPageScaler.scale(doc, i, virtualPaper, PdfPageScaler.FitMode.FIT_PAGE);
|
||||
float offX = (virtualW - targetW) / 2f;
|
||||
float offY = (virtualH - targetH) / 2f;
|
||||
Rect box = Rect.of(offX, offY, targetW, targetH);
|
||||
try (PdfPage page = doc.page(i)) {
|
||||
PdfPageBoxes.setMediaBox(page.rawHandle(), box);
|
||||
PdfPageBoxes.setCropBox(page.rawHandle(), box);
|
||||
}
|
||||
}
|
||||
doc.saveBytes();
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the auto-crop hot path: renders each page then mutates margins.
|
||||
private void runJpdfiumAutoCrop(Path in) throws IOException {
|
||||
try (PdfDocument doc = PdfDocument.open(in)) {
|
||||
int n = doc.pageCount();
|
||||
for (int i = 0; i < n; i++) {
|
||||
BufferedImage img =
|
||||
stirling.software.jpdfium.PdfImageConverter.pageToImage(doc, i, 150);
|
||||
PageSize size;
|
||||
try (PdfPage page = doc.page(i)) {
|
||||
size = page.size();
|
||||
}
|
||||
// Pretend bounds detection produced a centered crop.
|
||||
float cx = 20, cy = 20;
|
||||
float cw = size.width() - 40;
|
||||
float ch = size.height() - 40;
|
||||
float left = -cx, bottom = -cy;
|
||||
float right = cw - size.width() + cx;
|
||||
float top = ch - size.height() + cy;
|
||||
PdfMarginAdjuster.addMargins(doc, i, left, bottom, right, top);
|
||||
img.flush();
|
||||
}
|
||||
doc.saveBytes();
|
||||
}
|
||||
}
|
||||
|
||||
private Stats bench(IoRunnable r) throws IOException {
|
||||
double sumMs = 0;
|
||||
double sumHeapMb = 0;
|
||||
for (int i = 0; i < RUNS; i++) {
|
||||
System.gc();
|
||||
long before = usedHeap();
|
||||
long t0 = System.nanoTime();
|
||||
r.run();
|
||||
long t1 = System.nanoTime();
|
||||
long after = usedHeap();
|
||||
sumMs += (t1 - t0) / 1_000_000.0;
|
||||
sumHeapMb += Math.max(0, after - before) / (1024.0 * 1024.0);
|
||||
}
|
||||
Stats s = new Stats();
|
||||
s.avgMillis = sumMs / RUNS;
|
||||
s.avgPeakHeapMb = sumHeapMb / RUNS;
|
||||
return s;
|
||||
}
|
||||
|
||||
private static long usedHeap() {
|
||||
Runtime rt = Runtime.getRuntime();
|
||||
return rt.totalMemory() - rt.freeMemory();
|
||||
}
|
||||
|
||||
// Image-heavy PDF: one JPEG per page so crop/scale touch real content.
|
||||
private static void buildImageHeavyPdf(Path path, int pages) throws IOException {
|
||||
byte[] jpeg = makeJpeg(IMG_W, IMG_H);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
PDImageXObject img = PDImageXObject.createFromByteArray(doc, jpeg, "bg" + i);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
page.getMediaBox().getWidth(),
|
||||
page.getMediaBox().getHeight());
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 18);
|
||||
cs.newLineAtOffset(40, 40);
|
||||
cs.showText("page " + i);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
doc.save(path.toFile());
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] makeJpeg(int w, int h) throws IOException {
|
||||
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
for (int y = 0; y < h; y += 8) {
|
||||
// Gradient so the encoded JPEG is not trivially compressible.
|
||||
g.setColor(new Color((y * 7) & 0xff, (y * 11) & 0xff, (y * 13) & 0xff));
|
||||
g.fillRect(0, y, w, 8);
|
||||
}
|
||||
g.dispose();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "jpg", baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static class Stats {
|
||||
double avgMillis;
|
||||
double avgPeakHeapMb;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface IoRunnable {
|
||||
void run() throws IOException;
|
||||
}
|
||||
}
|
||||
+131
-180
@@ -1,6 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
@@ -10,17 +10,21 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -28,264 +32,211 @@ import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.ScalePagesRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@Tag("integration")
|
||||
class ScalePagesControllerTest {
|
||||
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
|
||||
return ResponseEntity.ok(new ByteArrayResource(bytes));
|
||||
}
|
||||
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
try (java.io.InputStream __in = response.getBody().getInputStream()) {
|
||||
__in.transferTo(baos);
|
||||
try (java.io.InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@TempDir Path tempDir;
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@InjectMocks private ScalePagesController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.when(tempFileManager.convertMultipartFileToFile(any(MultipartFile.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
MultipartFile mf = inv.getArgument(0);
|
||||
File f = Files.createTempFile("scale-in", ".pdf").toFile();
|
||||
Files.write(f.toPath(), mf.getBytes());
|
||||
return f;
|
||||
});
|
||||
lenient()
|
||||
.when(tempFileManager.createTempFile(anyString()))
|
||||
.thenAnswer(inv -> Files.createTempFile("scale-out", inv.getArgument(0)).toFile());
|
||||
}
|
||||
|
||||
private byte[] createRealPdf(PDRectangle pageSize, int numPages) throws IOException {
|
||||
// PDFium TransFormWithClip requires each page to have a content stream; add a
|
||||
// sentinel character on every page so the test fixtures are not empty.
|
||||
private MockMultipartFile createRealPdf(PDRectangle pageSize, int numPages) throws IOException {
|
||||
PDType1Font helv = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
doc.addPage(new PDPage(pageSize));
|
||||
PDPage page = new PDPage(pageSize);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(helv, 12);
|
||||
cs.newLineAtOffset(50, pageSize.getHeight() - 50);
|
||||
cs.showText("X");
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
Path pdfPath = tempDir.resolve("input.pdf");
|
||||
Path pdfPath = tempDir.resolve("input-" + System.nanoTime() + ".pdf");
|
||||
doc.save(pdfPath.toFile());
|
||||
return Files.readAllBytes(pdfPath);
|
||||
return new MockMultipartFile(
|
||||
"fileInput",
|
||||
"test.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
Files.readAllBytes(pdfPath));
|
||||
}
|
||||
}
|
||||
|
||||
private void setupFactory() throws IOException {
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
byte[] bytes = ((MultipartFile) inv.getArgument(0)).getBytes();
|
||||
return org.apache.pdfbox.Loader.loadPDF(bytes);
|
||||
});
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
private ScalePagesRequest buildRequest(MockMultipartFile file, String size, float factor) {
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize(size);
|
||||
request.setScaleFactor(factor);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Test
|
||||
void testScalePages_A4ToA3() throws Exception {
|
||||
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
|
||||
ResponseEntity<Resource> response = controller.scalePages(buildRequest(file, "A3", 1.0f));
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("A3");
|
||||
request.setScaleFactor(1.0f);
|
||||
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.scalePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(drainBody(response).length > 0);
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(1);
|
||||
PDRectangle mb = out.getPage(0).getMediaBox();
|
||||
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A3.getWidth(), within(0.5f));
|
||||
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A3.getHeight(), within(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testScalePages_KeepSize() throws Exception {
|
||||
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 2);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
MockMultipartFile file = createRealPdf(PDRectangle.A4, 2);
|
||||
ResponseEntity<Resource> response = controller.scalePages(buildRequest(file, "KEEP", 1.0f));
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("KEEP");
|
||||
request.setScaleFactor(1.0f);
|
||||
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.scalePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(2);
|
||||
PDRectangle mb = out.getPage(0).getMediaBox();
|
||||
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A4.getWidth(), within(0.5f));
|
||||
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A4.getHeight(), within(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testScalePages_WithScaleFactor() throws Exception {
|
||||
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
|
||||
ResponseEntity<Resource> response = controller.scalePages(buildRequest(file, "A4", 0.5f));
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("A4");
|
||||
request.setScaleFactor(0.5f);
|
||||
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.scalePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
// Page size stays A4; content is scaled by 0.5x and centered.
|
||||
PDRectangle mb = out.getPage(0).getMediaBox();
|
||||
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A4.getWidth(), within(0.5f));
|
||||
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A4.getHeight(), within(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testScalePages_Letter() throws Exception {
|
||||
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
|
||||
ResponseEntity<Resource> response =
|
||||
controller.scalePages(buildRequest(file, "LETTER", 1.0f));
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("LETTER");
|
||||
request.setScaleFactor(1.0f);
|
||||
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.scalePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
PDRectangle mb = out.getPage(0).getMediaBox();
|
||||
assertThat(mb.getWidth()).isCloseTo(PDRectangle.LETTER.getWidth(), within(0.5f));
|
||||
assertThat(mb.getHeight()).isCloseTo(PDRectangle.LETTER.getHeight(), within(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testScalePages_Legal() throws Exception {
|
||||
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
|
||||
ResponseEntity<Resource> response =
|
||||
controller.scalePages(buildRequest(file, "LEGAL", 1.0f));
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("LEGAL");
|
||||
request.setScaleFactor(1.0f);
|
||||
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.scalePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
PDRectangle mb = out.getPage(0).getMediaBox();
|
||||
assertThat(mb.getWidth()).isCloseTo(PDRectangle.LEGAL.getWidth(), within(0.5f));
|
||||
assertThat(mb.getHeight()).isCloseTo(PDRectangle.LEGAL.getHeight(), within(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testScalePages_InvalidPageSize() throws Exception {
|
||||
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
|
||||
ScalePagesRequest request = buildRequest(file, "INVALID_SIZE", 1.0f);
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("INVALID_SIZE");
|
||||
request.setScaleFactor(1.0f);
|
||||
|
||||
setupFactory();
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.scalePages(request));
|
||||
assertThatThrownBy(() -> controller.scalePages(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testScalePages_MultiplePages() throws Exception {
|
||||
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 5);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
MockMultipartFile file = createRealPdf(PDRectangle.A4, 5);
|
||||
ResponseEntity<Resource> response = controller.scalePages(buildRequest(file, "A5", 1.0f));
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("A5");
|
||||
request.setScaleFactor(1.0f);
|
||||
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.scalePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(5);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
PDRectangle mb = out.getPage(i).getMediaBox();
|
||||
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A5.getWidth(), within(0.5f));
|
||||
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A5.getHeight(), within(0.5f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testScalePages_LandscapeSize() throws Exception {
|
||||
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
|
||||
ResponseEntity<Resource> response =
|
||||
controller.scalePages(buildRequest(file, "A4_LANDSCAPE", 1.0f));
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("A4_LANDSCAPE");
|
||||
request.setScaleFactor(1.0f);
|
||||
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.scalePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
PDRectangle mb = out.getPage(0).getMediaBox();
|
||||
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A4.getHeight(), within(0.5f));
|
||||
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A4.getWidth(), within(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testScalePages_KeepWithEmptyDoc() throws Exception {
|
||||
// Create a PDF then load it, but mock factory to return empty doc for KEEP check
|
||||
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
|
||||
Path pdfPath = tempDir.resolve("empty.pdf");
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.save(pdfPath.toFile());
|
||||
}
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
"fileInput",
|
||||
"empty.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
Files.readAllBytes(pdfPath));
|
||||
ScalePagesRequest request = buildRequest(file, "KEEP", 1.0f);
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("KEEP");
|
||||
request.setScaleFactor(1.0f);
|
||||
|
||||
// Return an empty document to trigger the KEEP exception
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(new PDDocument());
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.scalePages(request));
|
||||
assertThatThrownBy(() -> controller.scalePages(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testScalePages_A0Size() throws Exception {
|
||||
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
MockMultipartFile file = createRealPdf(PDRectangle.A4, 1);
|
||||
ResponseEntity<Resource> response = controller.scalePages(buildRequest(file, "A0", 1.0f));
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("A0");
|
||||
request.setScaleFactor(1.0f);
|
||||
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.scalePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
PDRectangle mb = out.getPage(0).getMediaBox();
|
||||
assertThat(mb.getWidth()).isCloseTo(PDRectangle.A0.getWidth(), within(0.5f));
|
||||
assertThat(mb.getHeight()).isCloseTo(PDRectangle.A0.getHeight(), within(0.5f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -31,12 +31,12 @@ ext {
|
||||
googleJavaFormatVersion = "1.28.0"
|
||||
logback = "1.5.32"
|
||||
// junit-platform-launcher version managed by Spring Boot BOM
|
||||
modernJavaVersion = 21
|
||||
modernJavaVersion = 25
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(project.findProperty('javaVersion')?.toString() ?: '25')
|
||||
}
|
||||
@@ -158,8 +158,8 @@ subprojects {
|
||||
apply plugin: 'jacoco'
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
@@ -444,7 +444,8 @@ subprojects {
|
||||
"-XX:G1HeapRegionSize=4m",
|
||||
"-XX:+ExplicitGCInvokesConcurrent",
|
||||
"-XX:+UseStringDeduplication",
|
||||
"-XX:+UseCompactObjectHeaders"
|
||||
"-XX:+UseCompactObjectHeaders",
|
||||
"--enable-native-access=ALL-UNNAMED"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sign every .dylib inside the bootJar's JPDFium native jars.
|
||||
# Requires APPLE_SIGNING_IDENTITY set to a Developer ID identity in the keychain.
|
||||
#
|
||||
# Usage: sign-jpdfium-dylibs-in-bootjar.sh [path/to/stirling-pdf-*.jar]
|
||||
|
||||
set -u
|
||||
|
||||
echo "sign-jpdfium-dylibs-in-bootjar.sh: start ($(uname -s) $(uname -m))"
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin*) ;;
|
||||
*) echo "Not macOS, skipping"; exit 0;;
|
||||
esac
|
||||
|
||||
if [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
||||
echo "APPLE_SIGNING_IDENTITY not set; skipping"
|
||||
exit 0
|
||||
fi
|
||||
if ! command -v codesign >/dev/null 2>&1; then
|
||||
echo "codesign not on PATH; skipping"
|
||||
exit 0
|
||||
fi
|
||||
if ! command -v jar >/dev/null 2>&1; then
|
||||
echo "jar not on PATH (need a JDK setup-action earlier); skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BOOTJARS=()
|
||||
if [ -n "${1:-}" ]; then
|
||||
BOOTJARS+=("$1")
|
||||
else
|
||||
for cand in app/core/build/libs/stirling-pdf-*.jar \
|
||||
frontend/src-tauri/libs/stirling-pdf-*.jar; do
|
||||
[ -f "$cand" ] || continue
|
||||
BOOTJARS+=("$cand")
|
||||
done
|
||||
fi
|
||||
if [ "${#BOOTJARS[@]:-0}" = 0 ]; then
|
||||
echo "bootJar not found (expected app/core/build/libs/stirling-pdf-*.jar" \
|
||||
"or frontend/src-tauri/libs/stirling-pdf-*.jar)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for BOOTJAR in "${BOOTJARS[@]}"; do
|
||||
BOOTJAR=$(cd "$(dirname "$BOOTJAR")" && pwd)/$(basename "$BOOTJAR")
|
||||
echo ""
|
||||
echo "=== Target bootJar: $BOOTJAR ($(du -h "$BOOTJAR" | cut -f1)) ==="
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
# shellcheck disable=SC2064
|
||||
trap "rm -rf '$WORK'" EXIT
|
||||
|
||||
NATIVE_JAR_PATHS=()
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
NATIVE_JAR_PATHS+=("$line")
|
||||
done < <(jar tf "$BOOTJAR" \
|
||||
| grep -E '^BOOT-INF/lib/jpdfium-natives-darwin-(x64|arm64)-.*\.jar$' || true)
|
||||
|
||||
if [ "${#NATIVE_JAR_PATHS[@]:-0}" = 0 ]; then
|
||||
echo " No JPDFium darwin natives in this bootJar; skipping"
|
||||
rm -rf "$WORK"
|
||||
continue
|
||||
fi
|
||||
|
||||
( cd "$WORK" && jar xf "$BOOTJAR" ${NATIVE_JAR_PATHS[@]+"${NATIVE_JAR_PATHS[@]}"} ) \
|
||||
|| { echo "jar xf failed to extract natives jars" >&2; exit 1; }
|
||||
|
||||
ANY_SIGNED=0
|
||||
for nat_jar in "$WORK/BOOT-INF/lib"/jpdfium-natives-darwin-*.jar; do
|
||||
[ -f "$nat_jar" ] || continue
|
||||
base=$(basename "$nat_jar")
|
||||
echo " Processing $base"
|
||||
|
||||
exp_dir="$WORK/${base%.jar}.expanded"
|
||||
mkdir -p "$exp_dir"
|
||||
( cd "$exp_dir" && jar xf "$nat_jar" )
|
||||
|
||||
signed=0
|
||||
while IFS= read -r dylib; do
|
||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" \
|
||||
--options runtime --timestamp "$dylib" 2>&1 | sed 's/^/ /'
|
||||
signed=$((signed + 1))
|
||||
done < <(find "$exp_dir" -name '*.dylib' -type f)
|
||||
|
||||
if [ "$signed" = 0 ]; then
|
||||
echo " (no .dylibs found)"
|
||||
continue
|
||||
fi
|
||||
echo " signed $signed dylib(s)"
|
||||
|
||||
rm -f "$nat_jar"
|
||||
( cd "$exp_dir" && jar cfM0 "$nat_jar" . )
|
||||
ANY_SIGNED=1
|
||||
done
|
||||
|
||||
if [ "$ANY_SIGNED" = 0 ]; then
|
||||
echo " No .dylibs signed; skipping update"
|
||||
rm -rf "$WORK"
|
||||
continue
|
||||
fi
|
||||
|
||||
( cd "$WORK" && jar uf "$BOOTJAR" \
|
||||
BOOT-INF/lib/jpdfium-natives-darwin-x64-*.jar \
|
||||
BOOT-INF/lib/jpdfium-natives-darwin-arm64-*.jar ) \
|
||||
2>/dev/null || { echo "jar uf failed" >&2; exit 1; }
|
||||
|
||||
echo " Updated: $BOOTJAR ($(du -h "$BOOTJAR" | cut -f1))"
|
||||
rm -rf "$WORK"
|
||||
done
|
||||
+4
-2
@@ -442,8 +442,10 @@ compare_file_lists() {
|
||||
echo "New files created during test:"
|
||||
cat "${diff_file}.added" | sed 's/^> //'
|
||||
|
||||
# Check for tmp files
|
||||
grep -i "tmp\|temp" "${diff_file}.added" > "${diff_file}.tmp" || true
|
||||
# Exclude JPDFium native cache (deleteOnExit-registered, not a leak).
|
||||
grep -i "tmp\|temp" "${diff_file}.added" \
|
||||
| grep -v '/jpdfium-[0-9]\+/' \
|
||||
> "${diff_file}.tmp" || true
|
||||
if [ -s "${diff_file}.tmp" ]; then
|
||||
echo "WARNING: Temporary files detected:"
|
||||
cat "${diff_file}.tmp"
|
||||
|
||||
Reference in New Issue
Block a user