mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
V1 merge (#5193)
# 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:
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
+4
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-36
@@ -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(
|
||||
|
||||
+35
-9
@@ -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();
|
||||
|
||||
+12
-11
@@ -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.
|
||||
|
||||
+14
-9
@@ -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) {
|
||||
|
||||
+109
-76
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -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());
|
||||
|
||||
+6
-33
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+118
-43
@@ -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;
|
||||
|
||||
+208
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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()
|
||||
|
||||
+10
-51
@@ -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");
|
||||
|
||||
|
||||
+14
-5
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+204
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-19
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+1286
-265
File diff suppressed because it is too large
Load Diff
+293
@@ -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");
|
||||
};
|
||||
}
|
||||
}
|
||||
+136
-7
@@ -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|/|/)");
|
||||
|
||||
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(":", ":")
|
||||
.replace("/", "/")
|
||||
.replace("⁄", "/");
|
||||
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()) {
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+252
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
-101
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -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);
|
||||
|
||||
+17
-10
@@ -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);
|
||||
|
||||
+713
-383
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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());
|
||||
|
||||
+9
-8
@@ -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
|
||||
|
||||
+51
-17
@@ -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(
|
||||
|
||||
+42
-26
@@ -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));
|
||||
|
||||
+16
-5
@@ -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(
|
||||
() ->
|
||||
|
||||
+4
-1
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+757
-422
File diff suppressed because it is too large
Load Diff
+54
-57
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -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;
|
||||
|
||||
+25
-7
@@ -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 {
|
||||
|
||||
+746
-442
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -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);
|
||||
|
||||
+6
-3
@@ -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 =
|
||||
|
||||
+68
-39
@@ -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
|
||||
|
||||
+13
-1
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
|
||||
+19
-1
@@ -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;
|
||||
}
|
||||
|
||||
+53
@@ -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;
|
||||
}
|
||||
+58
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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;
|
||||
}
|
||||
|
||||
+53
@@ -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;
|
||||
}
|
||||
+29
@@ -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 =
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
|
||||
+14
-22
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user