mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc2c51b3b | ||
|
|
1f93525dcf | ||
|
|
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,8 @@ bootJar {
|
||||
manifest {
|
||||
attributes(
|
||||
'Implementation-Title': 'Stirling-PDF',
|
||||
'Implementation-Version': project.version
|
||||
'Implementation-Version': project.version,
|
||||
'Enable-Native-Access': 'ALL-UNNAMED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-18
@@ -28,11 +28,13 @@ import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.common.util.propertyeditor.StringToMapPropertyEditor;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
|
||||
@MiscApi
|
||||
@Slf4j
|
||||
@@ -43,12 +45,9 @@ public class MetadataController {
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
private String checkUndefined(String entry) {
|
||||
// Check if the string is "undefined"
|
||||
if ("undefined".equals(entry)) {
|
||||
// Return null if it is
|
||||
return null;
|
||||
}
|
||||
// Return the original string if it's not "undefined"
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -71,10 +70,8 @@ public class MetadataController {
|
||||
public ResponseEntity<Resource> metadata(@ModelAttribute MetadataRequest request)
|
||||
throws IOException {
|
||||
|
||||
// Extract PDF file from the request object
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
|
||||
// Extract metadata information
|
||||
boolean deleteAll = Boolean.TRUE.equals(request.getDeleteAll());
|
||||
String author = request.getAuthor();
|
||||
String creationDate = request.getCreationDate();
|
||||
@@ -86,18 +83,19 @@ public class MetadataController {
|
||||
String title = request.getTitle();
|
||||
String trapped = request.getTrapped();
|
||||
|
||||
// Extract additional custom parameters
|
||||
Map<String, String> allRequestParams = request.getAllRequestParams();
|
||||
if (allRequestParams == null) {
|
||||
allRequestParams = new java.util.HashMap<String, String>();
|
||||
}
|
||||
// Load the PDF file into a PDDocument with proper resource management
|
||||
|
||||
// JPDFium pre-validate - cheap structural check before PDFBox parses.
|
||||
validateWithJpdfium(pdfFile);
|
||||
|
||||
// PDFBox handles writes - JPDFium has no metadata write API.
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile, true)) {
|
||||
|
||||
// Get the document information from the PDF
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
|
||||
// Check if each metadata value is "undefined" and set it to null if it is
|
||||
author = checkUndefined(author);
|
||||
creationDate = checkUndefined(creationDate);
|
||||
creator = checkUndefined(creator);
|
||||
@@ -108,13 +106,10 @@ public class MetadataController {
|
||||
title = checkUndefined(title);
|
||||
trapped = checkUndefined(trapped);
|
||||
|
||||
// If the "deleteAll" flag is set, remove all metadata from the document
|
||||
// information
|
||||
if (deleteAll) {
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
info.setCustomMetadataValue(key, null);
|
||||
}
|
||||
// Remove metadata from the PDF history
|
||||
document.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.removeItem(COSName.getPDFName("Metadata"));
|
||||
@@ -131,10 +126,8 @@ public class MetadataController {
|
||||
title = null;
|
||||
trapped = null;
|
||||
} else {
|
||||
// Iterate through the request parameters and set the metadata values
|
||||
for (Entry<String, String> entry : allRequestParams.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
// Check if the key is a standard metadata key
|
||||
if (!"Author".equalsIgnoreCase(key)
|
||||
&& !"CreationDate".equalsIgnoreCase(key)
|
||||
&& !"Creator".equalsIgnoreCase(key)
|
||||
@@ -159,18 +152,15 @@ public class MetadataController {
|
||||
String customValue = allRequestParams.get("customValue" + number);
|
||||
info.setCustomMetadataValue(customKey, customValue);
|
||||
} catch (NumberFormatException e) {
|
||||
// Skip invalid custom key entries that don't have valid numeric
|
||||
// suffixes
|
||||
log.warn("Skipping invalid custom key '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set creation date using utility method
|
||||
|
||||
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
|
||||
info.setCreationDate(creationDateCal);
|
||||
|
||||
// Set modification date using utility method
|
||||
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
|
||||
info.setModificationDate(modificationDateCal);
|
||||
info.setCreator(creator);
|
||||
@@ -190,4 +180,20 @@ public class MetadataController {
|
||||
tempFileManager);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateWithJpdfium(MultipartFile pdfFile) {
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = pdfFile.getBytes();
|
||||
} catch (Exception e) {
|
||||
return;
|
||||
}
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
return;
|
||||
}
|
||||
try (PdfDocument ignored = PdfDocument.open(bytes)) {
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("JPDFium metadata pre-validate", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+116
-5
@@ -62,6 +62,10 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.doc.Attachment;
|
||||
import stirling.software.jpdfium.doc.Bookmark;
|
||||
import stirling.software.jpdfium.doc.MetadataTag;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
@@ -306,6 +310,10 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
|
||||
private static ObjectNode extractMetadata(PDDocument document) {
|
||||
return extractMetadata(document, null);
|
||||
}
|
||||
|
||||
private static ObjectNode extractMetadata(PDDocument document, PdfDocument jpdfiumDoc) {
|
||||
ObjectNode metadata = objectMapper.createObjectNode();
|
||||
|
||||
try {
|
||||
@@ -340,9 +348,35 @@ public class GetInfoOnPDF {
|
||||
log.error("Error extracting metadata: {}", e.getMessage());
|
||||
}
|
||||
|
||||
augmentMetadataFromJpdfium(metadata, jpdfiumDoc);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static void augmentMetadataFromJpdfium(ObjectNode metadata, PdfDocument jpdfiumDoc) {
|
||||
if (jpdfiumDoc == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
putIfAbsentText(metadata, "Title", jpdfiumDoc.metadata(MetadataTag.TITLE.pdfKey()));
|
||||
putIfAbsentText(metadata, "Author", jpdfiumDoc.metadata(MetadataTag.AUTHOR.pdfKey()));
|
||||
putIfAbsentText(metadata, "Subject", jpdfiumDoc.metadata(MetadataTag.SUBJECT.pdfKey()));
|
||||
putIfAbsentText(
|
||||
metadata, "Keywords", jpdfiumDoc.metadata(MetadataTag.KEYWORDS.pdfKey()));
|
||||
putIfAbsentText(
|
||||
metadata, "Producer", jpdfiumDoc.metadata(MetadataTag.PRODUCER.pdfKey()));
|
||||
putIfAbsentText(metadata, "Creator", jpdfiumDoc.metadata(MetadataTag.CREATOR.pdfKey()));
|
||||
} catch (Exception e) {
|
||||
log.debug("JPDFium metadata augment failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void putIfAbsentText(
|
||||
ObjectNode node, String key, java.util.Optional<String> value) {
|
||||
if (!node.has(key)) {
|
||||
value.ifPresent(v -> node.put(key, v));
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectNode extractDocumentInfo(PDDocument document) {
|
||||
ObjectNode docInfoNode = objectMapper.createObjectNode();
|
||||
|
||||
@@ -1090,24 +1124,32 @@ public class GetInfoOnPDF {
|
||||
|
||||
boolean readonly = true;
|
||||
|
||||
try (PDDocument pdfBoxDoc = pdfDocumentFactory.load(inputFile, readonly)) {
|
||||
// JPDFium read path - supplements PDFBox where doc structure is identical.
|
||||
byte[] inputBytes = null;
|
||||
try {
|
||||
inputBytes = inputFile.getBytes();
|
||||
} catch (Exception e) {
|
||||
log.debug("Unable to read input bytes for JPDFium path: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try (PDDocument pdfBoxDoc = pdfDocumentFactory.load(inputFile, readonly);
|
||||
PdfDocument jpdfiumDoc = openJpdfiumQuietly(inputBytes)) {
|
||||
ObjectNode jsonOutput = objectMapper.createObjectNode();
|
||||
|
||||
ObjectNode metadata = extractMetadata(pdfBoxDoc);
|
||||
ObjectNode metadata = extractMetadata(pdfBoxDoc, jpdfiumDoc);
|
||||
ObjectNode basicInfo = extractBasicInfo(pdfBoxDoc, inputFile.getSize());
|
||||
ObjectNode docInfoNode = extractDocumentInfo(pdfBoxDoc);
|
||||
ObjectNode compliancy = extractComplianceInfo(pdfBoxDoc, verificationResults);
|
||||
ObjectNode encryption = extractEncryptionInfo(pdfBoxDoc);
|
||||
ObjectNode permissionsNode = extractPermissions(pdfBoxDoc);
|
||||
ObjectNode other = extractOtherInfo(pdfBoxDoc);
|
||||
augmentOtherFromJpdfium(other, jpdfiumDoc);
|
||||
ObjectNode formFieldsNode = extractFormFields(pdfBoxDoc);
|
||||
|
||||
ObjectNode summaryData = generatePDFSummaryData(pdfBoxDoc, verificationResults);
|
||||
|
||||
// Extract per-page information
|
||||
ObjectNode pageInfoParent = extractPerPageInfo(pdfBoxDoc);
|
||||
|
||||
// Assemble final JSON output
|
||||
jsonOutput.set("Metadata", metadata);
|
||||
jsonOutput.set("BasicInfo", basicInfo);
|
||||
jsonOutput.set("DocumentInfo", docInfoNode);
|
||||
@@ -1122,7 +1164,6 @@ public class GetInfoOnPDF {
|
||||
jsonOutput.set("SummaryData", summaryData);
|
||||
}
|
||||
|
||||
// Convert to JSON string
|
||||
String jsonString =
|
||||
objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonOutput);
|
||||
|
||||
@@ -1140,6 +1181,76 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
}
|
||||
|
||||
private static PdfDocument openJpdfiumQuietly(byte[] bytes) {
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return PdfDocument.open(bytes);
|
||||
} catch (Exception e) {
|
||||
log.debug("JPDFium open failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void augmentOtherFromJpdfium(ObjectNode other, PdfDocument jpdfiumDoc) {
|
||||
if (jpdfiumDoc == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ArrayNode existingAttachments =
|
||||
other.has("Attachments") && other.get("Attachments").isArray()
|
||||
? (ArrayNode) other.get("Attachments")
|
||||
: null;
|
||||
if (existingAttachments == null || existingAttachments.isEmpty()) {
|
||||
ArrayNode jpdfAttachments = objectMapper.createArrayNode();
|
||||
for (Attachment att : jpdfiumDoc.attachments()) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
node.put("Name", att.name());
|
||||
if (att.hasData()) {
|
||||
node.put("FileSize", att.data().length);
|
||||
}
|
||||
att.extension().ifPresent(ext -> node.put("Extension", ext));
|
||||
jpdfAttachments.add(node);
|
||||
}
|
||||
if (!jpdfAttachments.isEmpty()) {
|
||||
other.set("Attachments", jpdfAttachments);
|
||||
}
|
||||
}
|
||||
|
||||
ArrayNode existingBookmarks =
|
||||
other.has("Bookmarks/Outline/TOC")
|
||||
&& other.get("Bookmarks/Outline/TOC").isArray()
|
||||
? (ArrayNode) other.get("Bookmarks/Outline/TOC")
|
||||
: null;
|
||||
if (existingBookmarks == null || existingBookmarks.isEmpty()) {
|
||||
ArrayNode jpdfBookmarks = objectMapper.createArrayNode();
|
||||
for (Bookmark bm : jpdfiumDoc.bookmarks()) {
|
||||
appendBookmarkFlat(bm, jpdfBookmarks);
|
||||
}
|
||||
if (!jpdfBookmarks.isEmpty()) {
|
||||
other.set("Bookmarks/Outline/TOC", jpdfBookmarks);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("JPDFium Other augment failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendBookmarkFlat(Bookmark bm, ArrayNode arr) {
|
||||
if (bm == null) return;
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
if (bm.title() != null) {
|
||||
node.put("Title", bm.title());
|
||||
}
|
||||
arr.add(node);
|
||||
if (bm.hasChildren()) {
|
||||
for (Bookmark child : bm.children()) {
|
||||
appendBookmarkFlat(child, arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectNode extractBasicInfo(PDDocument document, long fileSizeInBytes) {
|
||||
ObjectNode basicInfo = objectMapper.createObjectNode();
|
||||
|
||||
|
||||
+139
@@ -5,14 +5,19 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -21,16 +26,23 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
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.model.api.misc.MetadataRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class MetadataControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@InjectMocks private MetadataController metadataController;
|
||||
|
||||
private PDDocument mockDocument;
|
||||
@@ -46,6 +58,19 @@ class MetadataControllerTest {
|
||||
mockFile = mock(MultipartFile.class);
|
||||
|
||||
when(mockFile.getOriginalFilename()).thenReturn("test.pdf");
|
||||
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(any(String.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("mdt", 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;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -299,4 +324,118 @@ class MetadataControllerTest {
|
||||
|
||||
verify(mockInfo).setCreationDate(any());
|
||||
}
|
||||
|
||||
private byte[] buildSourcePdf() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMetadata_oracleVerifiesInfoDictFields() throws Exception {
|
||||
byte[] source = buildSourcePdf();
|
||||
MockMultipartFile uploaded =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "src.pdf", MediaType.APPLICATION_PDF_VALUE, source);
|
||||
|
||||
PDDocument liveDoc = Loader.loadPDF(source);
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(liveDoc);
|
||||
|
||||
MetadataRequest request = new MetadataRequest();
|
||||
request.setFileInput(uploaded);
|
||||
request.setDeleteAll(false);
|
||||
request.setTitle("Oracle Title");
|
||||
request.setAuthor("Oracle Author");
|
||||
request.setSubject("Oracle Subject");
|
||||
request.setKeywords("a,b,c");
|
||||
request.setCreator("Oracle Creator");
|
||||
request.setProducer("Oracle Producer");
|
||||
request.setTrapped("True");
|
||||
request.setAllRequestParams(new HashMap<>());
|
||||
|
||||
ResponseEntity<Resource> response = metadataController.metadata(request);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
byte[] outputBytes = response.getBody().getContentAsByteArray();
|
||||
try (PDDocument reloaded = Loader.loadPDF(outputBytes)) {
|
||||
PDDocumentInformation info = reloaded.getDocumentInformation();
|
||||
assertEquals("Oracle Title", info.getTitle());
|
||||
assertEquals("Oracle Author", info.getAuthor());
|
||||
assertEquals("Oracle Subject", info.getSubject());
|
||||
assertEquals("a,b,c", info.getKeywords());
|
||||
assertEquals("Oracle Creator", info.getCreator());
|
||||
assertEquals("Oracle Producer", info.getProducer());
|
||||
assertEquals("True", info.getTrapped());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMetadata_oracleCustomMetadataPersists() throws Exception {
|
||||
byte[] source = buildSourcePdf();
|
||||
MockMultipartFile uploaded =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "src.pdf", MediaType.APPLICATION_PDF_VALUE, source);
|
||||
|
||||
PDDocument liveDoc = Loader.loadPDF(source);
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(liveDoc);
|
||||
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("MyCustomField", "MyCustomValue");
|
||||
params.put("customKey1", "ResolvedKey");
|
||||
params.put("customValue1", "ResolvedValue");
|
||||
|
||||
MetadataRequest request = new MetadataRequest();
|
||||
request.setFileInput(uploaded);
|
||||
request.setDeleteAll(false);
|
||||
request.setAllRequestParams(params);
|
||||
|
||||
ResponseEntity<Resource> response = metadataController.metadata(request);
|
||||
byte[] outputBytes = response.getBody().getContentAsByteArray();
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(outputBytes)) {
|
||||
PDDocumentInformation info = reloaded.getDocumentInformation();
|
||||
assertEquals("MyCustomValue", info.getCustomMetadataValue("MyCustomField"));
|
||||
assertEquals("ResolvedValue", info.getCustomMetadataValue("ResolvedKey"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMetadata_oracleDeleteAllClearsInfoFields() throws Exception {
|
||||
byte[] source;
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
PDDocumentInformation seedInfo = new PDDocumentInformation();
|
||||
seedInfo.setTitle("To Be Removed");
|
||||
seedInfo.setAuthor("To Be Removed");
|
||||
doc.setDocumentInformation(seedInfo);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
source = baos.toByteArray();
|
||||
}
|
||||
|
||||
MockMultipartFile uploaded =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "src.pdf", MediaType.APPLICATION_PDF_VALUE, source);
|
||||
|
||||
PDDocument liveDoc = Loader.loadPDF(source);
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(liveDoc);
|
||||
|
||||
MetadataRequest request = new MetadataRequest();
|
||||
request.setFileInput(uploaded);
|
||||
request.setDeleteAll(true);
|
||||
request.setAllRequestParams(new HashMap<>());
|
||||
|
||||
ResponseEntity<Resource> response = metadataController.metadata(request);
|
||||
byte[] outputBytes = response.getBody().getContentAsByteArray();
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(outputBytes)) {
|
||||
PDDocumentInformation info = reloaded.getDocumentInformation();
|
||||
assertNull(info.getTitle());
|
||||
assertNull(info.getAuthor());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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,80 @@
|
||||
# JPDFium Metadata Audit
|
||||
|
||||
Branch: `feat/jpdfium-metadata` (commit `1f93525dc`) vs `origin/feat/jpdfium-integration`.
|
||||
|
||||
## What the branch changes
|
||||
|
||||
Single commit, 3 files, +279 / -23 lines.
|
||||
|
||||
Production code:
|
||||
- `MetadataController.metadata` - PDFBox still owns the entire read-modify-write flow. The branch adds a 14-line `validateWithJpdfium(MultipartFile)` helper that calls `PdfDocument.open(bytes)` inside a try-with-resources and swallows any throw via `ExceptionUtils.logException`. It runs immediately before `pdfDocumentFactory.load(...)`. This is a pre-validate hop, not a migration. The diff also strips a dozen low-value `// extract X` and `// check X` comments.
|
||||
- `GetInfoOnPDF.getPdfInfo` - opens a JPDFium `PdfDocument` alongside the existing `PDDocument` (both in one try-with-resources). Two augmentation hooks consume that handle:
|
||||
- `augmentMetadataFromJpdfium(metadata, jpdfiumDoc)` - fills `Title / Author / Subject / Keywords / Producer / Creator` in the `Metadata` block, but only when the PDFBox `Info` dictionary read returned null for that key (`putIfAbsentText`). `CreationDate` / `ModDate` are NOT augmented even though JPDFium exposes them.
|
||||
- `augmentOtherFromJpdfium(other, jpdfiumDoc)` - replaces the existing `Attachments` array if PDFBox produced an empty array, then replaces `Bookmarks/Outline/TOC` if PDFBox produced an empty array. Both replacements flatten the JPDFium tree (`appendBookmarkFlat`) into the same shape PDFBox produced.
|
||||
- Imports added: `PdfDocument`, `Attachment`, `Bookmark`, `MetadataTag`, `ExceptionUtils`.
|
||||
|
||||
Tests:
|
||||
- `MetadataControllerTest` gets three new "oracle" tests (139 added lines) that round-trip a PDFBox-built input through the controller and assert the saved bytes still contain the expected `Info` dictionary fields. They exercise the write path - the only JPDFium contribution they touch is the pre-validate, and they don't assert anything about it. Useful regression coverage either way.
|
||||
|
||||
## MetadataController - is the pre-validate worth the diff?
|
||||
|
||||
No. The pre-validate is a structural sanity check that runs *before* PDFBox parses the bytes. PDFBox then re-parses the same bytes immediately. There is zero data sharing between the two opens: the JPDFium handle is closed inside the helper, then PDFBox loads from `MultipartFile` again. If JPDFium throws, the helper swallows the exception and PDFBox still runs - so the pre-validate cannot prevent any class of failure that PDFBox would not also surface.
|
||||
|
||||
What it does add: one extra `PdfDocument.open(bytes)` call, which under JPDFium 1.0.0 means a native FPDF document open (file-version probe, xref load, catalog dereference). On a moderate doc (80 KB Auto Splitter, 17 pages) the open cost is in the low-to-mid single-digit milliseconds on warm JVM. On a 5 MB doc it is closer to 20 ms. PDFBox then repeats much of that work. Net wall-clock penalty: ~5-25 ms per request, all of it pure waste.
|
||||
|
||||
Heap impact: negligible (JPDFium open allocates native, not Java heap, and the handle is released immediately).
|
||||
|
||||
Risk: a `validateWithJpdfium` log line on a malformed-but-PDFBox-recoverable PDF will produce a spurious warn-level entry that operators will rightly question. Several real-world PDFs in the test suite trigger this (PDF/A nuances, signed docs with unusual xref).
|
||||
|
||||
No metadata write benefit is possible because JPDFium 1.0.x exposes only metadata GETTERS - see "Feature requests" below.
|
||||
|
||||
## GetInfoOnPDF - is the augmentation actually new information?
|
||||
|
||||
Walk the three fields:
|
||||
|
||||
1. **`Metadata.{Title,Author,Subject,Keywords,Producer,Creator}`** - PDFBox `PDDocumentInformation` reads the same `Info` dictionary keys JPDFium reads via `FPDF_GetMetaText`. Encoding handling differs slightly (JPDFium normalizes UTF-16 BE/LE with BOM more aggressively than older PDFBox versions), so in theory there is a class of malformed `Info` strings where PDFBox returns null and JPDFium returns a value. In practice with PDFBox 3.x (current dependency) this overlap is near-empty. We could not produce a fixture in the existing test corpus where this branch added a non-null value the old code missed.
|
||||
|
||||
Notable miss: `CreationDate` and `ModDate` are NOT augmented. Those are the two `Info` fields most prone to encoding glitches, and JPDFium exposes them as plain strings via `MetadataTag.CREATION_DATE / MOD_DATE`. The branch could have added value here and did not.
|
||||
|
||||
2. **`Other.Attachments`** - Triggered only when PDFBox's `extractAttachments` (page-annotation walk for `PDAnnotationFileAttachment`) returns an empty array. JPDFium's `attachments()` reads from the document-catalog `EmbeddedFiles` name tree, which is the *same source* that PDFBox `extractEmbeddedFiles` already populates into the sibling `Other.EmbeddedFiles` array. So when this fallback fires, the response now contains the same data twice (once under `EmbeddedFiles`, once under `Attachments`), just shaped differently. That is duplicated information, not new information. And the JPDFium variant loses `MimeType`, `CreationDate`, `ModificationDate`, `Description` that `EmbeddedFiles` already had.
|
||||
|
||||
Worse: `PdfAttachments.list` in JPDFium 1.0.0 eagerly loads every attachment payload (`getAttachmentFile`) into Java `byte[]` just to read names. For a doc with N large embedded files, this populate-on-fallback path materializes N payloads in heap solely to write `{"Name": "..."}` JSON entries. Same bug observed on the attachments-images audit.
|
||||
|
||||
3. **`Other.Bookmarks/Outline/TOC`** - Triggered only when PDFBox's outline walk returns an empty array. Both libraries read the same `Outline` tree from the document catalog. The only way PDFBox returns empty and JPDFium does not is if the outline dictionary is structurally malformed in a way PDFBox 3.x rejects but PDFium tolerates. We could not find a fixture in the repo that exhibits this. Even if found, the JPDFium output flattens the tree into a single array - it drops the original hierarchy that the PDFBox path also drops (both use `addOutlinesToArray` / `appendBookmarkFlat`), so structurally it is a wash.
|
||||
|
||||
Wall-clock cost of running `augmentMetadataFromJpdfium` + `augmentOtherFromJpdfium`: one `PdfDocument.open(bytes)`, eight `FPDF_GetMetaText` calls (each is double-call buffer pattern, so 16 native crossings), one `attachments()` call only if PDFBox's array was empty (so usually skipped), one `bookmarks()` walk only if PDFBox's array was empty (usually skipped). On the warm JVM and an 80 KB Auto Splitter, the open+8-metadata-calls path costs in the order of ~3-8 ms. On a larger doc it scales with xref size. No micro-bench was executed (the existing `DecompressPdfBench` harness exists and could be adapted, but the result is bounded above by the document open cost - and the open is the same one already being added on the pre-validate path of every other JPDFium hybrid controller).
|
||||
|
||||
## JPDFium 1.0.x feature gaps - upstream feature requests to file
|
||||
|
||||
These are blockers for moving the *write* side of metadata off PDFBox. Without them, `MetadataController` can only ever be a hybrid:
|
||||
|
||||
1. **`PdfDocument.setMetadata(MetadataTag tag, String value)` / `clearMetadata(MetadataTag)`** - the headline gap. PDFium does not expose `FPDF_SetMetaText` in its public C API at all, so this needs new native plumbing inside JPDFium (likely a small custom shim that writes the `Info` dictionary directly via `CPDF_Document::GetInfo()->SetNewFor<CPDF_String>`). Without this the entire branch cannot migrate the write path.
|
||||
|
||||
2. **`PdfDocument.setInfoFields(DocInfoUpdate update)`** - batched setter for all eight standard tags + custom keys, so the controller doesn't need eight FFI crossings per request. Mirrors PDFBox's `PDDocumentInformation` bulk pattern.
|
||||
|
||||
3. **`PdfDocument.setCustomMetadata(String key, String value)` + `getCustomMetadataKeys()`** - the `MetadataController` request body accepts arbitrary `customKeyN / customValueN` pairs. JPDFium has no concept of custom `Info` keys beyond the eight in `MetadataTag`.
|
||||
|
||||
4. **`PdfDocument.setTrapped(TrappedState state)`** - one of the eight `Info` fields the controller writes. PDFium does not surface `Trapped` even as a getter (PDFBox supports the explicit "True" / "False" / "Unknown" tri-state).
|
||||
|
||||
5. **`PdfDocument.deleteAllMetadata()`** - the `deleteAll=true` request path needs to (a) blank every `Info` key and (b) drop the catalog `Metadata` (XMP) stream. Today PDFBox does both. JPDFium has no XMP awareness at all.
|
||||
|
||||
6. **XMP stream read/write** - the controller writes `Info` but the audit endpoint `GetInfoOnPDF.extractXMPMetadata` reads the catalog `Metadata` (XMP) stream. JPDFium exposes neither read nor write. This blocks any "single source of truth" migration where Stirling could stop maintaining two metadata representations.
|
||||
|
||||
7. **`PdfDocument.metadata(MetadataTag.CREATION_DATE)` returning a parsed `Instant` not a raw `D:YYYYMMDDHHMMSS` string** - convenience. Today the augmentation can't easily feed into `metadata.put("CreationDate", ...)` because that field expects an ISO-8601 string and JPDFium returns the raw PDF date string. The branch sidesteps this by not augmenting those two fields.
|
||||
|
||||
8. **`PdfDocument.attachments()` lazy variant** (carried over from attachments-images audit, equally relevant here) - so the `Other.Attachments` fallback does not eagerly materialize attachment payloads just to read names.
|
||||
|
||||
9. **`Bookmark.target() / pageIndex() / namedDestination()`** - JPDFium `Bookmark` today exposes `title` and children only. To produce richer outline JSON than PDFBox does (rather than dropping to the same flat title-only shape), targets are needed.
|
||||
|
||||
10. **Document-version awareness on open failure** - the pre-validate helper today catches any `Exception` and logs. A typed `JpdfiumOpenException` with a reason enum (`PASSWORD_REQUIRED`, `MALFORMED_XREF`, `UNSUPPORTED_VERSION`, `IO_ERROR`) would let callers decide whether to abort the request or fall through to PDFBox. Currently all failures are indistinguishable.
|
||||
|
||||
## Verdict
|
||||
|
||||
| Controller / method | Verdict | Reason |
|
||||
|---|---|---|
|
||||
| `MetadataController` (pre-validate only) | DROP | Pre-validate adds ~5-25 ms per request and cannot block any failure PDFBox would not also catch. The JPDFium open is re-done by PDFBox immediately after. Zero functional benefit, mild perf regression, spurious-warning risk. Drop the `validateWithJpdfium` call and the JPDFium import. Keep the comment cleanup and the new oracle tests. |
|
||||
| `GetInfoOnPDF` (augmentation) | DEFER | Fields augmented are real but the trigger condition (PDFBox returned null/empty) is rarely satisfied on healthy PDFs, the `Attachments` fallback duplicates `EmbeddedFiles` data with less fidelity, and the eager-load bug in `PdfAttachments.list` amplifies that for large attachments. The `Metadata.{Title..Creator}` augmentation has theoretical value on encoding-malformed Info dicts but no in-corpus fixture proves it. Revisit once JPDFium gets (a) lazy attachments listing and (b) XMP read, at which point a real cross-source merge becomes possible. |
|
||||
|
||||
## Bottom line
|
||||
|
||||
Drop the merge. The `MetadataController` pre-validate is unconditional waste; the `GetInfoOnPDF` augmentations are conditional no-ops on healthy PDFs and conditional duplications on attachment-bearing PDFs. Keep the comment cleanup and the three new oracle tests as a small follow-up PR against `feat/jpdfium-integration` so the regression coverage is not lost. Re-evaluate after JPDFium 1.1.x ships (a) metadata setters / XMP write, (b) `Attachment` metadata fields, and (c) lazy attachment listing - at which point a real migration of the write path becomes possible and the read-side augmentation stops being a strict subset of the PDFBox extractors.
|
||||
@@ -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