# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: dependabot[bot] <[email protected]>
Signed-off-by: Balázs Szücs <[email protected]>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: OUNZAR Aymane <[email protected]>
Co-authored-by: YAOU Reda <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: Balázs Szücs <[email protected]>
Co-authored-by: Ludy <[email protected]>
Co-authored-by: tkymmm <[email protected]>
Co-authored-by: Peter Dave Hello <[email protected]>
Co-authored-by: albanobattistella <[email protected]>
Co-authored-by: PingLin8888 <[email protected]>
Co-authored-by: FdaSilvaYY <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: OteJlo <[email protected]>
Co-authored-by: Angel <[email protected]>
Co-authored-by: Ricardo Catarino <[email protected]>
Co-authored-by: Luis Antonio Argüelles González <[email protected]>
Co-authored-by: Dawid Urbański <[email protected]>
Co-authored-by: Stephan Paternotte <[email protected]>
Co-authored-by: Leonardo Santos Paulucio <[email protected]>
Co-authored-by: hamza khalem <[email protected]>
Co-authored-by: IT Creativity + Art Team <[email protected]>
Co-authored-by: Reece Browne <[email protected]>
Co-authored-by: James Brunton <[email protected]>
Co-authored-by: Victor Villarreal <[email protected]>
This commit is contained in:
Anthony Stirling
2025-12-21 10:40:32 +00:00
committed by GitHub
co-authored by ConnorYoh Connor Yoh OUNZAR Aymane YAOU Reda dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> Balázs Szücs Ludy tkymmm Peter Dave Hello albanobattistella PingLin8888 FdaSilvaYY Copilot OteJlo Angel Ricardo Catarino Luis Antonio Argüelles González Dawid Urbański Stephan Paternotte Leonardo Santos Paulucio hamza khalem IT Creativity + Art Team Reece Browne James Brunton Victor Villarreal
parent a5dcdd5bd9
commit 68ed54e398
343 changed files with 25208 additions and 6588 deletions
@@ -28,6 +28,10 @@ public class ReplaceAndInvertColorFactory {
String backGroundColor,
String textColor) {
if (replaceAndInvertOption == null) {
return null;
}
// Check Ghostscript availability for CMYK conversion
if (replaceAndInvertOption == ReplaceAndInvert.COLOR_SPACE_CONVERSION
&& !endpointConfiguration.isGroupEnabled("Ghostscript")) {
@@ -1,9 +1,15 @@
package stirling.software.SPDF.config;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Configuration;
@@ -12,105 +18,66 @@ import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.RegexPatternUtils;
/**
* Dependency checker that - runs probes in parallel with timeouts (prevents hanging on broken
* PATHs) - supports Windows+Unix in a single place - de-duplicates logic for version extraction &
* command availability - keeps group <-> command mapping and feature formatting tidy & immutable
*/
@Configuration
@Slf4j
public class ExternalAppDepConfig {
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(5);
private static final Pattern VERSION_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+){0,2})");
private final EndpointConfiguration endpointConfiguration;
private volatile boolean dependenciesChecked = false;
private final boolean isWindows =
System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows");
private final String weasyprintPath;
private final String unoconvPath;
private final String calibrePath;
private final String ocrMyPdfPath;
private final String sOfficePath;
/**
* Map of command(binary) -> affected groups (e.g. "gs" -> ["Ghostscript"]). Immutable to avoid
* accidental mutations.
*/
private final Map<String, List<String>> commandToGroupMapping;
private volatile boolean dependenciesChecked = false;
private final ExecutorService pool =
Executors.newFixedThreadPool(
Math.max(2, Runtime.getRuntime().availableProcessors() / 2));
public ExternalAppDepConfig(
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
this.endpointConfiguration = endpointConfiguration;
weasyprintPath = runtimePathConfig.getWeasyPrintPath();
unoconvPath = runtimePathConfig.getUnoConvertPath();
this.weasyprintPath = runtimePathConfig.getWeasyPrintPath();
this.unoconvPath = runtimePathConfig.getUnoConvertPath();
this.calibrePath = runtimePathConfig.getCalibrePath();
this.ocrMyPdfPath = runtimePathConfig.getOcrMyPdfPath();
this.sOfficePath = runtimePathConfig.getSOfficePath();
commandToGroupMapping =
new HashMap<>() {
{
put("gs", List.of("Ghostscript"));
put("ocrmypdf", List.of("OCRmyPDF"));
put("soffice", List.of("LibreOffice"));
put(weasyprintPath, List.of("Weasyprint"));
put("pdftohtml", List.of("Pdftohtml"));
put(unoconvPath, List.of("Unoconvert"));
put("qpdf", List.of("qpdf"));
put("tesseract", List.of("tesseract"));
put("rar", List.of("rar")); // Required for real CBR output
put("magick", List.of("ImageMagick"));
}
};
}
private boolean isCommandAvailable(String command) {
try {
ProcessBuilder processBuilder = new ProcessBuilder();
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
processBuilder.command("where", command);
} else {
processBuilder.command("which", command);
}
Process process = processBuilder.start();
int exitCode = process.waitFor();
return exitCode == 0;
} catch (Exception e) {
log.debug("Error checking for command {}: {}", command, e.getMessage());
return false;
}
}
private List<String> getAffectedFeatures(String group) {
return endpointConfiguration.getEndpointsForGroup(group).stream()
.map(endpoint -> formatEndpointAsFeature(endpoint))
.toList();
}
private String formatEndpointAsFeature(String endpoint) {
// First replace common terms
String feature = endpoint.replace("-", " ").replace("pdf", "PDF").replace("img", "image");
// Split into words and capitalize each word
return Arrays.stream(RegexPatternUtils.getInstance().getWordSplitPattern().split(feature))
.map(word -> capitalizeWord(word))
.collect(Collectors.joining(" "));
}
private String capitalizeWord(String word) {
if (word.isEmpty()) {
return word;
}
if ("pdf".equalsIgnoreCase(word)) {
return "PDF";
}
return word.substring(0, 1).toUpperCase() + word.substring(1).toLowerCase();
}
private void checkDependencyAndDisableGroup(String command) {
boolean isAvailable = isCommandAvailable(command);
if (!isAvailable) {
List<String> affectedGroups = commandToGroupMapping.get(command);
if (affectedGroups != null) {
for (String group : affectedGroups) {
List<String> affectedFeatures = getAffectedFeatures(group);
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
command,
group,
!affectedFeatures.isEmpty()
? String.join(", ", affectedFeatures)
: "unknown");
}
}
}
Map<String, List<String>> tmp = new HashMap<>();
tmp.put("gs", List.of("Ghostscript"));
tmp.put(ocrMyPdfPath, List.of("OCRmyPDF"));
tmp.put(sOfficePath, List.of("LibreOffice"));
tmp.put(weasyprintPath, List.of("Weasyprint"));
tmp.put("pdftohtml", List.of("Pdftohtml"));
tmp.put(unoconvPath, List.of("Unoconvert"));
tmp.put("qpdf", List.of("qpdf"));
tmp.put("tesseract", List.of("tesseract"));
tmp.put("rar", List.of("rar")); // Required for real CBR output
tmp.put(calibrePath, List.of("Calibre"));
tmp.put("ffmpeg", List.of("FFmpeg"));
tmp.put("magick", List.of("ImageMagick"));
this.commandToGroupMapping = Collections.unmodifiableMap(tmp);
}
public boolean isDependenciesChecked() {
@@ -119,57 +86,282 @@ public class ExternalAppDepConfig {
@PostConstruct
public void checkDependencies() {
// Check core dependencies
checkDependencyAndDisableGroup("gs");
checkDependencyAndDisableGroup("ocrmypdf");
checkDependencyAndDisableGroup("tesseract");
checkDependencyAndDisableGroup("soffice");
checkDependencyAndDisableGroup("qpdf");
checkDependencyAndDisableGroup(weasyprintPath);
checkDependencyAndDisableGroup("pdftohtml");
checkDependencyAndDisableGroup(unoconvPath);
checkDependencyAndDisableGroup("rar");
checkDependencyAndDisableGroup("magick");
// Special handling for Python/OpenCV dependencies
boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python");
if (!pythonAvailable) {
List<String> pythonFeatures = getAffectedFeatures("Python");
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
String.join(", ", pythonFeatures),
String.join(", ", openCVFeatures));
} else {
// If Python is available, check for OpenCV
try {
ProcessBuilder processBuilder = new ProcessBuilder();
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
processBuilder.command("python", "-c", "import cv2");
} else {
processBuilder.command("python3", "-c", "import cv2");
}
Process process = processBuilder.start();
int exitCode = process.waitFor();
if (exitCode != 0) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"OpenCV not available in Python - Disabling OpenCV features: {}",
String.join(", ", openCVFeatures));
}
} catch (Exception e) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
try {
// core checks in parallel
List<Callable<Void>> tasks =
commandToGroupMapping.keySet().stream()
.<Callable<Void>>map(
cmd ->
() -> {
checkDependencyAndDisableGroup(cmd);
return null;
})
.collect(Collectors.toList());
invokeAllWithTimeout(tasks, DEFAULT_TIMEOUT.plusSeconds(3));
// Python / OpenCV special handling
checkPythonAndOpenCV();
dependenciesChecked = true;
} finally {
endpointConfiguration.logDisabledEndpointsSummary();
pool.shutdown();
}
}
private void checkDependencyAndDisableGroup(String command) {
boolean available = isCommandAvailable(command);
if (!available) {
List<String> affectedGroups = commandToGroupMapping.get(command);
if (affectedGroups == null || affectedGroups.isEmpty()) return;
for (String group : affectedGroups) {
List<String> affectedFeatures = getAffectedFeatures(group);
endpointConfiguration.disableGroup(group);
log.warn(
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
e.getMessage(),
String.join(", ", openCVFeatures));
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
command,
group,
affectedFeatures.isEmpty()
? "unknown"
: String.join(", ", affectedFeatures));
}
return;
}
// Extra: enforce minimum WeasyPrint version if command matches
if (isWeasyprint(command)) {
Optional<String> version = getVersionSafe(command, "--version");
version.ifPresentOrElse(
v -> {
Version installed = new Version(v);
// https://www.courtbouillon.org/blog/00040-weasyprint-58/
Version required = new Version("58.0");
if (installed.compareTo(required) < 0) {
List<String> affectedGroups =
commandToGroupMapping.getOrDefault(
command, List.of("Weasyprint"));
for (String group : affectedGroups) {
endpointConfiguration.disableGroup(group);
}
log.warn(
"WeasyPrint version {} is below required {} - disabling"
+ " group(s): {}",
installed,
required,
String.join(", ", affectedGroups));
} else {
log.info("WeasyPrint {} meets minimum {}", installed, required);
}
},
() ->
log.warn(
"WeasyPrint version could not be determined ({} --version)",
command));
}
// Extra: enforce minimum qpdf version if command matches
if (isQpdf(command)) {
Optional<String> version = getVersionSafe(command, "--version");
version.ifPresentOrElse(
v -> {
Version installed = new Version(v);
Version required = new Version("12.0.0");
if (installed.compareTo(required) < 0) {
List<String> affectedGroups =
commandToGroupMapping.getOrDefault(command, List.of("qpdf"));
for (String group : affectedGroups) {
endpointConfiguration.disableGroup(group);
}
log.warn(
"qpdf version {} is below required {} - disabling group(s): {}",
installed,
required,
String.join(", ", affectedGroups));
} else {
log.info("qpdf {} meets minimum {}", installed, required);
}
},
() -> log.warn("qpdf version could not be determined ({} --version)", command));
}
}
private boolean isWeasyprint(String command) {
return Objects.equals(command, weasyprintPath)
|| command.toLowerCase(Locale.ROOT).contains("weasyprint");
}
private boolean isQpdf(String command) {
return command.toLowerCase(Locale.ROOT).contains("qpdf");
}
private List<String> getAffectedFeatures(String group) {
List<String> endpoints = new ArrayList<>(endpointConfiguration.getEndpointsForGroup(group));
return endpoints.stream().map(this::formatEndpointAsFeature).toList();
}
private String formatEndpointAsFeature(String endpoint) {
String feature = endpoint.replace("-", " ").replace("pdf", "PDF").replace("img", "image");
return Arrays.stream(RegexPatternUtils.getInstance().getWordSplitPattern().split(feature))
.map(this::capitalizeWord)
.collect(Collectors.joining(" "));
}
private String capitalizeWord(String word) {
if (word == null || word.isEmpty()) return word;
if ("pdf".equalsIgnoreCase(word)) return "PDF";
return word.substring(0, 1).toUpperCase(Locale.ROOT)
+ word.substring(1).toLowerCase(Locale.ROOT);
}
private void checkPythonAndOpenCV() {
String python = findFirstAvailable(List.of("python3", "python")).orElse(null);
if (python == null) {
disablePythonAndOpenCV("Python interpreter not found on PATH");
return;
}
// Check OpenCV import
int ec = runAndWait(List.of(python, "-c", "import cv2"), DEFAULT_TIMEOUT).exitCode();
if (ec != 0) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
log.warn(
"OpenCV not available in Python - Disabling OpenCV features: {}",
String.join(", ", openCVFeatures));
}
}
private void disablePythonAndOpenCV(String reason) {
List<String> pythonFeatures = getAffectedFeatures("Python");
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("Python");
endpointConfiguration.disableGroup("OpenCV");
log.warn(
"Missing dependency: Python (reason: {}) - Disabling Python features: {} and OpenCV"
+ " features: {}",
reason,
String.join(", ", pythonFeatures),
String.join(", ", openCVFeatures));
}
private Optional<String> findFirstAvailable(List<String> commands) {
for (String c : commands) {
if (isCommandAvailable(c)) return Optional.of(c);
}
return Optional.empty();
}
private boolean isCommandAvailable(String command) {
// First try OS-native lookup
List<String> lookup = isWindows ? List.of("where", command) : List.of("which", command);
ProbeResult res = runAndWait(lookup, DEFAULT_TIMEOUT);
if (res.exitCode() == 0) return true;
// Fallback: try `--version` when helpful (covers py-launcher shims on Windows etc.)
ProbeResult ver = runAndWait(List.of(command, "--version"), DEFAULT_TIMEOUT);
return ver.exitCode() == 0;
}
private Optional<String> getVersionSafe(String command, String arg) {
try {
ProbeResult res = runAndWait(List.of(command, arg), DEFAULT_TIMEOUT);
if (res.exitCode() != 0) return Optional.empty();
String text = res.combined();
Matcher m = VERSION_PATTERN.matcher(text);
return m.find() ? Optional.of(m.group(1)) : Optional.empty();
} catch (Exception e) {
return Optional.empty();
}
}
private void invokeAllWithTimeout(List<Callable<Void>> tasks, Duration timeout) {
try {
List<Future<Void>> futures =
pool.invokeAll(tasks, timeout.toMillis(), TimeUnit.MILLISECONDS);
for (Future<Void> f : futures) {
try {
f.get();
} catch (Exception ignored) {
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private ProbeResult runAndWait(List<String> cmd, Duration timeout) {
ProcessBuilder pb = new ProcessBuilder(cmd);
try {
Process p = pb.start();
boolean finished = p.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (!finished) {
p.destroyForcibly();
return new ProbeResult(124, "", "timeout");
}
String out = readStream(p.getInputStream());
String err = readStream(p.getErrorStream());
int ec = p.exitValue();
return new ProbeResult(ec, out, err);
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
return new ProbeResult(127, "", String.valueOf(e.getMessage()));
}
}
private static String readStream(InputStream in) throws IOException {
StringBuilder sb = new StringBuilder();
try (BufferedReader br =
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (sb.length() > 0) sb.append('\n');
sb.append(line);
}
}
endpointConfiguration.logDisabledEndpointsSummary();
dependenciesChecked = true;
log.info("Dependency checks completed");
return sb.toString().trim();
}
private record ProbeResult(int exitCode, String stdout, String stderr) {
String combined() {
return (stdout == null ? "" : stdout) + "\n" + (stderr == null ? "" : stderr);
}
}
/** Simple numeric version comparator (major.minor.patch). */
static class Version implements Comparable<Version> {
private final int[] parts;
Version(String ver) {
String[] tokens = ver.split("\\.");
parts = new int[3];
for (int i = 0; i < 3; i++) {
if (i < tokens.length) {
try {
parts[i] = Integer.parseInt(tokens[i]);
} catch (NumberFormatException e) {
parts[i] = 0;
}
} else {
parts[i] = 0;
}
}
}
@Override
public int compareTo(Version o) {
for (int i = 0; i < 3; i++) {
int a = parts[i];
int b = o.parts[i];
if (a != b) return Integer.compare(a, b);
}
return 0;
}
@Override
public String toString() {
return parts[0] + "." + parts[1] + "." + parts[2];
}
}
}
@@ -96,7 +96,7 @@ public class OpenApiConfig {
Components components = new Components().addSchemas("ErrorResponse", errorResponseSchema);
if (!applicationProperties.getSecurity().getEnableLogin()) {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return openAPI.components(components);
} else {
SecurityScheme apiKeyScheme =
@@ -1,5 +1,6 @@
package stirling.software.SPDF.controller.api;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
@@ -13,136 +14,279 @@ 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.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
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.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.WebResponseUtils;
@GeneralApi
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
@Slf4j
public class CropController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final EndpointConfiguration endpointConfiguration;
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 boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
private final CustomPDFDocumentFactory pdfDocumentFactory;
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};
}
// Sample every nth pixel for large images to reduce processing time
int step = (width > 2000 || height > 2000) ? 2 : 1;
int top = 0;
boolean found = false;
for (int y = 0; y < height && !found; y += step) {
for (int x = 0; x < width; x += step) {
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
top = y;
found = true;
break;
}
}
}
int bottom = height - 1;
found = false;
for (int y = height - 1; y >= 0 && !found; y -= step) {
for (int x = 0; x < width; x += step) {
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
bottom = y;
found = true;
break;
}
}
}
int left = 0;
found = false;
for (int x = 0; x < width && !found; x += step) {
for (int y = top; y <= bottom; y += step) {
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
left = x;
found = true;
break;
}
}
}
int right = width - 1;
found = false;
for (int x = width - 1; x >= 0 && !found; x -= step) {
for (int y = top; y <= bottom; y += step) {
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
right = x;
found = true;
break;
}
}
}
// Return bounds in format: [left, bottom, right, top]
// Note: Image coordinates are top-down, PDF coordinates are bottom-up
return new int[] {left, height - bottom - 1, right, height - top - 1};
}
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
private static boolean isWhite(int rgb, int threshold) {
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
return r >= threshold && g >= threshold && b >= threshold;
}
@PostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Crops a PDF document",
description =
"This operation takes an input PDF file and crops it according to the given"
+ " coordinates. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> cropPdf(@ModelAttribute CropPdfForm request) throws IOException {
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
if (request.isAutoCrop()) {
return cropWithAutomaticDetection(request);
}
if (request.getX() == null
|| request.getY() == null
|| request.getWidth() == null
|| request.getHeight() == null) {
throw new IllegalArgumentException(
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
}
if (request.isRemoveDataOutsideCrop()) {
return cropWithGhostscript(request);
} else {
if (request.isRemoveDataOutsideCrop()) {
log.warn(
"Ghostscript not available - 'removeDataOutsideCrop' option requires Ghostscript. Falling back to visual crop only.");
}
return cropWithPDFBox(request);
}
}
private ResponseEntity<byte[]> cropWithAutomaticDetection(@ModelAttribute CropPdfForm request)
throws IOException {
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
PDFRenderer renderer = new PDFRenderer(sourceDocument);
LayerUtility layerUtility = new LayerUtility(newDocument);
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();
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));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
}
}
}
private ResponseEntity<byte[]> cropWithPDFBox(@ModelAttribute CropPdfForm request)
throws IOException {
PDDocument sourceDocument = pdfDocumentFactory.load(request);
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
int totalPages = sourceDocument.getNumberOfPages();
LayerUtility layerUtility = new LayerUtility(newDocument);
int totalPages = sourceDocument.getNumberOfPages();
for (int i = 0; i < totalPages; i++) {
PDPage sourcePage = sourceDocument.getPage(i);
LayerUtility layerUtility = new LayerUtility(newDocument);
// 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);
for (int i = 0; i < totalPages; i++) {
PDPage sourcePage = sourceDocument.getPage(i);
contentStream.saveGraphicsState();
// Create a new page with the size of the source page
PDPage newPage = new PDPage(sourcePage.getMediaBox());
newDocument.addPage(newPage);
PDPageContentStream contentStream =
new PDPageContentStream(newDocument, newPage, AppendMode.OVERWRITE, true, true);
// Define the crop area
contentStream.addRect(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight());
contentStream.clip();
// Import the source page as a form XObject
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, i);
// Draw the entire formXObject
contentStream.drawForm(formXObject);
contentStream.saveGraphicsState();
contentStream.restoreGraphicsState();
}
// Define the crop area
contentStream.addRect(
request.getX(), request.getY(), request.getWidth(), request.getHeight());
contentStream.clip();
// Now, set the new page's media box to the cropped size
newPage.setMediaBox(
new PDRectangle(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight()));
}
// Draw the entire formXObject
contentStream.drawForm(formXObject);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
contentStream.restoreGraphicsState();
contentStream.close();
// Now, set the new page's media box to the cropped size
newPage.setMediaBox(
new PDRectangle(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight()));
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
newDocument.close();
sourceDocument.close();
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
}
private ResponseEntity<byte[]> cropWithGhostscript(@ModelAttribute CropPdfForm request)
throws IOException {
PDDocument sourceDocument = pdfDocumentFactory.load(request);
Path tempInputFile = null;
Path tempOutputFile = null;
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
PDPage page = sourceDocument.getPage(i);
PDRectangle cropBox =
new PDRectangle(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight());
page.setCropBox(cropBox);
}
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
PDPage page = sourceDocument.getPage(i);
PDRectangle cropBox =
new PDRectangle(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight());
page.setCropBox(cropBox);
}
Path tempInputFile = Files.createTempFile("crop_input", ".pdf");
Path tempOutputFile = Files.createTempFile("crop_output", ".pdf");
tempInputFile = Files.createTempFile(TEMP_INPUT_PREFIX, PDF_EXTENSION);
tempOutputFile = Files.createTempFile(TEMP_OUTPUT_PREFIX, PDF_EXTENSION);
try {
// Save the source document with crop boxes
sourceDocument.save(tempInputFile.toFile());
sourceDocument.close();
// Execute Ghostscript to process the crop boxes
ProcessExecutor processExecutor =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
List<String> command =
@@ -160,19 +304,34 @@ public class CropController {
return WebResponseUtils.bytesToWebResponse(
pdfContent,
request.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_cropped.pdf");
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Ghostscript processing was interrupted", e);
throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e);
} finally {
try {
if (tempInputFile != null) {
Files.deleteIfExists(tempInputFile);
}
if (tempOutputFile != null) {
Files.deleteIfExists(tempOutputFile);
} catch (IOException e) {
log.debug("Failed to delete temporary files", e);
}
}
}
private record CropBounds(float x, float y, float width, float height) {
static CropBounds fromPixels(int[] pixelBounds, float scaleX, float scaleY) {
if (pixelBounds.length != 4) {
throw new IllegalArgumentException(
"pixelBounds array must contain exactly 4 elements: [x1, y1, x2, y2]");
}
float x = pixelBounds[0] * scaleX;
float y = pixelBounds[1] * scaleY;
float width = (pixelBounds[2] - pixelBounds[0]) * scaleX;
float height = (pixelBounds[3] - pixelBounds[1]) * scaleY;
return new CropBounds(x, y, width, height);
}
}
}
@@ -10,65 +10,53 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@RestController
@RequestMapping("/api/v1/general")
@Slf4j
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class EditTableOfContentsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
@AutoJobPostMapping(
value = "/extract-bookmarks",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PostMapping(value = "/extract-bookmarks", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@ResponseBody
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
@RequestParam("file") MultipartFile file) throws Exception {
PDDocument document = null;
try {
document = pdfDocumentFactory.load(file);
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
throws Exception {
try (PDDocument document = pdfDocumentFactory.load(file)) {
PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
if (outline == null) {
log.info("No outline/bookmarks found in PDF");
return ResponseEntity.ok(new ArrayList<>());
return new ArrayList<>();
}
return ResponseEntity.ok(extractBookmarkItems(document, outline));
} finally {
if (document != null) {
document.close();
}
return extractBookmarkItems(document, outline);
}
}
@@ -97,7 +85,6 @@ public class EditTableOfContentsController {
PDOutlineItem child = current.getFirstChild();
if (child != null) {
List<Map<String, Object>> children = new ArrayList<>();
PDOutlineNode parent = current;
while (child != null) {
// Recursively process child items
@@ -155,20 +142,16 @@ public class EditTableOfContentsController {
return bookmark;
}
@AutoJobPostMapping(
value = "/edit-table-of-contents",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@PostMapping(value = "/edit-table-of-contents", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Edit Table of Contents",
description = "Add or edit bookmarks/table of contents in a PDF document.")
public ResponseEntity<byte[]> editTableOfContents(
@ModelAttribute EditTableOfContentsRequest request) throws Exception {
MultipartFile file = request.getFileInput();
PDDocument document = null;
try {
document = pdfDocumentFactory.load(file);
try (PDDocument document = pdfDocumentFactory.load(file);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
// Parse the bookmark data from JSON
List<BookmarkItem> bookmarks =
@@ -183,18 +166,12 @@ public class EditTableOfContentsController {
addBookmarksToOutline(document, outline, bookmarks);
// Save the document to a byte array
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(),
GeneralUtils.generateFilename(file.getOriginalFilename(), "_with_toc.pdf"),
MediaType.APPLICATION_PDF);
} finally {
if (document != null) {
document.close();
}
}
}
@@ -69,19 +69,29 @@ public class MergeController {
// fileOrder is newline-delimited original filenames in the desired order.
private static MultipartFile[] reorderFilesByProvidedOrder(
MultipartFile[] files, String fileOrder) {
String[] desired = fileOrder.split("\n", -1);
// Split by various line endings and trim each entry
String[] desired =
stirling.software.common.util.RegexPatternUtils.getInstance()
.getNewlineSplitPattern()
.split(fileOrder);
List<MultipartFile> remaining = new ArrayList<>(Arrays.asList(files));
List<MultipartFile> ordered = new ArrayList<>(files.length);
for (String name : desired) {
if (name == null || name.isEmpty()) continue;
name = name.trim();
if (name.isEmpty()) {
log.debug("Skipping empty entry");
continue;
}
int idx = indexOfByOriginalFilename(remaining, name);
if (idx >= 0) {
ordered.add(remaining.remove(idx));
} else {
log.debug("Filename from order list not found in uploaded files: {}", name);
}
}
// Append any files not explicitly listed, preserving their relative order
ordered.addAll(remaining);
return ordered.toArray(new MultipartFile[0]);
}
@@ -276,8 +286,10 @@ public class MergeController {
// 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(
@@ -14,32 +14,34 @@ import org.apache.pdfbox.util.Matrix;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralFormCopyUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
@Slf4j
public class MultiPageLayoutController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@AutoJobPostMapping(
value = "/multi-page-layout",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@PostMapping(value = "/multi-page-layout", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Merge multiple pages of a PDF document into a single page",
description =
@@ -55,7 +57,11 @@ public class MultiPageLayoutController {
if (pagesPerSheet != 2
&& pagesPerSheet != 3
&& pagesPerSheet != (int) Math.sqrt(pagesPerSheet) * Math.sqrt(pagesPerSheet)) {
throw new IllegalArgumentException("pagesPerSheet must be 2, 3 or a perfect square");
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"pagesPerSheet",
"must be 2, 3 or a perfect square");
}
int cols =
@@ -136,6 +142,26 @@ public class MultiPageLayoutController {
contentStream.close();
// If any source page is rotated, skip form copying/transformation entirely
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
if (hasRotation) {
log.info("Source document has rotated pages; skipping form field copying.");
} else {
try {
GeneralFormCopyUtils.copyAndTransformFormFields(
sourceDocument,
newDocument,
totalPages,
pagesPerSheet,
cols,
rows,
cellWidth,
cellHeight);
} catch (Exception e) {
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
}
}
sourceDocument.close();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -26,6 +26,7 @@ import stirling.software.SPDF.model.api.general.OverlayPdfsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -61,7 +62,8 @@ public class PdfOverlayController {
int[] counts = request.getCounts(); // Used for FixedRepeatOverlay mode
try (PDDocument basePdf = pdfDocumentFactory.load(baseFile);
Overlay overlay = new Overlay()) {
Overlay overlay = new Overlay();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Map<Integer, String> overlayGuide =
prepareOverlayGuide(
basePdf.getNumberOfPages(),
@@ -77,7 +79,6 @@ public class PdfOverlayController {
overlay.setOverlayPosition(Overlay.Position.BACKGROUND);
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
overlay.overlay(overlayGuide).save(outputStream);
byte[] data = outputStream.toByteArray();
String outputFilename =
@@ -116,7 +117,8 @@ public class PdfOverlayController {
fixedRepeatOverlay(overlayGuide, overlayFiles, counts, basePageCount);
break;
default:
throw new IllegalArgumentException("Invalid overlay mode");
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", mode);
}
return overlayGuide;
}
@@ -138,12 +140,11 @@ public class PdfOverlayController {
overlayFileIndex = (overlayFileIndex + 1) % overlayFiles.length;
}
try (PDDocument overlayPdf = Loader.loadPDF(overlayFiles[overlayFileIndex])) {
PDDocument singlePageDocument = new PDDocument();
try (PDDocument overlayPdf = Loader.loadPDF(overlayFiles[overlayFileIndex]);
PDDocument singlePageDocument = new PDDocument()) {
singlePageDocument.addPage(overlayPdf.getPage(pageCountInCurrentOverlay));
File tempFile = Files.createTempFile("overlay-page-", ".pdf").toFile();
singlePageDocument.save(tempFile);
singlePageDocument.close();
overlayGuide.put(basePageIndex, tempFile.getAbsolutePath());
tempFiles.add(tempFile); // Keep track of the temporary file for cleanup
@@ -179,8 +180,11 @@ public class PdfOverlayController {
Map<Integer, String> overlayGuide, File[] overlayFiles, int[] counts, int basePageCount)
throws IOException {
if (overlayFiles.length != counts.length) {
throw new IllegalArgumentException(
"Counts array length must match the number of overlay files");
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"counts array",
"length must match the number of overlay files");
}
int currentPage = 1;
for (int i = 0; i < overlayFiles.length; i++) {
@@ -200,6 +204,3 @@ public class PdfOverlayController {
}
}
}
// Additional classes like OverlayPdfsRequest, WebResponseUtils, etc. are assumed to be defined
// elsewhere.
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -202,7 +203,7 @@ public class RearrangePagesPDFController {
private List<Integer> processSortTypes(String sortTypes, int totalPages, String pageOrder) {
try {
SortTypes mode = SortTypes.valueOf(sortTypes.toUpperCase());
SortTypes mode = SortTypes.valueOf(sortTypes.toUpperCase(Locale.ROOT));
return switch (mode) {
case REVERSE_ORDER -> reverseOrder(totalPages);
case DUPLEX_SORT -> duplexSort(totalPages);
@@ -214,7 +215,12 @@ public class RearrangePagesPDFController {
case REMOVE_LAST -> removeLast(totalPages);
case REMOVE_FIRST_AND_LAST -> removeFirstAndLast(totalPages);
case DUPLICATE -> duplicate(totalPages, pageOrder);
default -> throw new IllegalArgumentException("Unsupported custom mode");
default ->
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"custom mode",
"unsupported");
};
} catch (IllegalArgumentException e) {
log.error("Unsupported custom mode", e);
@@ -246,7 +252,7 @@ public class RearrangePagesPDFController {
List<Integer> newPageOrder;
if (sortType != null
&& !sortType.isEmpty()
&& !"custom".equals(sortType.toLowerCase())) {
&& !"custom".equals(sortType.toLowerCase(Locale.ROOT))) {
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
} else {
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
@@ -259,18 +265,17 @@ public class RearrangePagesPDFController {
newPages.add(document.getPage(newPageOrder.get(i)));
}
// Remove all the pages from the original document
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
document.removePage(i);
}
// Create a new document based on the original one
PDDocument rearrangedDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
// Add the pages in the new order
for (PDPage page : newPages) {
document.addPage(page);
rearrangedDocument.addPage(page);
}
return WebResponseUtils.pdfDocToWebResponse(
document,
rearrangedDocument,
GeneralUtils.generateFilename(
pdfFile.getOriginalFilename(), "_rearranged.pdf"));
} catch (IOException e) {
@@ -15,105 +15,42 @@ import org.apache.pdfbox.util.Matrix;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
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.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@Slf4j
@RequiredArgsConstructor
public class ScalePagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@AutoJobPostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@Operation(
summary = "Change the size of a PDF page/document",
description =
"This operation takes an input PDF file and the size to scale the pages to in"
+ " the output PDF file. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> scalePages(@ModelAttribute ScalePagesRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
String targetPDRectangle = request.getPageSize();
float scaleFactor = request.getScaleFactor();
PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument outputDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
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);
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));
LayerUtility layerUtility = new LayerUtility(outputDocument);
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, i);
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
contentStream.close();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
outputDocument.save(baos);
outputDocument.close();
sourceDocument.close();
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(),
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"));
}
private PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
if ("KEEP".equals(targetPDRectangle)) {
if (sourceDocument.getNumberOfPages() == 0) {
// Do not return null here; throw a clear exception so callers don't get a nullable
// PDRectangle.
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
}
// use the first page to determine the target page size
PDPage sourcePage = sourceDocument.getPage(0);
PDRectangle sourceSize = sourcePage.getMediaBox();
if (sourceSize == null) {
// If media box is unexpectedly null, treat it as invalid
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
}
@@ -129,9 +66,10 @@ public class ScalePagesController {
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
}
private Map<String, PDRectangle> getSizeMap() {
private static Map<String, PDRectangle> getSizeMap() {
Map<String, PDRectangle> sizeMap = new HashMap<>();
// Add A0 - A6
// Portrait sizes (A0-A6)
sizeMap.put("A0", PDRectangle.A0);
sizeMap.put("A1", PDRectangle.A1);
sizeMap.put("A2", PDRectangle.A2);
@@ -140,10 +78,105 @@ public class ScalePagesController {
sizeMap.put("A5", PDRectangle.A5);
sizeMap.put("A6", PDRectangle.A6);
// Add other sizes
// 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()));
// Portrait US sizes
sizeMap.put("LETTER", PDRectangle.LETTER);
sizeMap.put("LEGAL", PDRectangle.LEGAL);
// 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()));
return sizeMap;
}
@PostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Change the size of a PDF page/document",
description =
"This operation takes an input PDF file and the size to scale the pages to in"
+ " the output PDF file. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> scalePages(@ModelAttribute ScalePagesRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
String targetPDRectangle = request.getPageSize();
float scaleFactor = request.getScaleFactor();
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument outputDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
// Create LayerUtility once outside the loop for better performance
LayerUtility layerUtility = new LayerUtility(outputDocument);
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();
}
}
outputDocument.save(baos);
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(),
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"));
}
}
}
@@ -63,7 +63,7 @@ public class SettingsController {
"defaultLocale", applicationProperties.getSystem().getDefaultLocale(),
"showUpdate", applicationProperties.getSystem().isShowUpdate(),
"showUpdateOnlyAdmin",
applicationProperties.getSystem().getShowUpdateOnlyAdmin(),
applicationProperties.getSystem().isShowUpdateOnlyAdmin(),
"customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(),
"fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit()));
return ResponseEntity.ok(settings);
@@ -123,7 +123,7 @@ public class SettingsController {
Map<String, Object> settings = new HashMap<>();
ApplicationProperties.Security security = applicationProperties.getSecurity();
settings.put("enableLogin", security.getEnableLogin());
settings.put("enableLogin", security.isEnableLogin());
settings.put("loginMethod", security.getLoginMethod());
settings.put("loginAttemptCount", security.getLoginAttemptCount());
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
@@ -351,8 +351,8 @@ public class SettingsController {
Map<String, Object> settings = new HashMap<>();
settings.put("enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
settings.put("googleVisibility", applicationProperties.getSystem().getGooglevisibility());
settings.put("metricsEnabled", applicationProperties.getMetrics().getEnabled());
settings.put("googleVisibility", applicationProperties.getSystem().isGooglevisibility());
settings.put("metricsEnabled", applicationProperties.getMetrics().isEnabled());
return ResponseEntity.ok(settings);
}
@@ -394,9 +394,9 @@ public class SettingsController {
settings.put("endpoints", applicationProperties.getEndpoints());
settings.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().getEnableAlphaFunctionality());
applicationProperties.getSystem().isEnableAlphaFunctionality());
settings.put("maxDPI", applicationProperties.getSystem().getMaxDPI());
settings.put("enableUrlToPDF", applicationProperties.getSystem().getEnableUrlToPDF());
settings.put("enableUrlToPDF", applicationProperties.getSystem().isEnableUrlToPDF());
settings.put("customPaths", applicationProperties.getSystem().getCustomPaths());
settings.put(
"tempFileManagement", applicationProperties.getSystem().getTempFileManagement());
@@ -52,15 +52,12 @@ public class SplitPDFController {
public ResponseEntity<byte[]> splitPdf(@ModelAttribute PDFWithPageNums request)
throws IOException {
PDDocument document = null;
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
TempFile outputTempFile = null;
MultipartFile file = request.getFileInput();
try {
outputTempFile = new TempFile(tempFileManager, ".zip");
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
PDDocument document = pdfDocumentFactory.load(file)) {
MultipartFile file = request.getFileInput();
document = pdfDocumentFactory.load(file);
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
int totalPages = document.getNumberOfPages();
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
@@ -78,7 +75,8 @@ public class SplitPDFController {
int previousPageNumber = 0;
for (int splitPoint : pageNumbers) {
try (PDDocument splitDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = previousPageNumber; i <= splitPoint; i++) {
PDPage page = document.getPage(i);
splitDocument.addPage(page);
@@ -89,7 +87,6 @@ public class SplitPDFController {
// Transfer metadata to split pdf
// PdfMetadataService.setMetadataToPdf(splitDocument, metadata);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
} catch (Exception e) {
@@ -98,8 +95,6 @@ public class SplitPDFController {
}
}
document.close();
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
try (ZipOutputStream zipOut =
@@ -131,28 +126,6 @@ public class SplitPDFController {
GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.zip");
return WebResponseUtils.bytesToWebResponse(
data, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
} finally {
try {
// Close the main document
if (document != null) {
document.close();
}
// Close all ByteArrayOutputStreams
for (ByteArrayOutputStream baos : splitDocumentsBoas) {
if (baos != null) {
baos.close();
}
}
// Close the output temporary file
if (outputTempFile != null) {
outputTempFile.close();
}
} catch (Exception e) {
log.error("Error while cleaning up resources", e);
}
}
}
}
@@ -5,6 +5,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -242,7 +243,7 @@ public class SplitPdfByChaptersController {
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf"
String fileName =
String.format(fileNumberFormatter, i)
String.format(Locale.ROOT, fileNumberFormatter, i)
+ bookmarks.get(i).getTitle()
+ ".pdf";
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
@@ -4,8 +4,7 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -20,25 +19,30 @@ import org.apache.pdfbox.util.Matrix;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.MultiFileResponse;
import stirling.software.SPDF.model.SplitTypes;
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PDFService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class SplitPdfBySectionsController {
@@ -46,28 +50,35 @@ public class SplitPdfBySectionsController {
private final TempFileManager tempFileManager;
private final PDFService pdfService;
@AutoJobPostMapping(
value = "/split-pdf-by-sections",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@MultiFileResponse
@PostMapping(value = "/split-pdf-by-sections", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Split PDF pages into smaller sections",
description =
"Split each page of a PDF into smaller sections based on the user's choice"
+ " (halves, thirds, quarters, etc.), both vertically and horizontally."
+ " which page to split, and how to split"
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
+ " Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<StreamingResponseBody> splitPdf(
@ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
MultipartFile file = request.getFileInput();
String pageNumbers = request.getPageNumbers();
SplitTypes splitMode =
Optional.ofNullable(request.getSplitMode())
.map(SplitTypes::valueOf)
.orElse(SplitTypes.SPLIT_ALL);
PDDocument sourceDocument = pdfDocumentFactory.load(file);
Set<Integer> pagesToSplit =
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
// Process the PDF based on split parameters
int horiz = request.getHorizontalDivisions() + 1;
int verti = request.getVerticalDivisions() + 1;
boolean merge = Boolean.TRUE.equals(request.getMerge());
List<PDDocument> splitDocuments = splitPdfPages(sourceDocument, verti, horiz);
List<PDDocument> splitDocuments = splitPdfPages(sourceDocument, verti, horiz, pagesToSplit);
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.pdf");
if (merge) {
@@ -109,52 +120,116 @@ public class SplitPdfBySectionsController {
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + "_split.zip");
}
// Based on the mode, get the pages that need to be split and return the pages set
private Set<Integer> getPagesToSplit(String pageNumbers, SplitTypes splitMode, int totalPages) {
Set<Integer> pagesToSplit = new HashSet<>();
switch (splitMode) {
case CUSTOM:
if (pageNumbers == null || pageNumbers.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.argumentRequired",
"{0} is required for {1} mode",
"page numbers",
"custom");
}
String[] pageOrderArr = pageNumbers.split(",");
List<Integer> pageListToSplit =
GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
pagesToSplit.addAll(pageListToSplit);
break;
case SPLIT_ALL:
for (int i = 0; i < totalPages; i++) {
pagesToSplit.add(i);
}
break;
case SPLIT_ALL_EXCEPT_FIRST:
for (int i = 1; i < totalPages; i++) {
pagesToSplit.add(i);
}
break;
case SPLIT_ALL_EXCEPT_LAST:
for (int i = 0; i < totalPages - 1; i++) {
pagesToSplit.add(i);
}
break;
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST:
for (int i = 1; i < totalPages - 1; i++) {
pagesToSplit.add(i);
}
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "split mode", splitMode);
}
return pagesToSplit;
}
public List<PDDocument> splitPdfPages(
PDDocument document, int horizontalDivisions, int verticalDivisions)
PDDocument document,
int horizontalDivisions,
int verticalDivisions,
Set<Integer> pagesToSplit)
throws IOException {
List<PDDocument> splitDocuments = new ArrayList<>();
int pageIndex = 0;
for (PDPage originalPage : document.getPages()) {
PDRectangle originalMediaBox = originalPage.getMediaBox();
float width = originalMediaBox.getWidth();
float height = originalMediaBox.getHeight();
float subPageWidth = width / horizontalDivisions;
float subPageHeight = height / verticalDivisions;
// If current page is not to split, add it to the splitDocuments directly.
if (!pagesToSplit.contains(pageIndex)) {
PDDocument newDoc = pdfDocumentFactory.createNewDocument();
newDoc.addPage(originalPage);
splitDocuments.add(newDoc);
} else {
// Otherwise, split current page.
PDRectangle originalMediaBox = originalPage.getMediaBox();
float width = originalMediaBox.getWidth();
float height = originalMediaBox.getHeight();
float subPageWidth = width / horizontalDivisions;
float subPageHeight = height / verticalDivisions;
LayerUtility layerUtility = new LayerUtility(document);
LayerUtility layerUtility = new LayerUtility(document);
for (int i = 0; i < horizontalDivisions; i++) {
for (int j = 0; j < verticalDivisions; j++) {
PDDocument subDoc = new PDDocument();
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
subDoc.addPage(subPage);
for (int i = 0; i < horizontalDivisions; i++) {
for (int j = 0; j < verticalDivisions; j++) {
PDDocument subDoc = new PDDocument();
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
subDoc.addPage(subPage);
PDFormXObject form =
layerUtility.importPageAsForm(
document, document.getPages().indexOf(originalPage));
PDFormXObject form =
layerUtility.importPageAsForm(
document, document.getPages().indexOf(originalPage));
try (PDPageContentStream contentStream =
new PDPageContentStream(
subDoc, subPage, AppendMode.APPEND, true, true)) {
// Set clipping area and position
float translateX = -subPageWidth * i;
try (PDPageContentStream contentStream =
new PDPageContentStream(
subDoc, subPage, AppendMode.APPEND, true, true)) {
// Set clipping area and position
float translateX = -subPageWidth * i;
// float translateY = height - subPageHeight * (verticalDivisions - j);
float translateY = -subPageHeight * (verticalDivisions - 1 - j);
// float translateY = height - subPageHeight * (verticalDivisions - j);
float translateY = -subPageHeight * (verticalDivisions - 1 - j);
contentStream.saveGraphicsState();
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
contentStream.clip();
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
contentStream.saveGraphicsState();
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
contentStream.clip();
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
// Draw the form
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
// Draw the form
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
}
splitDocuments.add(subDoc);
}
splitDocuments.add(subDoc);
}
}
pageIndex++;
}
return splitDocuments;
@@ -0,0 +1,208 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
@Slf4j
public class ConvertEbookToPDFController {
private static final Set<String> SUPPORTED_EXTENSIONS =
Set.of("epub", "mobi", "azw3", "fb2", "txt", "docx");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private boolean isCalibreEnabled() {
return endpointConfiguration.isGroupEnabled("Calibre");
}
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
@Operation(
summary = "Convert an eBook file to PDF",
description =
"This endpoint converts common eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX)"
+ " to PDF using Calibre. Input:BOOK Output:PDF Type:SISO")
public ResponseEntity<byte[]> convertEbookToPdf(
@ModelAttribute ConvertEbookToPdfRequest request) throws Exception {
if (!isCalibreEnabled()) {
throw new IllegalStateException("Calibre support is disabled");
}
MultipartFile inputFile = request.getFileInput();
if (inputFile == null || inputFile.isEmpty()) {
throw new IllegalArgumentException("No input file provided");
}
boolean optimizeForEbook = Boolean.TRUE.equals(request.getOptimizeForEbook());
if (optimizeForEbook && !isGhostscriptEnabled()) {
log.warn(
"Ghostscript optimization requested but Ghostscript is not enabled/available"
+ " for ebook conversion");
optimizeForEbook = false;
}
boolean embedAllFonts = Boolean.TRUE.equals(request.getEmbedAllFonts());
boolean includeTableOfContents = Boolean.TRUE.equals(request.getIncludeTableOfContents());
boolean includePageNumbers = Boolean.TRUE.equals(request.getIncludePageNumbers());
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "document";
}
String extension = FilenameUtils.getExtension(originalFilename);
if (extension == null || extension.isBlank()) {
throw new IllegalArgumentException("Unable to determine file type");
}
String lowerExtension = extension.toLowerCase(Locale.ROOT);
if (!SUPPORTED_EXTENSIONS.contains(lowerExtension)) {
throw new IllegalArgumentException("Unsupported eBook file extension: " + extension);
}
String baseName = FilenameUtils.getBaseName(originalFilename);
if (baseName == null || baseName.isBlank()) {
baseName = "document";
}
Path workingDirectory = tempFileManager.createTempDirectory();
Path inputPath = workingDirectory.resolve(baseName + "." + lowerExtension);
Path outputPath = workingDirectory.resolve(baseName + ".pdf");
try (InputStream inputStream = inputFile.getInputStream()) {
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
}
List<String> command =
buildCalibreCommand(
inputPath,
outputPath,
embedAllFonts,
includeTableOfContents,
includePageNumbers);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
.runCommandWithOutputHandling(command, workingDirectory.toFile());
if (result == null) {
throw new IllegalStateException("Calibre conversion returned no result");
}
if (result.getRc() != 0) {
String errorMessage = result.getMessages();
if (errorMessage == null || errorMessage.isBlank()) {
errorMessage = "Calibre conversion failed";
}
throw new IllegalStateException(errorMessage);
}
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
throw new IllegalStateException("Calibre did not produce a PDF output");
}
String outputFilename =
GeneralUtils.generateFilename(originalFilename, "_convertedToPDF.pdf");
try {
if (optimizeForEbook) {
byte[] pdfBytes = Files.readAllBytes(outputPath);
try {
byte[] optimizedPdf = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
return WebResponseUtils.bytesToWebResponse(optimizedPdf, outputFilename);
} catch (IOException e) {
log.warn(
"Ghostscript optimization failed for ebook conversion, returning"
+ " original PDF",
e);
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
}
}
try (PDDocument document = pdfDocumentFactory.load(outputPath.toFile())) {
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
}
} finally {
cleanupTempFiles(workingDirectory, inputPath, outputPath);
}
}
private List<String> buildCalibreCommand(
Path inputPath,
Path outputPath,
boolean embedAllFonts,
boolean includeTableOfContents,
boolean includePageNumbers) {
List<String> command = new ArrayList<>();
command.add("ebook-convert");
command.add(inputPath.toString());
command.add(outputPath.toString());
if (embedAllFonts) {
command.add("--embed-all-fonts");
}
if (includeTableOfContents) {
command.add("--pdf-add-toc");
}
if (includePageNumbers) {
command.add("--pdf-page-numbers");
}
return command;
}
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
List<Path> pathsToDelete = new ArrayList<>();
pathsToDelete.add(inputPath);
pathsToDelete.add(outputPath);
for (Path path : pathsToDelete) {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete temporary file: {}", path, e);
}
}
tempFileManager.deleteTempDirectory(workingDirectory);
}
}
@@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpStatus;
@@ -43,9 +44,9 @@ public class ConvertEmlToPDF {
summary = "Convert EML to PDF",
description =
"This endpoint converts EML (email) files to PDF format with extensive"
+ " customization options. Features include font settings, image constraints, display modes, attachment handling,"
+ " and HTML debug output. Input: EML file, Output: PDF"
+ " or HTML file. Type: SISO")
+ " customization options. Features include font settings, image"
+ " constraints, display modes, attachment handling, and HTML debug output."
+ " Input: EML file, Output: PDF or HTML file. Type: SISO")
public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
MultipartFile inputFile = request.getFileInput();
@@ -65,7 +66,7 @@ public class ConvertEmlToPDF {
}
// Validate file type - support EML
String lowerFilename = originalFilename.toLowerCase();
String lowerFilename = originalFilename.toLowerCase(Locale.ROOT);
if (!lowerFilename.endsWith(".eml")) {
log.error("Invalid file type for EML to PDF: {}", originalFilename);
return ResponseEntity.badRequest()
@@ -8,7 +8,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
@@ -18,7 +18,6 @@ import org.apache.commons.io.FileUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.rendering.ImageType;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -117,7 +116,7 @@ public class ConvertImgPDFController {
newPdfBytes,
"webp".equalsIgnoreCase(imageFormat)
? "png"
: imageFormat.toUpperCase(),
: imageFormat.toUpperCase(Locale.ROOT),
colorTypeResult,
singleImage,
dpi,
@@ -279,19 +278,9 @@ public class ConvertImgPDFController {
optimizeForEbook = false;
}
byte[] pdfBytes;
try {
pdfBytes =
CbzUtils.convertCbzToPdf(
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
} catch (IllegalArgumentException ex) {
String message = ex.getMessage() == null ? "Invalid CBZ file" : ex.getMessage();
Map<String, Object> errorBody =
Map.of("error", "Invalid CBZ file", "message", message, "trace", "");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(errorBody);
}
byte[] pdfBytes =
CbzUtils.convertCbzToPdf(
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
@@ -313,17 +302,7 @@ public class ConvertImgPDFController {
dpi = 300;
}
byte[] cbzBytes;
try {
cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory);
} catch (IllegalArgumentException ex) {
String message = ex.getMessage() == null ? "Invalid PDF file" : ex.getMessage();
Map<String, Object> errorBody =
Map.of("error", "Invalid PDF file", "message", message, "trace", "");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(errorBody);
}
byte[] cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory);
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbz");
@@ -348,19 +327,9 @@ public class ConvertImgPDFController {
optimizeForEbook = false;
}
byte[] pdfBytes;
try {
pdfBytes =
CbrUtils.convertCbrToPdf(
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
} catch (IllegalArgumentException ex) {
String message = ex.getMessage() == null ? "Invalid CBR file" : ex.getMessage();
Map<String, Object> errorBody =
Map.of("error", "Invalid CBR file", "message", message, "trace", "");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(errorBody);
}
byte[] pdfBytes =
CbrUtils.convertCbrToPdf(
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
@@ -382,17 +351,7 @@ public class ConvertImgPDFController {
dpi = 300;
}
byte[] cbrBytes;
try {
cbrBytes = PdfToCbrUtils.convertPdfToCbr(file, dpi, pdfDocumentFactory);
} catch (IllegalArgumentException ex) {
String message = ex.getMessage() == null ? "Invalid PDF file" : ex.getMessage();
Map<String, Object> errorBody =
Map.of("error", "Invalid PDF file", "message", message, "trace", "");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(errorBody);
}
byte[] cbrBytes = PdfToCbrUtils.convertPdfToCbr(file, dpi, pdfDocumentFactory);
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbr");
@@ -8,6 +8,7 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
@@ -30,6 +31,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@@ -55,15 +57,16 @@ public class ConvertOfficeController {
// Check for valid file extension and sanitize filename
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFilename == null || originalFilename.isBlank()) {
throw new IllegalArgumentException("Missing original filename");
throw ExceptionUtils.createFileNoNameException();
}
// Check for valid file extension
String extension = FilenameUtils.getExtension(originalFilename);
if (extension == null || !isValidFileExtension(extension)) {
throw new IllegalArgumentException("Invalid file extension");
throw ExceptionUtils.createIllegalArgumentException(
"error.invalid.extension", "Invalid file extension: " + extension);
}
String extensionLower = extension.toLowerCase();
String extensionLower = extension.toLowerCase(Locale.ROOT);
String baseName = FilenameUtils.getBaseName(originalFilename);
if (baseName == null || baseName.isBlank()) {
@@ -86,6 +89,7 @@ public class ConvertOfficeController {
Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
}
Path libreOfficeProfile = null;
try {
ProcessExecutorResult result;
// Run Unoconvert command
@@ -105,8 +109,10 @@ public class ConvertOfficeController {
.runCommandWithOutputHandling(command);
} // Run soffice command
else {
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
List<String> command = new ArrayList<>();
command.add("soffice");
command.add(runtimePathConfig.getSOfficePath());
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
command.add("--headless");
command.add("--nologo");
command.add("--convert-to");
@@ -137,7 +143,7 @@ public class ConvertOfficeController {
p ->
p.getFileName()
.toString()
.toLowerCase()
.toLowerCase(Locale.ROOT)
.endsWith(".pdf"))
.findFirst()
.orElse(null);
@@ -162,6 +168,9 @@ public class ConvertOfficeController {
} catch (IOException e) {
log.warn("Failed to delete temp input file: {}", inputPath, e);
}
if (libreOfficeProfile != null) {
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
}
}
}
@@ -0,0 +1,204 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.io.FilenameUtils;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest;
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.OutputFormat;
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
@Slf4j
public class ConvertPDFToEpubController {
private static final String CALIBRE_GROUP = "Calibre";
private static final String DEFAULT_EXTENSION = "pdf";
private static final String FILTERED_CSS =
"font-family,color,background-color,margin-left,margin-right";
private static final String SMART_CHAPTER_EXPRESSION =
"//h:*[re:test(., '\\s*Chapter\\s+', 'i')]";
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private static List<String> buildCalibreCommand(
Path inputPath, Path outputPath, boolean detectChapters, TargetDevice targetDevice) {
List<String> command = new ArrayList<>();
command.add("ebook-convert");
command.add(inputPath.toString());
command.add(outputPath.toString());
// Golden defaults
command.add("--enable-heuristics");
command.add("--insert-blank-line");
command.add("--filter-css");
command.add(FILTERED_CSS);
if (detectChapters) {
command.add("--chapter");
command.add(SMART_CHAPTER_EXPRESSION);
}
if (targetDevice != null) {
command.add("--output-profile");
command.add(targetDevice.getCalibreProfile());
}
return command;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
@Operation(
summary = "Convert PDF to EPUB/AZW3",
description =
"Convert a PDF file to a high-quality EPUB or AZW3 ebook using Calibre. Input:PDF"
+ " Output:EPUB/AZW3 Type:SISO")
public ResponseEntity<byte[]> convertPdfToEpub(@ModelAttribute ConvertPdfToEpubRequest request)
throws Exception {
if (!endpointConfiguration.isGroupEnabled(CALIBRE_GROUP)) {
throw new IllegalStateException(
"Calibre support is disabled. Enable the Calibre group or install Calibre to use"
+ " this feature.");
}
MultipartFile inputFile = request.getFileInput();
if (inputFile == null || inputFile.isEmpty()) {
throw new IllegalArgumentException("No input file provided");
}
boolean detectChapters = !Boolean.FALSE.equals(request.getDetectChapters());
TargetDevice targetDevice =
request.getTargetDevice() == null
? TargetDevice.TABLET_PHONE_IMAGES
: request.getTargetDevice();
OutputFormat outputFormat =
request.getOutputFormat() == null ? OutputFormat.EPUB : request.getOutputFormat();
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "document." + DEFAULT_EXTENSION;
}
String extension = FilenameUtils.getExtension(originalFilename);
if (extension.isBlank()) {
throw new IllegalArgumentException("Unable to determine file type");
}
if (!DEFAULT_EXTENSION.equalsIgnoreCase(extension)) {
throw new IllegalArgumentException("Input file must be a PDF");
}
String baseName = FilenameUtils.getBaseName(originalFilename);
if (baseName == null || baseName.isBlank()) {
baseName = "document";
}
Path workingDirectory = null;
Path inputPath = null;
Path outputPath = null;
try {
workingDirectory = tempFileManager.createTempDirectory();
inputPath = workingDirectory.resolve(baseName + "." + DEFAULT_EXTENSION);
outputPath = workingDirectory.resolve(baseName + "." + outputFormat.getExtension());
try (InputStream inputStream = inputFile.getInputStream()) {
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
}
List<String> command =
buildCalibreCommand(inputPath, outputPath, detectChapters, targetDevice);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
.runCommandWithOutputHandling(command, workingDirectory.toFile());
if (result == null) {
throw new IllegalStateException("Calibre conversion returned no result");
}
if (result.getRc() != 0) {
String errorMessage = result.getMessages();
if (errorMessage == null || errorMessage.isBlank()) {
errorMessage = "Calibre conversion failed";
}
throw new IllegalStateException(errorMessage);
}
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
throw new IllegalStateException(
"Calibre did not produce a " + outputFormat.name() + " output");
}
String outputFilename =
GeneralUtils.generateFilename(
originalFilename,
"_convertedTo"
+ outputFormat.name()
+ "."
+ outputFormat.getExtension());
byte[] outputBytes = Files.readAllBytes(outputPath);
MediaType mediaType = MediaType.valueOf(outputFormat.getMediaType());
return WebResponseUtils.bytesToWebResponse(outputBytes, outputFilename, mediaType);
} finally {
cleanupTempFiles(workingDirectory, inputPath, outputPath);
}
}
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
if (workingDirectory == null) {
return;
}
List<Path> pathsToDelete = new ArrayList<>();
if (inputPath != null) {
pathsToDelete.add(inputPath);
}
if (outputPath != null) {
pathsToDelete.add(outputPath);
}
for (Path path : pathsToDelete) {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete temporary file: {}", path, e);
}
}
try {
tempFileManager.deleteTempDirectory(workingDirectory);
} catch (Exception e) {
log.warn("Failed to delete temporary directory: {}", workingDirectory, e);
}
}
}
@@ -3,34 +3,38 @@ package stirling.software.SPDF.controller.api.converters;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.HtmlConversionResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.PDFToFile;
import stirling.software.common.util.TempFileManager;
@ConvertApi
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@RequestMapping("/api/v1/convert")
@RequiredArgsConstructor
public class ConvertPDFToHtml {
private final TempFileManager tempFileManager;
private final RuntimePathConfig runtimePathConfig;
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
@Operation(
summary = "Convert PDF to HTML",
description =
"This endpoint converts a PDF file to HTML format. Input:PDF Output:HTML Type:SISO")
@HtmlConversionResponse
public ResponseEntity<byte[]> processPdfToHTML(@ModelAttribute PDFFile file) throws Exception {
MultipartFile inputFile = file.getFileInput();
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
return pdfToFile.processPdfToHtml(inputFile);
}
}
@@ -7,21 +7,20 @@ import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.PowerPointConversionResponse;
import stirling.software.SPDF.config.swagger.TextPlainConversionResponse;
import stirling.software.SPDF.config.swagger.WordConversionResponse;
import stirling.software.SPDF.config.swagger.XmlConversionResponse;
import stirling.software.SPDF.model.api.converters.PdfToPresentationRequest;
import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest;
import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
@@ -29,15 +28,17 @@ import stirling.software.common.util.PDFToFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ConvertApi
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
public class ConvertPDFToOffice {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final RuntimePathConfig runtimePathConfig;
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
@PowerPointConversionResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
@Operation(
summary = "Convert PDF to Presentation format",
description =
@@ -48,12 +49,11 @@ public class ConvertPDFToOffice {
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String outputFormat = request.getOutputFormat();
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
@TextPlainConversionResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
@Operation(
summary = "Convert PDF to Text or RTF format",
description =
@@ -74,13 +74,12 @@ public class ConvertPDFToOffice {
MediaType.TEXT_PLAIN);
}
} else {
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
}
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
@WordConversionResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
@Operation(
summary = "Convert PDF to Word document",
description =
@@ -90,12 +89,11 @@ public class ConvertPDFToOffice {
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String outputFormat = request.getOutputFormat();
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
@XmlConversionResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
@Operation(
summary = "Convert PDF to XML",
description =
@@ -104,7 +102,7 @@ public class ConvertPDFToOffice {
public ResponseEntity<byte[]> processPdfToXML(@ModelAttribute PDFFile file) throws Exception {
MultipartFile inputFile = file.getFileInput();
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
return pdfToFile.processPdfToOfficeFormat(inputFile, "xml", "writer_pdf_import");
}
}
@@ -0,0 +1,293 @@
package stirling.software.SPDF.controller.api.converters;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import javax.imageio.ImageIO;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.converters.PdfToVideoRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempDirectory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
public class ConvertPdfToVideoController {
private static final Set<String> SUPPORTED_FORMATS = Set.of("mp4", "webm");
private static final Map<String, String> RESOLUTION_FILTERS =
Map.of(
"ORIGINAL", "scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1",
"1080P", "scale=-2:1080,setsar=1",
"720P", "scale=-2:720,setsar=1",
"480P", "scale=-2:480,setsar=1");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
@Operation(
summary = "Convert PDF to Video Slideshow",
description =
"This endpoint converts a PDF document into a slideshow-style video."
+ " Input:PDF Output:Video Type:SISO")
public ResponseEntity<byte[]> convertPdfToVideo(@ModelAttribute PdfToVideoRequest request)
throws Exception {
if (!CheckProgramInstall.isFfmpegAvailable()) {
throw ExceptionUtils.createFfmpegRequiredException();
}
MultipartFile inputFile = request.getFileInput();
if (inputFile == null || inputFile.isEmpty()) {
throw ExceptionUtils.createPdfFileRequiredException();
}
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
float opacity = request.getOpacity() != null ? request.getOpacity() : 1.0f;
if (opacity < 0.0f || opacity > 1.0f) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "opacity", opacity);
}
String format = normalizeFormat(request.getVideoFormat());
int secondsPerPage = request.getSecondsPerPage() != null ? request.getSecondsPerPage() : 3;
if (secondsPerPage <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"secondsPerPage",
secondsPerPage);
}
int dpi = request.getDpi() != null ? request.getDpi() : 150;
int maxDpi = getMaxDpi();
if (dpi > maxDpi) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dpiExceedsLimit",
"DPI value {0} exceeds maximum safe limit of {1}.",
dpi,
maxDpi);
}
if (dpi < 72) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "dpi", dpi);
}
String resolution =
request.getResolution() != null
? request.getResolution().toUpperCase(Locale.ROOT)
: "ORIGINAL";
if (!RESOLUTION_FILTERS.containsKey(resolution)) {
resolution = "ORIGINAL";
}
String watermarkText = request.getWatermarkText();
boolean isWatermarkEnabled = watermarkText != null && !watermarkText.isBlank();
String originalPdfFileName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalPdfFileName == null || originalPdfFileName.isBlank()) {
originalPdfFileName = "document.pdf";
}
String pdfBaseName =
originalPdfFileName.contains(".")
? originalPdfFileName.substring(0, originalPdfFileName.lastIndexOf('.'))
: originalPdfFileName;
try (TempFile inputTempFile = new TempFile(tempFileManager, ".pdf");
TempDirectory framesDirectory = new TempDirectory(tempFileManager);
TempFile outputVideo = new TempFile(tempFileManager, "." + format)) {
inputFile.transferTo(inputTempFile.getFile());
generateFrames(
inputTempFile.getPath(),
framesDirectory.getPath(),
dpi,
opacity,
watermarkText,
isWatermarkEnabled);
DecimalFormat decimalFormat =
new DecimalFormat("0.######", DecimalFormatSymbols.getInstance(Locale.ROOT));
String frameRate = decimalFormat.format(1.0d / secondsPerPage);
List<String> command = buildFfmpegCommand(format, resolution, frameRate, outputVideo);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.FFMPEG)
.runCommandWithOutputHandling(
command, framesDirectory.getPath().toFile());
byte[] videoBytes = Files.readAllBytes(outputVideo.getPath());
MediaType mediaType = getMediaType(format);
String outputName = pdfBaseName + "-video." + format;
return WebResponseUtils.bytesToWebResponse(videoBytes, outputName, mediaType);
}
}
private void generateFrames(
Path inputPdf,
Path outputDir,
int dpi,
float opacity,
String watermarkText,
boolean isWatermarkEnabled)
throws IOException {
try (PDDocument document = pdfDocumentFactory.load(inputPdf.toFile())) {
PDFRenderer renderer = new PDFRenderer(document);
renderer.setSubsamplingAllowed(true);
int pageCount = document.getNumberOfPages();
if (pageCount == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "PDF", "no pages");
}
for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) {
final int currentPageIndex = pageIndex;
// Validate dimensions BEFORE attempting to render to prevent OOM
ExceptionUtils.validateRenderingDimensions(
document.getPage(currentPageIndex), currentPageIndex + 1, dpi);
BufferedImage image =
ExceptionUtils.handleOomRendering(
currentPageIndex + 1,
dpi,
() ->
renderer.renderImageWithDPI(
currentPageIndex, dpi, ImageType.RGB));
if (isWatermarkEnabled) {
applyWatermark(image, opacity, watermarkText);
}
Path framePath =
outputDir.resolve(
String.format(Locale.ROOT, "frame_%05d.png", pageIndex + 1));
ImageIO.write(image, "png", framePath.toFile());
}
}
}
private void applyWatermark(BufferedImage image, float opacity, String watermarkText) {
Graphics2D graphics = image.createGraphics();
try {
graphics.setRenderingHint(
java.awt.RenderingHints.KEY_TEXT_ANTIALIASING,
java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
int baseDimension = Math.min(image.getWidth(), image.getHeight());
int fontSize = Math.max(32, baseDimension / 5);
Font font = new Font(Font.SANS_SERIF, Font.BOLD, fontSize);
graphics.setFont(font);
FontMetrics metrics = graphics.getFontMetrics(font);
int textWidth = metrics.stringWidth(watermarkText);
int textHeight = metrics.getAscent();
AffineTransform originalTransform = graphics.getTransform();
double angle = Math.atan2(image.getHeight(), image.getWidth());
graphics.translate(image.getWidth() / 2.0, image.getHeight() / 2.0);
graphics.rotate(-angle);
// Draw shadow
graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity / 2));
graphics.setColor(Color.BLACK);
graphics.drawString(watermarkText, -textWidth / 2f + 3, textHeight / 2f + 3);
// Draw main text
graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity));
graphics.setColor(Color.WHITE);
graphics.drawString(watermarkText, -textWidth / 2f, textHeight / 2f);
graphics.setTransform(originalTransform);
} finally {
graphics.dispose();
}
}
private List<String> buildFfmpegCommand(
String format, String resolution, String frameRate, TempFile outputVideo) {
List<String> command = new ArrayList<>();
command.add("ffmpeg");
command.add("-y");
command.add("-framerate");
command.add(frameRate);
command.add("-i");
command.add("frame_%05d.png");
command.add("-vf");
command.add(
RESOLUTION_FILTERS.getOrDefault(resolution, RESOLUTION_FILTERS.get("ORIGINAL")));
if ("mp4".equals(format)) {
command.addAll(
List.of("-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart"));
} else if ("webm".equals(format)) {
command.addAll(List.of("-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30"));
}
command.add(outputVideo.getAbsolutePath());
return command;
}
private String normalizeFormat(String requestedFormat) {
String format = requestedFormat != null ? requestedFormat.toLowerCase(Locale.ROOT) : "mp4";
if (!SUPPORTED_FORMATS.contains(format)) {
format = "mp4";
}
return format;
}
private int getMaxDpi() {
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
return properties.getSystem().getMaxDPI();
}
return 500;
}
private MediaType getMediaType(String format) {
return switch (format) {
case "webm" -> MediaType.valueOf("video/webm");
default -> MediaType.valueOf("video/mp4");
};
}
}
@@ -2,38 +2,50 @@ package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
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.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils;
@ConvertApi
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@Slf4j
@RequestMapping("/api/v1/convert")
@RequiredArgsConstructor
public class ConvertWebsiteToPDF {
@@ -41,8 +53,12 @@ public class ConvertWebsiteToPDF {
private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties;
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
@StandardPdfResponse
private static final Pattern FILE_SCHEME_PATTERN =
Pattern.compile("(?<![a-z0-9_])file\\s*:(?:/{1,3}|%2f|%5c|%3a|&#x2f;|&#47;)");
private static final Pattern NUMERIC_HTML_ENTITY_PATTERN = Pattern.compile("&#(x?[0-9a-f]+);");
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
@Operation(
summary = "Convert a URL to a PDF",
description =
@@ -56,7 +72,7 @@ public class ConvertWebsiteToPDF {
URI location = null;
HttpStatus status = HttpStatus.SEE_OTHER;
if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
location =
uriComponentsBuilder
.queryParam("error", "error.endpointDisabled")
@@ -89,14 +105,33 @@ public class ConvertWebsiteToPDF {
}
Path tempOutputFile = null;
Path tempHtmlInput = null;
PDDocument doc = null;
try {
// Download the remote content first to ensure we don't allow dangerous schemes
String htmlContent = fetchRemoteHtml(URL);
if (containsDisallowedUriScheme(htmlContent)) {
URI rejectionLocation =
uriComponentsBuilder
.queryParam("error", "error.disallowedUrlContent")
.build()
.toUri();
log.warn("Rejected URL to PDF conversion due to disallowed content references");
return ResponseEntity.status(status).location(rejectionLocation).build();
}
tempHtmlInput = Files.createTempFile("url_input_", ".html");
Files.writeString(tempHtmlInput, htmlContent, StandardCharsets.UTF_8);
// Prepare the output file path
tempOutputFile = Files.createTempFile("output_", ".pdf");
// Prepare the WeasyPrint command
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getWeasyPrintPath());
command.add(tempHtmlInput.toString());
command.add("--base-url");
command.add(URL);
command.add("--pdf-forms");
command.add(tempOutputFile.toString());
@@ -118,6 +153,13 @@ public class ConvertWebsiteToPDF {
}
return response;
} finally {
if (tempHtmlInput != null) {
try {
Files.deleteIfExists(tempHtmlInput);
} catch (IOException e) {
log.error("Error deleting temporary HTML input file", e);
}
}
if (tempOutputFile != null) {
try {
@@ -129,6 +171,93 @@ public class ConvertWebsiteToPDF {
}
}
private String fetchRemoteHtml(String url) throws IOException, InterruptedException {
HttpClient client =
HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request =
HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(20))
.GET()
.header("User-Agent", "Stirling-PDF/URL-to-PDF")
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 400 || response.body() == null) {
throw ExceptionUtils.createIOException(
"error.httpRequestFailed",
"Failed to retrieve remote HTML. Status: {0}",
null,
response.statusCode());
}
return response.body();
}
private boolean containsDisallowedUriScheme(String htmlContent) {
if (htmlContent == null || htmlContent.isEmpty()) {
return false;
}
String normalized = normalizeForSchemeDetection(htmlContent);
return FILE_SCHEME_PATTERN.matcher(normalized).find();
}
private String normalizeForSchemeDetection(String htmlContent) {
String lowerCaseContent = htmlContent.toLowerCase(Locale.ROOT);
String decodedHtmlEntities = decodeNumericHtmlEntities(lowerCaseContent);
decodedHtmlEntities =
decodedHtmlEntities
.replace("&colon;", ":")
.replace("&sol;", "/")
.replace("&frasl;", "/");
return percentDecode(decodedHtmlEntities);
}
private String percentDecode(String content) {
StringBuilder result = new StringBuilder(content.length());
for (int i = 0; i < content.length(); i++) {
char current = content.charAt(i);
if (current == '%' && i + 2 < content.length()) {
String hex = content.substring(i + 1, i + 3);
try {
int value = Integer.parseInt(hex, 16);
result.append((char) value);
i += 2;
continue;
} catch (NumberFormatException ignored) {
// Fall through to append the literal characters when parsing fails
}
}
result.append(current);
}
return result.toString();
}
private String decodeNumericHtmlEntities(String content) {
Matcher matcher = NUMERIC_HTML_ENTITY_PATTERN.matcher(content);
StringBuffer decoded = new StringBuffer();
while (matcher.find()) {
String entityBody = matcher.group(1);
try {
int radix = entityBody.startsWith("x") ? 16 : 10;
int codePoint =
Integer.parseInt(radix == 16 ? entityBody.substring(1) : entityBody, radix);
matcher.appendReplacement(
decoded, Matcher.quoteReplacement(Character.toString((char) codePoint)));
} catch (NumberFormatException ex) {
matcher.appendReplacement(decoded, matcher.group(0));
}
}
matcher.appendTail(decoded);
return decoded.toString();
}
private String convertURLToFileName(String url) {
String safeName = GeneralUtils.convertToFileName(url);
if (safeName == null || safeName.isBlank()) {
@@ -7,6 +7,7 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -122,7 +123,7 @@ public class ExtractCSVController {
}
private String generateEntryName(String baseName, int pageNum, int tableIndex) {
return String.format("%s_p%d_t%d.csv", baseName, pageNum, tableIndex);
return String.format(Locale.ROOT, "%s_p%d_t%d.csv", baseName, pageNum, tableIndex);
}
private String getBaseName(String filename) {
@@ -0,0 +1,252 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.commons.io.FilenameUtils;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Slf4j
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
public class PdfVectorExportController {
private static final MediaType PDF_MEDIA_TYPE = MediaType.APPLICATION_PDF;
private static final Set<String> GHOSTSCRIPT_INPUTS =
Set.of("ps", "eps", "epsf"); // PCL/PXL/XPS require GhostPDL (gpcl6/gxps)
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/vector/pdf")
@Operation(
summary = "Convert PostScript formats to PDF",
description =
"Converts PostScript vector inputs (PS, EPS, EPSF) to PDF using Ghostscript."
+ " Input:PS/EPS Output:PDF Type:SISO")
public ResponseEntity<byte[]> convertGhostscriptInputsToPdf(
@Valid @ModelAttribute PdfVectorExportRequest request) throws Exception {
String originalName =
request.getFileInput() != null
? request.getFileInput().getOriginalFilename()
: null;
String extension =
originalName != null
? FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT)
: "";
try (TempFile inputTemp =
new TempFile(tempFileManager, extension.isEmpty() ? "" : "." + extension);
TempFile outputTemp = new TempFile(tempFileManager, ".pdf")) {
request.getFileInput().transferTo(inputTemp.getFile());
if (GHOSTSCRIPT_INPUTS.contains(extension)) {
boolean prepress = request.getPrepress() != null && request.getPrepress();
runGhostscriptToPdf(inputTemp.getPath(), outputTemp.getPath(), prepress);
} else if ("pdf".equals(extension)) {
Files.copy(
inputTemp.getPath(),
outputTemp.getPath(),
StandardCopyOption.REPLACE_EXISTING);
} else {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Unsupported Ghostscript input format {0}",
extension);
}
byte[] pdfBytes = Files.readAllBytes(outputTemp.getPath());
String outputName = GeneralUtils.generateFilename(originalName, "_converted.pdf");
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputName, PDF_MEDIA_TYPE);
}
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/vector")
@Operation(
summary = "Convert PDF to vector format",
description =
"Converts PDF to Ghostscript vector formats (EPS, PS, PCL, or XPS)."
+ " Input:PDF Output:VECTOR Type:SISO")
public ResponseEntity<byte[]> convertPdfToVector(
@Valid @ModelAttribute PdfVectorExportRequest request) throws Exception {
String originalName =
request.getFileInput() != null
? request.getFileInput().getOriginalFilename()
: null;
String outputFormat = request.getOutputFormat();
if (outputFormat == null || outputFormat.isEmpty()) {
outputFormat = "eps";
}
outputFormat = outputFormat.toLowerCase(Locale.ROOT);
try (TempFile inputTemp = new TempFile(tempFileManager, ".pdf");
TempFile outputTemp = new TempFile(tempFileManager, "." + outputFormat)) {
request.getFileInput().transferTo(inputTemp.getFile());
runGhostscriptPdfToVector(inputTemp.getPath(), outputTemp.getPath(), outputFormat);
byte[] vectorBytes = Files.readAllBytes(outputTemp.getPath());
String outputName =
GeneralUtils.generateFilename(originalName, "_converted." + outputFormat);
MediaType mediaType;
switch (outputFormat.toLowerCase(Locale.ROOT)) {
case "eps":
case "ps":
mediaType = MediaType.parseMediaType("application/postscript");
break;
case "pcl":
mediaType = MediaType.parseMediaType("application/vnd.hp-PCL");
break;
case "xps":
mediaType = MediaType.parseMediaType("application/vnd.ms-xpsdocument");
break;
default:
mediaType = MediaType.APPLICATION_OCTET_STREAM;
}
return WebResponseUtils.bytesToWebResponse(vectorBytes, outputName, mediaType);
}
}
private void runGhostscriptPdfToVector(Path inputPath, Path outputPath, String outputFormat)
throws IOException, InterruptedException {
if (!endpointConfiguration.isGroupEnabled("Ghostscript")) {
throw ExceptionUtils.createGhostscriptConversionException(outputFormat);
}
List<String> command = new ArrayList<>();
command.add("gs");
// Set device based on output format
String device =
switch (outputFormat.toLowerCase(Locale.ROOT)) {
case "eps" -> "eps2write";
case "ps" -> "ps2write";
case "pcl" -> "pxlcolor"; // PCL XL color
case "xps" -> "xpswrite";
default ->
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Unsupported output format: {0}",
outputFormat);
};
command.add("-sDEVICE=" + device);
command.add("-dNOPAUSE");
command.add("-dBATCH");
command.add("-dSAFER");
command.add("-sOutputFile=" + outputPath.toAbsolutePath());
command.add(inputPath.toAbsolutePath().toString());
log.debug("Executing Ghostscript command: {}", String.join(" ", command));
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command);
ExceptionUtils.GhostscriptException criticalError =
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
if (criticalError != null) {
log.error(
"Ghostscript PDF to {} conversion detected critical error: {}. Command: {}",
outputFormat.toUpperCase(),
criticalError.getMessage(),
String.join(" ", command));
throw criticalError;
}
if (result.getRc() != 0) {
log.error(
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command: {}",
outputFormat.toUpperCase(),
result.getRc(),
result.getMessages(),
String.join(" ", command));
throw ExceptionUtils.createGhostscriptConversionException(outputFormat);
}
}
private void runGhostscriptToPdf(Path inputPath, Path outputPath, boolean prepress)
throws IOException, InterruptedException {
if (!endpointConfiguration.isGroupEnabled("Ghostscript")) {
throw ExceptionUtils.createGhostscriptConversionException("pdfwrite");
}
List<String> command = new ArrayList<>();
command.add("gs");
command.add("-sDEVICE=pdfwrite");
command.add("-dNOPAUSE");
command.add("-dBATCH");
command.add("-dSAFER");
command.add("-dCompatibilityLevel=1.4");
if (prepress) {
command.add("-dPDFSETTINGS=/prepress");
}
command.add("-sOutputFile=" + outputPath.toAbsolutePath());
command.add(inputPath.toAbsolutePath().toString());
log.debug("Executing Ghostscript PostScript-to-PDF command: {}", String.join(" ", command));
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command);
ExceptionUtils.GhostscriptException criticalError =
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
if (criticalError != null) {
log.error(
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command: {}",
criticalError.getMessage(),
String.join(" ", command));
throw criticalError;
}
if (result.getRc() != 0) {
log.error(
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}. Command: {}",
result.getRc(),
result.getMessages(),
String.join(" ", command));
throw ExceptionUtils.createGhostscriptConversionException("pdfwrite");
}
}
}
@@ -8,40 +8,53 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.FilterResponse;
import stirling.software.SPDF.model.api.PDFComparisonAndCount;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.model.api.filter.ContainsTextRequest;
import stirling.software.SPDF.model.api.filter.FileSizeRequest;
import stirling.software.SPDF.model.api.filter.PageRotationRequest;
import stirling.software.SPDF.model.api.filter.PageSizeRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.FilterApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils;
@FilterApi
@RestController
@RequestMapping("/api/v1/filter")
@Tag(name = "Filter", description = "Filter APIs")
@RequiredArgsConstructor
public class FilterController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-contains-text")
@FilterResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-text")
@Operation(
summary = "Checks if a PDF contains set text, returns true if does",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> containsText(@ModelAttribute ContainsTextRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
@@ -54,159 +67,182 @@ public class FilterController {
pdfDocument, Filenames.toSimpleFileName(inputFile.getOriginalFilename()));
}
}
return null;
return ResponseEntity.noContent().build();
}
// TODO
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-contains-image")
@FilterResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-image")
@Operation(
summary = "Checks if a PDF contains an image",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> containsImage(@ModelAttribute PDFWithPageNums request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String pageNumber = request.getPageNumbers();
PDDocument pdfDocument = pdfDocumentFactory.load(inputFile);
if (PdfUtils.hasImages(pdfDocument, pageNumber))
return WebResponseUtils.pdfDocToWebResponse(
pdfDocument, Filenames.toSimpleFileName(inputFile.getOriginalFilename()));
return null;
try (PDDocument pdfDocument = pdfDocumentFactory.load(inputFile)) {
if (PdfUtils.hasImages(pdfDocument, pageNumber)) {
return WebResponseUtils.pdfDocToWebResponse(
pdfDocument, Filenames.toSimpleFileName(inputFile.getOriginalFilename()));
}
}
return ResponseEntity.noContent().build();
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-page-count")
@FilterResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-count")
@Operation(
summary = "Checks if a PDF is greater, less or equal to a setPageCount",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> pageCount(@ModelAttribute PDFComparisonAndCount request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
int pageCount = request.getPageCount();
String comparator = request.getComparator();
// Load the PDF
PDDocument document = pdfDocumentFactory.load(inputFile);
int actualPageCount = document.getNumberOfPages();
// Perform the comparison
boolean valid =
switch (comparator) {
case "Greater" -> actualPageCount > pageCount;
case "Equal" -> actualPageCount == pageCount;
case "Less" -> actualPageCount < pageCount;
default ->
throw ExceptionUtils.createInvalidArgumentException(
"comparator", comparator);
};
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
return null;
boolean valid;
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
int actualPageCount = document.getNumberOfPages();
valid = compare(actualPageCount, pageCount, comparator);
}
return valid
? WebResponseUtils.multiPartFileToWebResponse(inputFile)
: ResponseEntity.noContent().build();
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size")
@FilterResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size")
@Operation(
summary = "Checks if a PDF is of a certain size",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> pageSize(@ModelAttribute PageSizeRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String standardPageSize = request.getStandardPageSize();
String comparator = request.getComparator();
// Load the PDF
PDDocument document = pdfDocumentFactory.load(inputFile);
final boolean valid;
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
PDPage firstPage = document.getPage(0);
PDRectangle actualPageSize = firstPage.getMediaBox();
PDPage firstPage = document.getPage(0);
PDRectangle actualPageSize = firstPage.getMediaBox();
float actualArea = actualPageSize.getWidth() * actualPageSize.getHeight();
PDRectangle standardSize = PdfUtils.textToPageSize(standardPageSize);
float standardArea = standardSize.getWidth() * standardSize.getHeight();
// Calculate the area of the actual page size
float actualArea = actualPageSize.getWidth() * actualPageSize.getHeight();
valid = compare(actualArea, standardArea, comparator);
}
// Get the standard size and calculate its area
PDRectangle standardSize = PdfUtils.textToPageSize(standardPageSize);
float standardArea = standardSize.getWidth() * standardSize.getHeight();
// Perform the comparison
boolean valid =
switch (comparator) {
case "Greater" -> actualArea > standardArea;
case "Equal" -> actualArea == standardArea;
case "Less" -> actualArea < standardArea;
default ->
throw ExceptionUtils.createInvalidArgumentException(
"comparator", comparator);
};
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
return null;
return valid
? WebResponseUtils.multiPartFileToWebResponse(inputFile)
: ResponseEntity.noContent().build();
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size")
@FilterResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size")
@Operation(
summary = "Checks if a PDF is a set file size",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> fileSize(@ModelAttribute FileSizeRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
long fileSize = request.getFileSize();
String comparator = request.getComparator();
// Get the file size
long actualFileSize = inputFile.getSize();
boolean valid = compare(actualFileSize, fileSize, comparator);
// Perform the comparison
boolean valid =
switch (comparator) {
case "Greater" -> actualFileSize > fileSize;
case "Equal" -> actualFileSize == fileSize;
case "Less" -> actualFileSize < fileSize;
default ->
throw ExceptionUtils.createInvalidArgumentException(
"comparator", comparator);
};
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
return null;
return valid
? WebResponseUtils.multiPartFileToWebResponse(inputFile)
: ResponseEntity.noContent().build();
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-page-rotation")
@FilterResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-rotation")
@Operation(
summary = "Checks if a PDF is of a certain rotation",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> pageRotation(@ModelAttribute PageRotationRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
int rotation = request.getRotation();
String comparator = request.getComparator();
// Load the PDF
PDDocument document = pdfDocumentFactory.load(inputFile);
boolean valid;
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
PDPage firstPage = document.getPage(0);
int actualRotation = firstPage.getRotation();
valid = compare(actualRotation, rotation, comparator);
}
// Get the rotation of the first page
PDPage firstPage = document.getPage(0);
int actualRotation = firstPage.getRotation();
return valid
? WebResponseUtils.multiPartFileToWebResponse(inputFile)
: ResponseEntity.noContent().build();
}
// Perform the comparison
boolean valid =
switch (comparator) {
case "Greater" -> actualRotation > rotation;
case "Equal" -> actualRotation == rotation;
case "Less" -> actualRotation < rotation;
default ->
throw ExceptionUtils.createInvalidArgumentException(
"comparator", comparator);
};
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
return null;
/**
* Compares two values based on the provided comparator.
*
* @param <T> The type of the values being compared.
* @param actual The actual value.
* @param expected The expected value.
* @param comparator The comparator to use (e.g., "Greater", "Less", "Equal").
* @return True if the comparison is valid, false otherwise.
*/
private static <T extends Comparable<T>> boolean compare(
T actual, T expected, String comparator) {
return switch (comparator) {
case "Greater" -> actual.compareTo(expected) > 0;
case "Equal" -> actual.compareTo(expected) == 0;
case "Less" -> actual.compareTo(expected) < 0;
default ->
throw ExceptionUtils.createInvalidArgumentException("comparator", comparator);
};
}
}
@@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
@@ -17,10 +18,12 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.misc.AddAttachmentRequest;
import stirling.software.SPDF.model.api.misc.ExtractAttachmentsRequest;
import stirling.software.SPDF.service.AttachmentServiceInterface;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -54,4 +57,35 @@ public class AttachmentController {
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
"_with_attachments.pdf"));
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/extract-attachments")
@Operation(
summary = "Extract attachments from PDF",
description =
"This endpoint extracts all embedded attachments from a PDF into a ZIP archive."
+ " Input:PDF Output:ZIP Type:SISO")
public ResponseEntity<byte[]> extractAttachments(
@ModelAttribute ExtractAttachmentsRequest request) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(request, true)) {
Optional<byte[]> extracted = pdfAttachmentService.extractAttachments(document);
if (extracted.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.noAttachmentsFound",
"No embedded attachments found in the provided PDF");
}
MultipartFile fileInput = request.getFileInput();
String sourceName =
fileInput != null ? fileInput.getOriginalFilename() : request.getFileId();
String outputName =
Filenames.toSimpleFileName(
GeneralUtils.generateFilename(sourceName, "_attachments.zip"));
return WebResponseUtils.bytesToWebResponse(
extracted.get(), outputName, MediaType.APPLICATION_OCTET_STREAM);
}
}
}
@@ -139,14 +139,14 @@ public class AutoSplitPdfController {
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
final int dpi = renderDpi;
final int pageNum = page;
try {
bim = pdfRenderer.renderImageWithDPI(page, renderDpi);
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
}
bim =
ExceptionUtils.handleOomRendering(
pageNum + 1,
dpi,
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
String result = decodeQRCode(bim);
boolean isValidQrCode = VALID_QR_CONTENTS.contains(result);
@@ -5,6 +5,7 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -64,7 +65,11 @@ public class BlankPageController {
}
double whitePixelPercentage = (whitePixels / (double) totalPixels) * 100;
log.info(String.format("Page has white pixel percent of %.2f%%", whitePixelPercentage));
log.info(
String.format(
Locale.ROOT,
"Page has white pixel percent of %.2f%%",
whitePixelPercentage));
return whitePixelPercentage >= whitePercent;
}
@@ -117,16 +122,16 @@ public class BlankPageController {
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
final int dpi = renderDpi;
final int currentPageIndex = pageIndex;
try {
image = pdfRenderer.renderImageWithDPI(pageIndex, renderDpi);
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(
pageIndex + 1, renderDpi, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(
pageIndex + 1, renderDpi, e);
}
image =
ExceptionUtils.handleOomRendering(
currentPageIndex + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
currentPageIndex, dpi));
blank = isBlankImage(image, threshold, whitePercent, threshold);
}
}
@@ -165,6 +170,8 @@ public class BlankPageController {
return WebResponseUtils.baosToWebResponse(
baos, filename + "_processed.zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
throw e;
} catch (IOException e) {
log.error("exception", e);
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
@@ -82,7 +82,7 @@ public class ConfigController {
// If userService is null, proprietary module isn't loaded
// (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false)
boolean enableLogin =
applicationProperties.getSecurity().getEnableLogin() && userService != null;
applicationProperties.getSecurity().isEnableLogin() && userService != null;
configData.put("enableLogin", enableLogin);
// Mail settings - check both SMTP enabled AND invites enabled
@@ -120,7 +120,7 @@ public class ConfigController {
// System settings
configData.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().getEnableAlphaFunctionality());
applicationProperties.getSystem().isEnableAlphaFunctionality());
configData.put(
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
@@ -108,14 +108,14 @@ public class ExtractImageScansController {
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
final int dpi = renderDpi;
final int pageIndex = i;
try {
image = pdfRenderer.renderImageWithDPI(i, renderDpi);
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
}
image =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
dpi,
() -> pdfRenderer.renderImageWithDPI(pageIndex, dpi));
ImageIO.write(image, "png", tempFile.toFile());
// Add temp file path to images list
@@ -202,7 +202,8 @@ public class ExtractImageScansController {
zipBytes, outputZipFilename, MediaType.APPLICATION_OCTET_STREAM);
}
if (processedImageBytes.isEmpty()) {
throw new IllegalArgumentException("No images detected");
throw ExceptionUtils.createIllegalArgumentException(
"error.noContent", "No {0} detected", "images");
} else {
// Return the processed image as a response
@@ -60,31 +60,51 @@ public class FlattenController {
return WebResponseUtils.pdfDocToWebResponse(
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
} else {
// flatten whole page aka convert each page to image and readd it (making text
// flatten whole page aka convert each page to image and re-add it (making text
// unselectable)
PDFRenderer pdfRenderer = new PDFRenderer(document);
PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
int defaultRenderDpi = 100; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
Integer configuredMaxDpi = null;
if (properties != null && properties.getSystem() != null) {
configuredMaxDpi = properties.getSystem().getMaxDPI();
}
int maxDpi =
(configuredMaxDpi != null && configuredMaxDpi > 0)
? configuredMaxDpi
: defaultRenderDpi;
Integer requestedDpi = request.getRenderDpi();
int renderDpiTemp = maxDpi;
if (requestedDpi != null) {
renderDpiTemp = Math.min(requestedDpi, maxDpi);
renderDpiTemp = Math.max(renderDpiTemp, 72);
}
final int renderDpi = renderDpiTemp;
int numPages = document.getNumberOfPages();
for (int i = 0; i < numPages; i++) {
final int pageIndex = i;
BufferedImage image = null;
try {
BufferedImage image;
// Validate dimensions BEFORE rendering to prevent OOM
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, renderDpi);
// Use global maximum DPI setting, fallback to 300 if not set
int renderDpi = 300; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
// Wrap entire rendering operation to catch OutOfMemoryError from any depth
image =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
renderDpi,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, renderDpi, ImageType.RGB));
try {
image = pdfRenderer.renderImageWithDPI(i, renderDpi, ImageType.RGB);
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
}
PDPage page = new PDPage();
page.setMediaBox(document.getPage(i).getMediaBox());
newDocument.addPage(page);
@@ -96,8 +116,22 @@ public class FlattenController {
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
}
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OutOfMemoryDpiException to be handled by GlobalExceptionHandler
newDocument.close();
document.close();
throw e;
} catch (IOException e) {
log.error("exception", e);
log.error("IOException during page processing: ", e);
// Continue processing other pages
} catch (OutOfMemoryError e) {
// Catch any OutOfMemoryError that escaped the inner try block
newDocument.close();
document.close();
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
} finally {
// Help GC by clearing the image reference
image = null;
}
}
return WebResponseUtils.pdfDocToWebResponse(
@@ -9,6 +9,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -22,25 +23,35 @@ import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.*;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempDirectory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@MiscApi
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@Slf4j
@RequiredArgsConstructor
public class OCRController {
@@ -49,6 +60,7 @@ public class OCRController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private final RuntimePathConfig runtimePathConfig;
private boolean isOcrMyPdfEnabled() {
return endpointConfiguration.isGroupEnabled("OCRmyPDF");
@@ -60,7 +72,7 @@ public class OCRController {
/** Gets the list of available Tesseract languages from the tessdata directory */
public List<String> getAvailableTesseractLanguages() {
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
String tessdataDir = runtimePathConfig.getTessDataPath();
File[] files = new File(tessdataDir).listFiles();
if (files == null) {
return Collections.emptyList();
@@ -72,14 +84,14 @@ public class OCRController {
.toList();
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ocr-pdf")
@StandardPdfResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ocr-pdf")
@Operation(
summary = "Process a PDF file with OCR",
description =
"This endpoint processes a PDF file using OCR (Optical Character Recognition). "
+ "Users can specify languages, sidecar, deskew, clean, cleanFinal, ocrType, ocrRenderType, and removeImagesAfter options. "
+ "Uses OCRmyPDF if available, falls back to Tesseract. Input:PDF Output:PDF Type:SI-Conditional")
"This endpoint processes a PDF file using OCR (Optical Character Recognition). Users can"
+ " specify languages, sidecar, deskew, clean, cleanFinal, ocrType, ocrRenderType,"
+ " and removeImagesAfter options. Uses OCRmyPDF if available, falls back to"
+ " Tesseract. Input:PDF Output:PDF Type:SI-Conditional")
public ResponseEntity<byte[]> processPdfWithOCR(
@ModelAttribute ProcessPdfWithOcrRequest request)
throws IOException, InterruptedException {
@@ -98,7 +110,7 @@ public class OCRController {
}
if (!"hocr".equals(ocrRenderType) && !"sandwich".equals(ocrRenderType)) {
throw new IOException("ocrRenderType wrong");
throw ExceptionUtils.createOcrInvalidRenderTypeException();
}
// Get available Tesseract languages
@@ -214,7 +226,7 @@ public class OCRController {
List<String> command =
new ArrayList<>(
Arrays.asList(
"ocrmypdf",
runtimePathConfig.getOcrMyPdfPath(),
"--verbose",
"2",
"--output-type",
@@ -267,7 +279,7 @@ public class OCRController {
}
if (result.getRc() != 0) {
throw new IOException("OCRmyPDF failed with return code: " + result.getRc());
throw ExceptionUtils.createOcrProcessingFailedException(result.getRc());
}
// Remove images from the OCR processed PDF if the flag is set to true
@@ -334,7 +346,9 @@ public class OCRController {
};
File pageOutputPath =
new File(tempOutputDir, String.format("page_%d.pdf", pageNum));
new File(
tempOutputDir,
String.format(Locale.ROOT, "page_%d.pdf", pageNum));
if (shouldOcr) {
// Convert page to image
@@ -346,18 +360,18 @@ public class OCRController {
&& applicationProperties.getSystem() != null) {
renderDpi = applicationProperties.getSystem().getMaxDPI();
}
final int dpi = renderDpi;
final int currentPageNum = pageNum;
try {
image = pdfRenderer.renderImageWithDPI(pageNum, renderDpi);
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(
pageNum + 1, renderDpi, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(
pageNum + 1, renderDpi, e);
}
image =
ExceptionUtils.handleOomRendering(
currentPageNum + 1,
dpi,
() -> pdfRenderer.renderImageWithDPI(currentPageNum, dpi));
File imagePath =
new File(tempImagesDir, String.format("page_%d.png", pageNum));
new File(
tempImagesDir,
String.format(Locale.ROOT, "page_%d.png", pageNum));
ImageIO.write(image, "png", imagePath);
// Build OCR command
@@ -365,7 +379,9 @@ public class OCRController {
command.add("tesseract");
command.add(imagePath.toString());
command.add(
new File(tempOutputDir, String.format("page_%d", pageNum))
new File(
tempOutputDir,
String.format(Locale.ROOT, "page_%d", pageNum))
.toString());
command.add("-l");
command.add(String.join("+", selectedLanguages));
@@ -9,6 +9,7 @@ import java.awt.print.PrinterJob;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Locale;
import javax.imageio.ImageIO;
import javax.print.PrintService;
@@ -21,19 +22,25 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.PrintFileRequest;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.util.ExceptionUtils;
@MiscApi
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@Slf4j
public class PrintFileController {
// TODO
// @AutoJobPostMapping(value = "/print-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
// @PostMapping(value = "/print-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
// @Operation(
// summary = "Prints PDF/Image file to a set printer",
// description =
@@ -45,18 +52,22 @@ public class PrintFileController {
String originalFilename = file.getOriginalFilename();
if (originalFilename != null
&& (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) {
throw new IOException("Invalid file path detected: " + originalFilename);
throw ExceptionUtils.createIllegalArgumentException(
"error.invalid.filepath", "Invalid file path detected: " + originalFilename);
}
String printerName = request.getPrinterName();
String contentType = file.getContentType();
try {
// Find matching printer
PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
String normalizedPrinterName = printerName.toLowerCase(Locale.ROOT);
PrintService selectedService =
Arrays.stream(services)
.filter(
service ->
service.getName().toLowerCase().contains(printerName))
service.getName()
.toLowerCase(Locale.ROOT)
.contains(normalizedPrinterName))
.findFirst()
.orElseThrow(
() ->
@@ -20,6 +20,7 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.model.api.PDFFile;
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.ProcessExecutor.ProcessExecutorResult;
@@ -114,7 +115,9 @@ public class RepairController {
repairSuccess = true;
}
} else {
throw new IOException("PDF repair failed with available tools");
throw ExceptionUtils.createFileProcessingException(
"PDF repair",
new IOException("PDF repair failed with available tools"));
}
}
@@ -7,7 +7,11 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import javax.imageio.ImageIO;
@@ -30,24 +34,28 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.misc.AddStampRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@MiscApi
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class StampController {
@@ -71,8 +79,7 @@ public class StampController {
});
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-stamp")
@StandardPdfResponse
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-stamp")
@Operation(
summary = "Add stamp to a PDF file",
description =
@@ -84,7 +91,8 @@ public class StampController {
MultipartFile pdfFile = request.getFileInput();
String pdfFileName = pdfFile.getOriginalFilename();
if (pdfFileName.contains("..") || pdfFileName.startsWith("/")) {
throw new IllegalArgumentException("Invalid PDF file path");
throw ExceptionUtils.createIllegalArgumentException(
"error.invalid.filepath", "Invalid PDF file path: " + pdfFileName);
}
String stampType = request.getStampType();
@@ -92,14 +100,19 @@ public class StampController {
MultipartFile stampImage = request.getStampImage();
if ("image".equalsIgnoreCase(stampType)) {
if (stampImage == null) {
throw new IllegalArgumentException(
throw ExceptionUtils.createIllegalArgumentException(
"error.stamp.image.required",
"Stamp image file must be provided when stamp type is 'image'");
}
String stampImageName = stampImage.getOriginalFilename();
if (stampImageName == null
|| stampImageName.contains("..")
|| stampImageName.startsWith("/")) {
throw new IllegalArgumentException("Invalid stamp image file path");
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"stamp image file path",
stampImageName);
}
}
String alphabet = request.getAlphabet();
@@ -112,7 +125,7 @@ public class StampController {
String customColor = request.getCustomColor();
float marginFactor =
switch (request.getCustomMargin().toLowerCase()) {
switch (request.getCustomMargin().toLowerCase(Locale.ROOT)) {
case "small" -> 0.02f;
case "medium" -> 0.035f;
case "large" -> 0.05f;
@@ -196,9 +209,9 @@ public class StampController {
resourceDir =
switch (alphabet) {
case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf";
case "japanese" -> "static/fonts/NotoSansJP-Regular.ttf";
case "korean" -> "static/fonts/NotoSansKR-Regular.ttf";
case "chinese" -> "static/fonts/NotoSansSC-Regular.ttf";
case "japanese" -> "static/fonts/Meiryo.ttf";
case "korean" -> "static/fonts/malgun.ttf";
case "chinese" -> "static/fonts/SimSun.ttf";
case "thai" -> "static/fonts/NotoSansThai-Regular.ttf";
case "roman" -> "static/fonts/NotoSans-Regular.ttf";
default -> "static/fonts/NotoSans-Regular.ttf";
@@ -234,6 +247,29 @@ public class StampController {
PDRectangle pageSize = page.getMediaBox();
float x, y;
if (overrideX >= 0 && overrideY >= 0) {
// Use override values if provided
x = overrideX;
y = overrideY;
} else {
x = calculatePositionX(pageSize, position, fontSize, font, fontSize, stampText, margin);
y =
calculatePositionY(
pageSize, position, calculateTextCapHeight(font, fontSize), margin);
}
String currentDate = LocalDate.now().toString();
String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
int pageCount = document.getNumberOfPages();
String processedStampText =
stampText
.replace("@date", currentDate)
.replace("@time", currentTime)
.replace("@page_count", String.valueOf(pageCount));
// Split the stampText into multiple lines
String[] lines =
RegexPatternUtils.getInstance().getEscapedNewlinePattern().split(stampText);
@@ -243,46 +279,15 @@ public class StampController {
float descent = font.getFontDescriptor().getDescent();
float lineHeight = ((ascent - descent) / 1000) * fontSize;
// Compute a single pivot for the entire text block to avoid line-by-line wobble
float capHeight = calculateTextCapHeight(font, fontSize);
float blockHeight = Math.max(lineHeight, lineHeight * Math.max(1, lines.length));
float maxWidth = 0f;
for (String ln : lines) {
maxWidth = Math.max(maxWidth, calculateTextWidth(ln, font, fontSize));
}
if (overrideX >= 0 && overrideY >= 0) {
// Use override values if provided
x = overrideX;
y = overrideY;
} else {
// Base positioning on the true multi-line block size
x = calculatePositionX(pageSize, position, maxWidth, null, 0, null, margin);
y = calculatePositionY(pageSize, position, blockHeight, margin);
}
// After anchoring the block, draw from the top line downward
float adjustedX = x;
float adjustedY = y;
float pivotX = adjustedX + maxWidth / 2f;
float pivotY = adjustedY + blockHeight / 2f;
// Apply rotation about the block center at the graphics state level
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(pivotX, pivotY));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
contentStream.transform(Matrix.getTranslateInstance(-pivotX, -pivotY));
contentStream.beginText();
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
// Start from top line: yTop = adjustedY + blockHeight - capHeight
float yLine = adjustedY + blockHeight - capHeight - (i * lineHeight);
contentStream.setTextMatrix(Matrix.getTranslateInstance(adjustedX, yLine));
// Set the text matrix for each line with rotation
contentStream.setTextMatrix(
Matrix.getRotateInstance(Math.toRadians(rotation), x, y - (i * lineHeight)));
contentStream.showText(line);
}
contentStream.endText();
contentStream.restoreGraphicsState();
}
private void addImageStamp(
@@ -326,17 +331,9 @@ public class StampController {
}
contentStream.saveGraphicsState();
// Rotate and scale about the center of the image
float centerX = x + (desiredPhysicalWidth / 2f);
float centerY = y + (desiredPhysicalHeight / 2f);
contentStream.transform(Matrix.getTranslateInstance(centerX, centerY));
contentStream.transform(Matrix.getTranslateInstance(x, y));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
contentStream.drawImage(
xobject,
-desiredPhysicalWidth / 2f,
-desiredPhysicalHeight / 2f,
desiredPhysicalWidth,
desiredPhysicalHeight);
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
contentStream.restoreGraphicsState();
}
@@ -19,6 +19,7 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
@@ -203,7 +204,7 @@ public class PipelineDirectoryProcessor {
? filename.substring(
filename.lastIndexOf(".")
+ 1)
.toLowerCase()
.toLowerCase(Locale.ROOT)
: "";
// Check against allowed extensions
@@ -213,7 +214,8 @@ public class PipelineDirectoryProcessor {
extension.toLowerCase());
if (!isAllowed) {
log.info(
"Skipping file with unsupported extension: {} ({})",
"Skipping file with unsupported extension: {}"
+ " ({})",
filename,
extension);
}
@@ -226,7 +228,8 @@ public class PipelineDirectoryProcessor {
fileMonitor.isFileReadyForProcessing(path);
if (!isReady) {
log.info(
"File not ready for processing (locked/created last 5s): {}",
"File not ready for processing (locked/created"
+ " last 5s): {}",
path);
}
return isReady;
@@ -8,6 +8,7 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.zip.ZipEntry;
@@ -121,7 +122,9 @@ public class PipelineProcessor {
boolean hasInputFileType = false;
for (String extension : inputFileTypes) {
if ("ALL".equals(extension)
|| file.getFilename().toLowerCase().endsWith(extension)) {
|| file.getFilename()
.toLowerCase(Locale.ROOT)
.endsWith(extension)) {
hasInputFileType = true;
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", file);
@@ -159,7 +162,8 @@ public class PipelineProcessor {
String providedExtension = "no extension";
if (filename != null && filename.contains(".")) {
providedExtension =
filename.substring(filename.lastIndexOf(".")).toLowerCase();
filename.substring(filename.lastIndexOf("."))
.toLowerCase(Locale.ROOT);
}
logPrintStream.println(
@@ -187,7 +191,10 @@ public class PipelineProcessor {
file ->
finalinputFileTypes.stream()
.anyMatch(
file.getFilename().toLowerCase()
file.getFilename()
.toLowerCase(
Locale
.ROOT)
::endsWith))
.toList();
}
@@ -228,7 +235,7 @@ public class PipelineProcessor {
if (filename != null && filename.contains(".")) {
return filename.substring(
filename.lastIndexOf("."))
.toLowerCase();
.toLowerCase(Locale.ROOT);
}
return "no extension";
})
@@ -289,7 +296,7 @@ public class PipelineProcessor {
newFilename = removeTrailingNaming(extractFilename(response));
}
// Check if the response body is a zip file
if (isZip(response.getBody())) {
if (isZip(response.getBody(), newFilename)) {
// Unzip the file and add all the files to the new output files
newOutputFiles.addAll(unzip(response.getBody()));
} else {
@@ -379,14 +386,25 @@ public class PipelineProcessor {
return outputFiles;
}
private boolean isZip(byte[] data) {
private boolean isZip(byte[] data, String filename) {
if (data == null || data.length < 4) {
return false;
}
if (filename != null) {
String lower = filename.toLowerCase();
if (lower.endsWith(".cbz")) {
// Treat CBZ as non-zip for our unzipping purposes
return false;
}
}
// Check the first four bytes of the data against the standard zip magic number
return data[0] == 0x50 && data[1] == 0x4B && data[2] == 0x03 && data[3] == 0x04;
}
private boolean isZip(byte[] data) {
return isZip(data, null);
}
private List<Resource> unzip(byte[] data) throws IOException {
log.info("Unzipping data of length: {}", data.length);
List<Resource> unzippedFiles = new ArrayList<>();
@@ -410,7 +428,7 @@ public class PipelineProcessor {
}
};
// If the unzipped file is a zip file, unzip it
if (isZip(baos.toByteArray())) {
if (isZip(baos.toByteArray(), filename)) {
log.info("File {} is a zip file. Unzipping...", filename);
unzippedFiles.addAll(unzip(baos.toByteArray()));
} else {
@@ -41,7 +41,17 @@ public class PasswordController {
throws IOException {
MultipartFile fileInput = request.getFileInput();
String password = request.getPassword();
PDDocument document = pdfDocumentFactory.load(fileInput, password);
PDDocument document;
try {
document = pdfDocumentFactory.load(fileInput, password);
} catch (IOException e) {
// Handle password errors specifically
if (ExceptionUtils.isPasswordError(e)) {
throw ExceptionUtils.createPdfPasswordException(e);
}
throw ExceptionUtils.handlePdfException(e);
}
try {
document.setAllSecurityToBeRemoved(true);
@@ -61,6 +61,7 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.model.api.security.RedactionArea;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -509,7 +510,8 @@ public class RedactController {
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) {
throw new IllegalArgumentException("No text patterns provided for redaction");
throw ExceptionUtils.createIllegalArgumentException(
"error.redaction.no.patterns", "No text patterns provided for redaction");
}
PDDocument document = null;
@@ -518,14 +520,15 @@ public class RedactController {
try {
if (request.getFileInput() == null) {
log.error("File input is null");
throw new IllegalArgumentException("File input cannot be null");
throw ExceptionUtils.createFileNullOrEmptyException();
}
document = pdfDocumentFactory.load(request.getFileInput());
if (document == null) {
log.error("Failed to load PDF document");
throw new IllegalArgumentException("Failed to load PDF document");
throw ExceptionUtils.createPdfCorruptedException(
"during redaction", new IOException("Failed to load PDF document"));
}
Map<Integer, List<PDFText>> allFoundTextsByPage =
@@ -1,7 +1,9 @@
package stirling.software.SPDF.controller.web;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.ModelAndView;
@@ -13,44 +15,57 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.CheckProgramInstall;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@Controller
@Tag(name = "Convert", description = "Convert APIs")
public class ConverterWebController {
@Deprecated
// @GetMapping("/img-to-pdf")
@GetMapping("/img-to-pdf")
@Hidden
public String convertImgToPdfForm(Model model) {
model.addAttribute("currentPage", "img-to-pdf");
return "convert/img-to-pdf";
}
@Deprecated
// @GetMapping("/cbz-to-pdf")
@GetMapping("/cbz-to-pdf")
@Hidden
public String convertCbzToPdfForm(Model model) {
model.addAttribute("currentPage", "cbz-to-pdf");
return "convert/cbz-to-pdf";
}
@Deprecated
// @GetMapping("/pdf-to-cbz")
@GetMapping("/pdf-to-cbz")
@Hidden
public String convertPdfToCbzForm(Model model) {
model.addAttribute("currentPage", "pdf-to-cbz");
return "convert/pdf-to-cbz";
}
@Deprecated
// @GetMapping("/cbr-to-pdf")
@GetMapping("/cbr-to-pdf")
@Hidden
public String convertCbrToPdfForm(Model model) {
model.addAttribute("currentPage", "cbr-to-pdf");
return "convert/cbr-to-pdf";
}
@Deprecated
// @GetMapping("/pdf-to-cbr")
@GetMapping("/ebook-to-pdf")
@Hidden
public String convertEbookToPdfForm(Model model) {
model.addAttribute("currentPage", "ebook-to-pdf");
return "convert/ebook-to-pdf";
}
@GetMapping("/pdf-to-epub")
@Hidden
public String convertPdfToEpubForm(Model model) {
if (!ApplicationContextProvider.getBean(EndpointConfiguration.class)
.isEndpointEnabled("pdf-to-epub")) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
model.addAttribute("currentPage", "pdf-to-epub");
return "convert/pdf-to-epub";
}
@GetMapping("/pdf-to-cbr")
@Hidden
public String convertPdfToCbrForm(Model model) {
if (!ApplicationContextProvider.getBean(EndpointConfiguration.class)
@@ -61,40 +76,35 @@ public class ConverterWebController {
return "convert/pdf-to-cbr";
}
@Deprecated
// @GetMapping("/html-to-pdf")
@GetMapping("/html-to-pdf")
@Hidden
public String convertHTMLToPdfForm(Model model) {
model.addAttribute("currentPage", "html-to-pdf");
return "convert/html-to-pdf";
}
@Deprecated
// @GetMapping("/markdown-to-pdf")
@GetMapping("/markdown-to-pdf")
@Hidden
public String convertMarkdownToPdfForm(Model model) {
model.addAttribute("currentPage", "markdown-to-pdf");
return "convert/markdown-to-pdf";
}
@Deprecated
// @GetMapping("/pdf-to-markdown")
@GetMapping("/pdf-to-markdown")
@Hidden
public String convertPdfToMarkdownForm(Model model) {
model.addAttribute("currentPage", "pdf-to-markdown");
return "convert/pdf-to-markdown";
}
@Deprecated
// @GetMapping("/url-to-pdf")
@GetMapping("/url-to-pdf")
@Hidden
public String convertURLToPdfForm(Model model) {
model.addAttribute("currentPage", "url-to-pdf");
return "convert/url-to-pdf";
}
@Deprecated
// @GetMapping("/file-to-pdf")
@GetMapping("/file-to-pdf")
@Hidden
public String convertToPdfForm(Model model) {
model.addAttribute("currentPage", "file-to-pdf");
@@ -103,8 +113,7 @@ public class ConverterWebController {
// PDF TO......
@Deprecated
// @GetMapping("/pdf-to-img")
@GetMapping("/pdf-to-img")
@Hidden
public String pdfToimgForm(Model model) {
boolean isPython = CheckProgramInstall.isPythonAvailable();
@@ -120,8 +129,7 @@ public class ConverterWebController {
return "convert/pdf-to-img";
}
@Deprecated
// @GetMapping("/pdf-to-html")
@GetMapping("/pdf-to-html")
@Hidden
public ModelAndView pdfToHTML() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-html");
@@ -129,8 +137,7 @@ public class ConverterWebController {
return modelAndView;
}
@Deprecated
// @GetMapping("/pdf-to-presentation")
@GetMapping("/pdf-to-presentation")
@Hidden
public ModelAndView pdfToPresentation() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-presentation");
@@ -138,8 +145,7 @@ public class ConverterWebController {
return modelAndView;
}
@Deprecated
// @GetMapping("/pdf-to-text")
@GetMapping("/pdf-to-text")
@Hidden
public ModelAndView pdfToText() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-text");
@@ -147,8 +153,7 @@ public class ConverterWebController {
return modelAndView;
}
@Deprecated
// @GetMapping("/pdf-to-word")
@GetMapping("/pdf-to-word")
@Hidden
public ModelAndView pdfToWord() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-word");
@@ -156,8 +161,7 @@ public class ConverterWebController {
return modelAndView;
}
@Deprecated
// @GetMapping("/pdf-to-xml")
@GetMapping("/pdf-to-xml")
@Hidden
public ModelAndView pdfToXML() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-xml");
@@ -165,8 +169,7 @@ public class ConverterWebController {
return modelAndView;
}
@Deprecated
// @GetMapping("/pdf-to-csv")
@GetMapping("/pdf-to-csv")
@Hidden
public ModelAndView pdfToCSV() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-csv");
@@ -174,19 +177,45 @@ public class ConverterWebController {
return modelAndView;
}
@Deprecated
// @GetMapping("/pdf-to-pdfa")
@GetMapping("/pdf-to-pdfa")
@Hidden
public String pdfToPdfAForm(Model model) {
model.addAttribute("currentPage", "pdf-to-pdfa");
return "convert/pdf-to-pdfa";
}
@Deprecated
// @GetMapping("/eml-to-pdf")
@GetMapping("/pdf-to-vector")
@Hidden
public String pdfToVectorForm(Model model) {
model.addAttribute("currentPage", "pdf-to-vector");
return "convert/pdf-to-vector";
}
@GetMapping("/vector-to-pdf")
@Hidden
public String vectorToPdfForm(Model model) {
model.addAttribute("currentPage", "vector-to-pdf");
return "convert/vector-to-pdf";
}
@GetMapping("/eml-to-pdf")
@Hidden
public String convertEmlToPdfForm(Model model) {
model.addAttribute("currentPage", "eml-to-pdf");
return "convert/eml-to-pdf";
}
@GetMapping("/pdf-to-video")
@Hidden
public String pdfToVideo(Model model) {
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
model.addAttribute("maxDPI", properties.getSystem().getMaxDPI());
} else {
model.addAttribute("maxDPI", 500);
}
model.addAttribute("currentPage", "pdf-to-video");
return "convert/pdf-to-video";
}
}
@@ -89,8 +89,8 @@ public class HomeWebController {
@ResponseBody
@Hidden
public String getRobotsTxt() {
Boolean allowGoogle = applicationProperties.getSystem().getGooglevisibility();
if (Boolean.TRUE.equals(allowGoogle)) {
boolean allowGoogle = applicationProperties.getSystem().isGooglevisibility();
if (allowGoogle) {
return "User-agent: Googlebot\nAllow: /\n\nUser-agent: *\nAllow: /";
} else {
return "User-agent: Googlebot\nDisallow: /\n\nUser-agent: *\nDisallow: /";
@@ -40,9 +40,7 @@ public class MetricsController {
@PostConstruct
public void init() {
Boolean metricsEnabled = applicationProperties.getMetrics().getEnabled();
if (metricsEnabled == null) metricsEnabled = true;
this.metricsEnabled = metricsEnabled;
metricsEnabled = applicationProperties.getMetrics().isEnabled();
}
@GetMapping("/status")
@@ -389,7 +387,7 @@ public class MetricsController {
long hours = duration.toHoursPart();
long minutes = duration.toMinutesPart();
long seconds = duration.toSecondsPart();
return String.format("%dd %dh %dm %ds", days, hours, minutes, seconds);
return String.format(Locale.ROOT, "%dd %dh %dm %ds", days, hours, minutes, seconds);
}
@Setter
@@ -11,15 +11,19 @@ import org.springframework.web.servlet.ModelAndView;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.CheckProgramInstall;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@RequiredArgsConstructor
@Slf4j
public class OtherWebController {
private final ApplicationProperties applicationProperties;
private final RuntimePathConfig runtimePathConfig;
@Deprecated
// @GetMapping("/compress-pdf")
@@ -129,7 +133,7 @@ public class OtherWebController {
}
public List<String> getAvailableTesseractLanguages() {
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
String tessdataDir = runtimePathConfig.getTessDataPath();
File[] files = new File(tessdataDir).listFiles();
if (files == null) {
return Collections.emptyList();
@@ -216,4 +220,12 @@ public class OtherWebController {
model.addAttribute("currentPage", "add-attachments");
return "misc/add-attachments";
}
@Deprecated
// @GetMapping("/extract-attachments")
@Hidden
public String extractAttachmentsForm(Model model) {
model.addAttribute("currentPage", "extract-attachments");
return "misc/extract-attachments";
}
}
@@ -65,6 +65,6 @@ public class UploadLimitService {
if (bytes < 1024) return bytes + " B";
int exp = (int) (Math.log(bytes) / Math.log(1024));
String pre = "KMGTPE".charAt(exp - 1) + "B";
return String.format(Locale.US, "%.1f %s", bytes / Math.pow(1024, exp), pre);
return String.format(Locale.ROOT, "%.1f %s", bytes / Math.pow(1024, exp), pre);
}
}
@@ -0,0 +1,9 @@
package stirling.software.SPDF.model;
public enum SplitTypes {
CUSTOM,
SPLIT_ALL_EXCEPT_FIRST_AND_LAST,
SPLIT_ALL_EXCEPT_FIRST,
SPLIT_ALL_EXCEPT_LAST,
SPLIT_ALL
}
@@ -12,20 +12,20 @@ import stirling.software.common.model.api.PDFFile;
public class SplitPdfByChaptersRequest extends PDFFile {
@Schema(
description = "Whether to include Metadata or not",
defaultValue = "true",
defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean includeMetadata;
@Schema(
description = "Whether to allow duplicates or not",
defaultValue = "true",
defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean allowDuplicates;
@Schema(
description = "Maximum bookmark level required",
minimum = "0",
defaultValue = "2",
defaultValue = "0",
requiredMode = Schema.RequiredMode.REQUIRED)
private Integer bookmarkLevel;
}
@@ -5,11 +5,29 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.SPDF.model.SplitTypes;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class SplitPdfBySectionsRequest extends PDFFile {
@Schema(
description = "Pages to be split by section",
defaultValue = "SPLIT_ALL",
requiredMode = Schema.RequiredMode.REQUIRED)
private String pageNumbers;
@Schema(
implementation = SplitTypes.class,
description =
"Modes for page split. Valid values are:\n"
+ "SPLIT_ALL_EXCEPT_FIRST_AND_LAST: Splits all except the first and the last pages.\n"
+ "SPLIT_ALL_EXCEPT_FIRST: Splits all except the first page.\n"
+ "SPLIT_ALL_EXCEPT_LAST: Splits all except the last page.\n"
+ "SPLIT_ALL: Splits all pages.\n"
+ "CUSTOM: Custom split.\n")
private String splitMode;
@Schema(
description = "Number of horizontal divisions for each PDF page",
defaultValue = "0",
@@ -26,7 +44,7 @@ public class SplitPdfBySectionsRequest extends PDFFile {
@Schema(
description = "Merge the split documents into a single PDF",
defaultValue = "true",
defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean merge;
}
@@ -0,0 +1,53 @@
package stirling.software.SPDF.model.api.converters;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class ConvertEbookToPdfRequest {
@Schema(
description =
"The input eBook file to be converted to a PDF file (EPUB, MOBI, AZW3, FB2,"
+ " TXT, DOCX)",
contentMediaType =
"application/epub+zip, application/x-mobipocket-ebook, application/x-azw3,"
+ " text/xml, text/plain,"
+ " application/vnd.openxmlformats-officedocument.wordprocessingml.document",
requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile fileInput;
@Schema(
description = "Embed all fonts from the eBook into the generated PDF",
allowableValues = {"true", "false"},
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "false")
private Boolean embedAllFonts;
@Schema(
description = "Add a generated table of contents to the resulting PDF",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"true", "false"},
defaultValue = "false")
private Boolean includeTableOfContents;
@Schema(
description = "Add page numbers to the generated PDF",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"true", "false"},
defaultValue = "false")
private Boolean includePageNumbers;
@Schema(
description =
"Optimize the PDF for eBook reading (smaller file size, better rendering on"
+ " eInk devices)",
allowableValues = {"true", "false"},
defaultValue = "false")
private Boolean optimizeForEbook;
}
@@ -0,0 +1,58 @@
package stirling.software.SPDF.model.api.converters;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class ConvertPdfToEpubRequest extends PDFFile {
@Schema(
description = "Detect headings that look like chapters and insert EPUB page breaks.",
allowableValues = {"true", "false"},
defaultValue = "true")
private Boolean detectChapters = Boolean.TRUE;
@Schema(
description = "Choose an output profile optimized for the reader device.",
allowableValues = {"TABLET_PHONE_IMAGES", "KINDLE_EINK_TEXT"},
defaultValue = "TABLET_PHONE_IMAGES")
private TargetDevice targetDevice = TargetDevice.TABLET_PHONE_IMAGES;
@Schema(
description = "Choose the output format for the ebook.",
allowableValues = {"EPUB", "AZW3"},
defaultValue = "EPUB")
private OutputFormat outputFormat = OutputFormat.EPUB;
@Getter
public enum TargetDevice {
TABLET_PHONE_IMAGES("tablet"),
KINDLE_EINK_TEXT("kindle");
private final String calibreProfile;
TargetDevice(String calibreProfile) {
this.calibreProfile = calibreProfile;
}
}
@Getter
public enum OutputFormat {
EPUB("epub", "application/epub+zip"),
AZW3("azw3", "application/vnd.amazon.ebook");
private final String extension;
private final String mediaType;
OutputFormat(String extension, String mediaType) {
this.extension = extension;
this.mediaType = mediaType;
}
}
}
@@ -12,8 +12,11 @@ import stirling.software.common.model.api.PDFFile;
public class PdfToPdfARequest extends PDFFile {
@Schema(
description = "The output PDF/A type",
description = "The output format type (PDF/A or PDF/X)",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"pdfa", "pdfa-1"})
allowableValues = {
"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx", "pdfx-1",
"pdfx-3", "pdfx-4"
})
private String outputFormat;
}
@@ -0,0 +1,53 @@
package stirling.software.SPDF.model.api.converters;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class PdfToVideoRequest extends PDFFile {
@Schema(
description = "The output video format",
allowableValues = {"mp4", "webm"},
defaultValue = "mp4")
private String videoFormat = "mp4";
@Schema(
description = "Seconds each page should appear in the video",
minimum = "1",
maximum = "30",
defaultValue = "3")
private Integer secondsPerPage = 3;
@Schema(
description = "Target video resolution",
allowableValues = {"ORIGINAL", "1080p", "720p", "480p"},
defaultValue = "ORIGINAL")
private String resolution = "ORIGINAL";
@Schema(
description = "DPI to render PDF pages before encoding",
minimum = "72",
maximum = "500",
defaultValue = "150")
private Integer dpi = 150;
@Schema(
description =
"Opacity of the watermark (only applied if a watermark text is specified)",
minimum = "0.0",
maximum = "1.0",
defaultValue = "0.1")
private Float opacity = 0.1f;
@Schema(
description = "Watermark text to overlay on the video",
example = "Stirling Software",
defaultValue = "Stirling Software")
private String watermarkText;
}
@@ -0,0 +1,29 @@
package stirling.software.SPDF.model.api.converters;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class PdfVectorExportRequest extends PDFFile {
@Schema(
description = "Target vector format extension",
allowableValues = {"eps", "ps", "pcl", "xps"},
defaultValue = "eps")
@Pattern(regexp = "(?i)(eps|ps|pcl|xps)")
private String outputFormat = "eps";
@Schema(
description = "Apply Ghostscript prepress settings",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"true", "false"},
defaultValue = "false")
private Boolean prepress;
}
@@ -14,21 +14,24 @@ public class CropPdfForm extends PDFFile {
@Schema(
description = "The x-coordinate of the top-left corner of the crop area",
type = "number")
private float x;
private Float x;
@Schema(
description = "The y-coordinate of the top-left corner of the crop area",
type = "number")
private float y;
private Float y;
@Schema(description = "The width of the crop area", type = "number")
private float width;
private Float width;
@Schema(description = "The height of the crop area", type = "number")
private float height;
private Float height;
@Schema(
description = "Whether to remove text outside the crop area (keeps images)",
type = "boolean")
private boolean removeDataOutsideCrop = true;
@Schema(description = "Enable auto-crop to detect and remove white space", type = "boolean")
private boolean autoCrop = false;
}
@@ -27,7 +27,7 @@ public class AddStampRequest extends PDFWithPageNums {
@Schema(
description = "The selected alphabet of the stamp text",
allowableValues = {"roman", "arabic", "japanese", "korean", "chinese"},
allowableValues = {"roman", "arabic", "japanese", "korean", "chinese", "thai"},
defaultValue = "roman")
private String alphabet = "roman";
@@ -54,10 +54,10 @@ public class AddStampRequest extends PDFWithPageNums {
"Position for stamp placement based on a 1-9 grid (1: bottom-left, 2: bottom-center,"
+ " 3: bottom-right, 4: middle-left, 5: middle-center, 6: middle-right,"
+ " 7: top-left, 8: top-center, 9: top-right)",
type = "integer",
allowableValues = {"1", "2", "3", "4", "5", "6", "7", "8", "9"},
defaultValue = "8",
requiredMode = Schema.RequiredMode.REQUIRED)
private int position = 5;
private int position;
@Schema(
description =
@@ -0,0 +1,10 @@
package stirling.software.SPDF.model.api.misc;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class ExtractAttachmentsRequest extends PDFFile {}
@@ -18,4 +18,10 @@ public class FlattenRequest extends PDFFile {
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "false")
private Boolean flattenOnlyForms;
@Schema(
description = "Optional DPI for page rendering when flattening the full document.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
minimum = "72")
private Integer renderDpi;
}
@@ -79,14 +79,6 @@ public class ScannerEffectRequest {
@Schema(description = "Whether advanced settings are enabled", example = "false")
private boolean advancedEnabled = false;
public int getQualityValue() {
return switch (quality) {
case low -> 30;
case medium -> 60;
case high -> 100;
};
}
public int getRotationValue() {
return switch (rotation) {
case none -> 0;
@@ -97,26 +89,26 @@ public class ScannerEffectRequest {
}
public void applyHighQualityPreset() {
this.blur = 0.1f;
this.blur = 0.10f;
this.noise = 1.0f;
this.brightness = 1.02f;
this.contrast = 1.05f;
this.resolution = 300;
this.brightness = 1.03f;
this.contrast = 1.06f;
this.resolution = 150;
}
public void applyMediumQualityPreset() {
this.blur = 0.5f;
this.noise = 3.0f;
this.brightness = 1.05f;
this.contrast = 1.1f;
this.resolution = 300;
this.blur = 0.10f;
this.noise = 1.0f;
this.brightness = 1.06f;
this.contrast = 1.12f;
this.resolution = 100;
}
public void applyLowQualityPreset() {
this.blur = 1.0f;
this.noise = 5.0f;
this.brightness = 1.1f;
this.contrast = 1.2f;
this.resolution = 150;
this.blur = 0.9f;
this.noise = 2.5f;
this.brightness = 1.08f;
this.contrast = 1.15f;
this.resolution = 75;
}
}
@@ -27,7 +27,7 @@ public class AddWatermarkRequest extends PDFFile {
@Schema(
description = "The selected alphabet",
allowableValues = {"roman", "arabic", "japanese", "korean", "chinese"},
allowableValues = {"roman", "arabic", "japanese", "korean", "chinese", "thai"},
defaultValue = "roman")
private String alphabet;
@@ -3,6 +3,7 @@ package stirling.software.SPDF.pdf;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -204,12 +205,14 @@ public class TextFinder extends PDFTextStripper {
TextPosition pos = i < pageTextPositions.size() ? pageTextPositions.get(i) : null;
debug.append(
String.format(
Locale.ROOT,
" [%d] '%c' (0x%02X) -> %s\n",
i,
c,
(int) c,
pos != null
? String.format("(%.1f,%.1f)", pos.getX(), pos.getY())
? String.format(
Locale.ROOT, "(%.1f,%.1f)", pos.getX(), pos.getY())
: "null"));
}
@@ -3,6 +3,7 @@ package stirling.software.SPDF.service;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
@@ -88,7 +89,7 @@ public class ApiDocService {
: RegexPatternUtils.getInstance().getApiDocInputTypePattern())
.matcher(description);
while (matcher.find()) {
String type = matcher.group(1).toUpperCase();
String type = matcher.group(1).toUpperCase(Locale.ROOT);
if (outputToFileTypes.containsKey(type)) {
return outputToFileTypes.get(type);
}
@@ -2,14 +2,22 @@ package stirling.software.SPDF.service;
import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.commons.lang3.StringUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -17,17 +25,37 @@ import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
import org.apache.pdfbox.pdmodel.PageMode;
import org.apache.pdfbox.pdmodel.common.PDNameTreeNode;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.apache.pdfbox.pdmodel.common.filespecification.PDFileSpecification;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
public class AttachmentService implements AttachmentServiceInterface {
private static final long DEFAULT_MAX_ATTACHMENT_SIZE_BYTES = 50L * 1024 * 1024; // 50 MB
private static final long DEFAULT_MAX_TOTAL_ATTACHMENT_SIZE_BYTES =
200L * 1024 * 1024; // 200 MB
private final long maxAttachmentSizeBytes;
private final long maxTotalAttachmentSizeBytes;
public AttachmentService() {
this(DEFAULT_MAX_ATTACHMENT_SIZE_BYTES, DEFAULT_MAX_TOTAL_ATTACHMENT_SIZE_BYTES);
}
public AttachmentService(long maxAttachmentSizeBytes, long maxTotalAttachmentSizeBytes) {
this.maxAttachmentSizeBytes = maxAttachmentSizeBytes;
this.maxTotalAttachmentSizeBytes = maxTotalAttachmentSizeBytes;
}
@Override
public PDDocument addAttachment(PDDocument document, List<MultipartFile> attachments)
throws IOException {
@@ -93,6 +121,216 @@ public class AttachmentService implements AttachmentServiceInterface {
return document;
}
@Override
public Optional<byte[]> extractAttachments(PDDocument document) throws IOException {
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog == null) {
return Optional.empty();
}
PDDocumentNameDictionary documentNames = catalog.getNames();
if (documentNames == null) {
return Optional.empty();
}
PDEmbeddedFilesNameTreeNode embeddedFilesTree = documentNames.getEmbeddedFiles();
if (embeddedFilesTree == null) {
return Optional.empty();
}
Map<String, PDComplexFileSpecification> embeddedFiles = new LinkedHashMap<>();
collectEmbeddedFiles(embeddedFilesTree, embeddedFiles);
if (embeddedFiles.isEmpty()) {
return Optional.empty();
}
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ZipOutputStream zipOutputStream = new ZipOutputStream(baos)) {
Set<String> usedNames = new HashSet<>();
boolean hasExtractedAttachments = false;
long totalBytesWritten = 0L;
for (Map.Entry<String, PDComplexFileSpecification> entry : embeddedFiles.entrySet()) {
PDComplexFileSpecification fileSpecification = entry.getValue();
PDEmbeddedFile embeddedFile = getEmbeddedFile(fileSpecification);
if (embeddedFile == null) {
log.debug(
"Skipping attachment {} because embedded file was null",
entry.getKey());
continue;
}
String filename = determineFilename(entry.getKey(), fileSpecification);
filename = Filenames.toSimpleFileName(filename);
String sanitizedFilename = sanitizeFilename(filename);
Optional<byte[]> attachmentData = readAttachmentData(embeddedFile);
if (attachmentData.isEmpty()) {
log.warn(
"Skipping attachment '{}' because it exceeds the size limit of {} bytes",
sanitizedFilename,
maxAttachmentSizeBytes);
continue;
}
byte[] data = attachmentData.get();
if (maxTotalAttachmentSizeBytes > 0
&& (data.length + totalBytesWritten) > maxTotalAttachmentSizeBytes) {
log.warn(
"Skipping attachment '{}' because the total size would exceed {} bytes",
sanitizedFilename,
maxTotalAttachmentSizeBytes);
continue;
}
String uniqueFilename = ensureUniqueFilename(sanitizedFilename, usedNames);
ZipEntry zipEntry = new ZipEntry(uniqueFilename);
if (embeddedFile.getModDate() != null) {
zipEntry.setLastModifiedTime(
FileTime.from(embeddedFile.getModDate().toInstant()));
}
if (embeddedFile.getCreationDate() != null) {
zipEntry.setCreationTime(
FileTime.from(embeddedFile.getCreationDate().toInstant()));
}
zipEntry.setSize(data.length);
zipOutputStream.putNextEntry(zipEntry);
zipOutputStream.write(data);
zipOutputStream.closeEntry();
hasExtractedAttachments = true;
totalBytesWritten += data.length;
log.info("Extracted attachment '{}' ({} bytes)", uniqueFilename, data.length);
}
zipOutputStream.finish();
if (!hasExtractedAttachments) {
return Optional.empty();
}
return Optional.of(baos.toByteArray());
}
}
private String sanitizeFilename(String candidate) {
String sanitized = Filenames.toSimpleFileName(candidate);
if (StringUtils.isBlank(sanitized)) {
sanitized = generateDefaultFilename();
}
return sanitized;
}
private String generateDefaultFilename() {
return "unknown_attachment_" + System.currentTimeMillis();
}
private Optional<byte[]> readAttachmentData(PDEmbeddedFile embeddedFile) throws IOException {
try (var inputStream = embeddedFile.createInputStream();
var buffer = new ByteArrayOutputStream()) {
byte[] chunk = new byte[8192];
long total = 0L;
int read;
while ((read = inputStream.read(chunk)) != -1) {
total += read;
if (maxAttachmentSizeBytes > 0 && total > maxAttachmentSizeBytes) {
return Optional.empty();
}
buffer.write(chunk, 0, read);
}
return Optional.of(buffer.toByteArray());
}
}
private void collectEmbeddedFiles(
PDNameTreeNode<PDComplexFileSpecification> node,
Map<String, PDComplexFileSpecification> collector)
throws IOException {
if (node == null) {
return;
}
Map<String, PDComplexFileSpecification> names = node.getNames();
if (names != null) {
collector.putAll(names);
}
List<PDNameTreeNode<PDComplexFileSpecification>> kids = node.getKids();
if (kids != null) {
for (PDNameTreeNode<PDComplexFileSpecification> kid : kids) {
collectEmbeddedFiles(kid, collector);
}
}
}
private PDEmbeddedFile getEmbeddedFile(PDFileSpecification fileSpecification) {
if (!(fileSpecification instanceof PDComplexFileSpecification complexSpecification)) {
return null;
}
if (complexSpecification.getEmbeddedFileUnicode() != null) {
return complexSpecification.getEmbeddedFileUnicode();
}
if (complexSpecification.getEmbeddedFile() != null) {
return complexSpecification.getEmbeddedFile();
}
if (complexSpecification.getEmbeddedFileDos() != null) {
return complexSpecification.getEmbeddedFileDos();
}
if (complexSpecification.getEmbeddedFileMac() != null) {
return complexSpecification.getEmbeddedFileMac();
}
return complexSpecification.getEmbeddedFileUnix();
}
private String determineFilename(String key, PDComplexFileSpecification specification) {
if (specification == null) {
return fallbackFilename(key);
}
String name = specification.getFileUnicode();
if (StringUtils.isBlank(name)) {
name = specification.getFilename();
}
if (StringUtils.isBlank(name)) {
name = specification.getFile();
}
if (StringUtils.isBlank(name)) {
name = key;
}
return fallbackFilename(name);
}
private String fallbackFilename(String candidate) {
if (StringUtils.isBlank(candidate)) {
return "unknown_attachment_" + System.currentTimeMillis();
}
return candidate;
}
private String ensureUniqueFilename(String filename, Set<String> usedNames) {
String baseName = filename;
String extension = "";
int lastDot = filename.lastIndexOf('.');
if (lastDot > 0 && lastDot < filename.length() - 1) {
baseName = filename.substring(0, lastDot);
extension = filename.substring(lastDot);
}
String uniqueName = filename;
int counter = 1;
while (usedNames.contains(uniqueName)) {
uniqueName = baseName + "_" + counter + extension;
counter++;
}
usedNames.add(uniqueName);
return uniqueName;
}
private PDEmbeddedFilesNameTreeNode getEmbeddedFilesTree(PDDocument document) {
PDDocumentCatalog catalog = document.getDocumentCatalog();
PDDocumentNameDictionary documentNames = catalog.getNames();
@@ -2,6 +2,7 @@ package stirling.software.SPDF.service;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.web.multipart.MultipartFile;
@@ -10,4 +11,6 @@ public interface AttachmentServiceInterface {
PDDocument addAttachment(PDDocument document, List<MultipartFile> attachments)
throws IOException;
Optional<byte[]> extractAttachments(PDDocument document) throws IOException;
}
@@ -1,6 +1,7 @@
package stirling.software.SPDF.service;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -70,7 +71,10 @@ public class MetricsAggregatorService {
String key =
String.format(
"http_requests_%s_%s", method, uri.replace("/", "_"));
Locale.ROOT,
"http_requests_%s_%s",
method,
uri.replace("/", "_"));
double currentCount = counter.count();
double lastCount = lastSentMetrics.getOrDefault(key, 0.0);
double difference = currentCount - lastCount;
@@ -1,6 +1,7 @@
package stirling.software.SPDF.utils.text;
import java.io.IOException;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDSimpleFont;
@@ -24,7 +25,8 @@ public class TextEncodingHelper {
byte[] encoded = font.encode(text);
if (encoded.length > 0) {
log.debug(
"Text '{}' has good full-string encoding for font {} - permissively allowing",
"Text '{}' has good full-string encoding for font {} - permissively"
+ " allowing",
text,
font.getName() != null ? font.getName() : "Unknown");
return true;
@@ -61,6 +63,7 @@ public class TextEncodingHelper {
for (int i = 0; i < text.length(); ) {
int codePoint = text.codePointAt(i);
String charStr = new String(Character.toChars(codePoint));
String hex = String.format(Locale.ROOT, "%04X", codePoint); // U+%04X
totalCodePoints++;
try {
@@ -71,28 +74,23 @@ public class TextEncodingHelper {
if (charWidth >= 0) {
successfulCodePoints++;
log.debug(
"Code point '{}' (U+{}) encoded successfully",
charStr,
Integer.toHexString(codePoint).toUpperCase());
log.debug("Code point '{}' (U+{}) encoded successfully", charStr, hex);
} else {
log.debug(
"Code point '{}' (U+{}) has invalid width: {}",
charStr,
Integer.toHexString(codePoint).toUpperCase(),
hex,
charWidth);
}
} else {
log.debug(
"Code point '{}' (U+{}) encoding failed - empty result",
charStr,
Integer.toHexString(codePoint).toUpperCase());
"Code point '{}' (U+{}) encoding failed - empty result", charStr, hex);
}
} catch (IOException | IllegalArgumentException e) {
log.debug(
"Code point '{}' (U+{}) validation failed: {}",
charStr,
Integer.toHexString(codePoint).toUpperCase(),
hex,
e.getMessage());
}
@@ -100,16 +98,20 @@ public class TextEncodingHelper {
}
double successRate =
totalCodePoints > 0 ? (double) successfulCodePoints / totalCodePoints : 0;
totalCodePoints > 0 ? (double) successfulCodePoints / totalCodePoints : 0.0;
String pct =
String.format(
Locale.ROOT, "%.1f%%", successRate * 100); // Pre-formatting percentage!
boolean isAcceptable = successRate >= 0.95;
log.debug(
"Array validation for '{}': {}/{} code points successful ({:.1f}%) - {}",
"Array validation for '{}': {}/{} code points successful ({}) - {}",
text,
successfulCodePoints,
totalCodePoints,
successRate * 100,
isAcceptable ? "ALLOWING" : "rejecting");
pct,
(isAcceptable ? "ALLOWING" : "rejecting"));
return isAcceptable;
}