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;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ server.error.whitelabel.enabled=false
|
||||
server.error.include-stacktrace=always
|
||||
server.error.include-exception=true
|
||||
server.error.include-message=always
|
||||
|
||||
# Enable RFC 7807 Problem Details for HTTP APIs
|
||||
spring.mvc.problemdetails.enabled=true
|
||||
|
||||
#logging.level.org.springframework.web=DEBUG
|
||||
#logging.level.org.springframework=DEBUG
|
||||
#logging.level.org.springframework.security=DEBUG
|
||||
|
||||
@@ -97,6 +97,13 @@ premium:
|
||||
enabled: true # Enable audit logging
|
||||
level: 2 # Audit logging level: 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
||||
retentionDays: 90 # Number of days to retain audit logs
|
||||
databaseNotifications:
|
||||
backups:
|
||||
successful: false # set to 'true' to enable email notifications for successful database backups
|
||||
failed: false # set to 'true' to enable email notifications for failed database backups
|
||||
imports:
|
||||
successful: false # set to 'true' to enable email notifications for successful database imports
|
||||
failed: false # set to 'true' to enable email notifications for failed database imports
|
||||
|
||||
mail:
|
||||
enabled: false # set to 'true' to enable sending emails
|
||||
@@ -126,7 +133,7 @@ system:
|
||||
showUpdate: false # see when a new update is available
|
||||
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
|
||||
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
|
||||
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
|
||||
tessdataDir: "" # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
|
||||
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
|
||||
enableDesktopInstallSlide: true # Set to 'false' to hide the desktop app installation slide in the onboarding flow
|
||||
enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
|
||||
@@ -169,6 +176,9 @@ system:
|
||||
operations:
|
||||
weasyprint: '' # Defaults to /opt/venv/bin/weasyprint
|
||||
unoconvert: '' # Defaults to /opt/venv/bin/unoconvert
|
||||
calibre: '' # Defaults to /usr/bin/ebook-convert
|
||||
ocrmypdf: '' # Defaults to /usr/bin/ocrmypdf
|
||||
soffice: '' # Defaults to /usr/bin/soffice
|
||||
fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB".
|
||||
tempFileManagement:
|
||||
baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-classic",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
"moduleVersion": "1.5.18",
|
||||
"moduleVersion": "1.5.20",
|
||||
"moduleLicense": "GNU Lesser General Public License",
|
||||
"moduleLicenseUrl": "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-core",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
"moduleVersion": "1.5.18",
|
||||
"moduleVersion": "1.5.20",
|
||||
"moduleLicense": "GNU Lesser General Public License",
|
||||
"moduleLicenseUrl": "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
@@ -122,7 +122,7 @@
|
||||
{
|
||||
"moduleName": "com.fasterxml:classmate",
|
||||
"moduleUrl": "https://github.com/FasterXML/java-classmate",
|
||||
"moduleVersion": "1.7.0",
|
||||
"moduleVersion": "1.7.1",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -151,6 +151,13 @@
|
||||
"moduleVersion": "1.4.0",
|
||||
"moduleLicense": "LICENSE-JJ2000.txt, LICENSE-Sun.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.github.junrar:junrar",
|
||||
"moduleUrl": "https://github.com/junrar/junrar",
|
||||
"moduleVersion": "7.5.7",
|
||||
"moduleLicense": "UnRar License",
|
||||
"moduleLicenseUrl": "https://github.com/junrar/junrar/blob/master/LICENSE"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.github.stephenc.jcip:jcip-annotations",
|
||||
"moduleUrl": "http://stephenc.github.com/jcip-annotations",
|
||||
@@ -168,7 +175,7 @@
|
||||
{
|
||||
"moduleName": "com.google.code.gson:gson",
|
||||
"moduleUrl": "https://github.com/google/gson",
|
||||
"moduleVersion": "2.13.1",
|
||||
"moduleVersion": "2.13.2",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -179,6 +186,13 @@
|
||||
"moduleLicense": "Apache 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.errorprone:error_prone_annotations",
|
||||
"moduleUrl": "https://errorprone.info/error_prone_annotations",
|
||||
"moduleVersion": "2.41.0",
|
||||
"moduleLicense": "Apache 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.guava:failureaccess",
|
||||
"moduleUrl": "https://github.com/google/guava/",
|
||||
@@ -263,7 +277,7 @@
|
||||
{
|
||||
"moduleName": "com.nimbusds:nimbus-jose-jwt",
|
||||
"moduleUrl": "https://connect2id.com",
|
||||
"moduleVersion": "9.37.3",
|
||||
"moduleVersion": "9.37.4",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -364,14 +378,14 @@
|
||||
{
|
||||
"moduleName": "com.unboundid.product.scim2:scim2-sdk-client",
|
||||
"moduleUrl": "https://github.com/pingidentity/scim2",
|
||||
"moduleVersion": "4.0.0",
|
||||
"moduleVersion": "4.1.0",
|
||||
"moduleLicense": "UnboundID SCIM2 SDK Free Use License",
|
||||
"moduleLicenseUrl": "https://github.com/pingidentity/scim2"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.unboundid.product.scim2:scim2-sdk-common",
|
||||
"moduleUrl": "https://github.com/pingidentity/scim2",
|
||||
"moduleVersion": "4.0.0",
|
||||
"moduleVersion": "4.1.0",
|
||||
"moduleLicense": "UnboundID SCIM2 SDK Free Use License",
|
||||
"moduleLicenseUrl": "https://github.com/pingidentity/scim2"
|
||||
},
|
||||
@@ -504,7 +518,7 @@
|
||||
{
|
||||
"moduleName": "com.zaxxer:HikariCP",
|
||||
"moduleUrl": "https://github.com/brettwooldridge/HikariCP",
|
||||
"moduleVersion": "6.3.2",
|
||||
"moduleVersion": "6.3.3",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -587,35 +601,35 @@
|
||||
{
|
||||
"moduleName": "io.micrometer:micrometer-commons",
|
||||
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
|
||||
"moduleVersion": "1.15.3",
|
||||
"moduleVersion": "1.15.5",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.micrometer:micrometer-core",
|
||||
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
|
||||
"moduleVersion": "1.15.3",
|
||||
"moduleVersion": "1.15.5",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.micrometer:micrometer-jakarta9",
|
||||
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
|
||||
"moduleVersion": "1.15.3",
|
||||
"moduleVersion": "1.15.5",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.micrometer:micrometer-observation",
|
||||
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
|
||||
"moduleVersion": "1.15.3",
|
||||
"moduleVersion": "1.15.5",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.micrometer:micrometer-registry-prometheus",
|
||||
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
|
||||
"moduleVersion": "1.15.3",
|
||||
"moduleVersion": "1.15.5",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -668,6 +682,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
|
||||
"moduleVersion": "2.2.40",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
|
||||
@@ -675,6 +696,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
|
||||
"moduleVersion": "2.2.40",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
|
||||
@@ -682,10 +710,17 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
|
||||
"moduleVersion": "2.2.40",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "jakarta.activation:jakarta.activation-api",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "2.1.3",
|
||||
"moduleVersion": "2.1.4",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
@@ -727,14 +762,7 @@
|
||||
{
|
||||
"moduleName": "jakarta.mail:jakarta.mail-api",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "2.1.3",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "jakarta.mail:jakarta.mail-api",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "2.1.4",
|
||||
"moduleVersion": "2.1.5",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
@@ -790,7 +818,7 @@
|
||||
{
|
||||
"moduleName": "jakarta.xml.bind:jakarta.xml.bind-api",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "4.0.2",
|
||||
"moduleVersion": "4.0.4",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
@@ -824,7 +852,7 @@
|
||||
},
|
||||
{
|
||||
"moduleName": "net.bytebuddy:byte-buddy",
|
||||
"moduleVersion": "1.17.7",
|
||||
"moduleVersion": "1.17.8",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -886,7 +914,7 @@
|
||||
{
|
||||
"moduleName": "org.apache.commons:commons-lang3",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-lang/",
|
||||
"moduleVersion": "3.18.0",
|
||||
"moduleVersion": "3.19.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -933,7 +961,7 @@
|
||||
{
|
||||
"moduleName": "org.apache.pdfbox:fontbox",
|
||||
"moduleUrl": "https://pdfbox.apache.org",
|
||||
"moduleVersion": "3.0.5",
|
||||
"moduleVersion": "3.0.6",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -946,28 +974,28 @@
|
||||
{
|
||||
"moduleName": "org.apache.pdfbox:pdfbox",
|
||||
"moduleUrl": "https://pdfbox.apache.org",
|
||||
"moduleVersion": "3.0.5",
|
||||
"moduleVersion": "3.0.6",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.pdfbox:pdfbox-io",
|
||||
"moduleUrl": "https://pdfbox.apache.org",
|
||||
"moduleVersion": "3.0.5",
|
||||
"moduleVersion": "3.0.6",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.pdfbox:preflight",
|
||||
"moduleUrl": "https://pdfbox.apache.org",
|
||||
"moduleVersion": "3.0.5",
|
||||
"moduleVersion": "3.0.6",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.pdfbox:xmpbox",
|
||||
"moduleUrl": "https://pdfbox.apache.org",
|
||||
"moduleVersion": "3.0.5",
|
||||
"moduleVersion": "3.0.6",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -981,7 +1009,7 @@
|
||||
{
|
||||
"moduleName": "org.apache.tomcat.embed:tomcat-embed-el",
|
||||
"moduleUrl": "https://tomcat.apache.org/",
|
||||
"moduleVersion": "10.1.44",
|
||||
"moduleVersion": "10.1.48",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -1029,14 +1057,14 @@
|
||||
{
|
||||
"moduleName": "org.bouncycastle:bcpkix-jdk18on",
|
||||
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
|
||||
"moduleVersion": "1.81",
|
||||
"moduleVersion": "1.82",
|
||||
"moduleLicense": "Bouncy Castle Licence",
|
||||
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.bouncycastle:bcprov-jdk18on",
|
||||
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
|
||||
"moduleVersion": "1.81",
|
||||
"moduleVersion": "1.82",
|
||||
"moduleLicense": "Bouncy Castle Licence",
|
||||
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
|
||||
},
|
||||
@@ -1050,26 +1078,26 @@
|
||||
{
|
||||
"moduleName": "org.bouncycastle:bcutil-jdk18on",
|
||||
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
|
||||
"moduleVersion": "1.81",
|
||||
"moduleVersion": "1.82",
|
||||
"moduleLicense": "Bouncy Castle Licence",
|
||||
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.checkerframework:checker-qual",
|
||||
"moduleUrl": "https://checkerframework.org/",
|
||||
"moduleVersion": "3.49.3",
|
||||
"moduleVersion": "3.49.5",
|
||||
"moduleLicense": "The MIT License",
|
||||
"moduleLicenseUrl": "http://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.commonmark:commonmark",
|
||||
"moduleVersion": "0.25.1",
|
||||
"moduleVersion": "0.27.0",
|
||||
"moduleLicense": "BSD-2-Clause",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/BSD-2-Clause"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.commonmark:commonmark-ext-gfm-tables",
|
||||
"moduleVersion": "0.25.1",
|
||||
"moduleVersion": "0.27.0",
|
||||
"moduleLicense": "BSD-2-Clause",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/BSD-2-Clause"
|
||||
},
|
||||
@@ -1083,224 +1111,224 @@
|
||||
{
|
||||
"moduleName": "org.eclipse.angus:angus-activation",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "2.0.2",
|
||||
"moduleVersion": "2.0.3",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.angus:angus-mail",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "2.0.4",
|
||||
"moduleVersion": "2.0.5",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.angus:jakarta.mail",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "2.0.4",
|
||||
"moduleVersion": "2.0.5",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-client",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-common",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-server",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jetty-server",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-servlet",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.ee10:jetty-ee10-annotations",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.ee10:jetty-ee10-plus",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.ee10:jetty-ee10-servlet",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.ee10:jetty-ee10-servlets",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.ee10:jetty-ee10-webapp",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.websocket:jetty-websocket-core-client",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.websocket:jetty-websocket-core-common",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.websocket:jetty-websocket-core-server",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.websocket:jetty-websocket-jetty-api",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty.websocket:jetty-websocket-jetty-common",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-alpn-client",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-client",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-ee",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-http",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-io",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-plus",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-security",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-server",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-session",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-util",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.eclipse.jetty:jetty-xml",
|
||||
"moduleUrl": "https://jetty.org/",
|
||||
"moduleVersion": "12.0.25",
|
||||
"moduleVersion": "12.0.29",
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jaxb:jaxb-core",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "4.0.5",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jaxb:jaxb-runtime",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "4.0.5",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jaxb:txw2",
|
||||
"moduleUrl": "https://eclipse-ee4j.github.io/jaxb-ri/",
|
||||
"moduleVersion": "4.0.5",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
@@ -1321,7 +1349,7 @@
|
||||
{
|
||||
"moduleName": "org.hibernate.orm:hibernate-core",
|
||||
"moduleUrl": "https://www.hibernate.org/orm/6.6",
|
||||
"moduleVersion": "6.6.26.Final",
|
||||
"moduleVersion": "6.6.33.Final",
|
||||
"moduleLicense": "GNU Library General Public License v2.1 or later",
|
||||
"moduleLicenseUrl": "https://www.opensource.org/licenses/LGPL-2.1"
|
||||
},
|
||||
@@ -1472,28 +1500,28 @@
|
||||
{
|
||||
"moduleName": "org.ow2.asm:asm",
|
||||
"moduleUrl": "http://asm.ow2.org",
|
||||
"moduleVersion": "9.8",
|
||||
"moduleVersion": "9.9",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.ow2.asm:asm-commons",
|
||||
"moduleUrl": "http://asm.ow2.org",
|
||||
"moduleVersion": "9.8",
|
||||
"moduleVersion": "9.9",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.ow2.asm:asm-tree",
|
||||
"moduleUrl": "http://asm.ow2.org",
|
||||
"moduleVersion": "9.8",
|
||||
"moduleVersion": "9.9",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.postgresql:postgresql",
|
||||
"moduleUrl": "https://jdbc.postgresql.org/",
|
||||
"moduleVersion": "42.7.7",
|
||||
"moduleVersion": "42.7.8",
|
||||
"moduleLicense": "BSD-2-Clause",
|
||||
"moduleLicenseUrl": "https://jdbc.postgresql.org/about/license.html"
|
||||
},
|
||||
@@ -1520,327 +1548,327 @@
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springdoc:springdoc-openapi-starter-common",
|
||||
"moduleVersion": "2.8.12",
|
||||
"moduleVersion": "2.8.13",
|
||||
"moduleLicense": "The Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springdoc:springdoc-openapi-starter-webmvc-api",
|
||||
"moduleVersion": "2.8.12",
|
||||
"moduleVersion": "2.8.13",
|
||||
"moduleLicense": "The Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springdoc:springdoc-openapi-starter-webmvc-ui",
|
||||
"moduleVersion": "2.8.12",
|
||||
"moduleVersion": "2.8.13",
|
||||
"moduleLicense": "The Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-actuator",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-actuator-autoconfigure",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-autoconfigure",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-devtools",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-actuator",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-aop",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-cache",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-data-jpa",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-jdbc",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-jetty",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-json",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-logging",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-mail",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-oauth2-client",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-security",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-thymeleaf",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-validation",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-web",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.data:spring-data-commons",
|
||||
"moduleUrl": "https://spring.io/projects/spring-data",
|
||||
"moduleVersion": "3.5.3",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.data:spring-data-jpa",
|
||||
"moduleUrl": "https://projects.spring.io/spring-data-jpa",
|
||||
"moduleVersion": "3.5.3",
|
||||
"moduleVersion": "3.5.5",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-config",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "6.5.3",
|
||||
"moduleVersion": "6.5.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-core",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "6.5.3",
|
||||
"moduleVersion": "6.5.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-crypto",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "6.5.3",
|
||||
"moduleVersion": "6.5.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-oauth2-client",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "6.5.3",
|
||||
"moduleVersion": "6.5.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-oauth2-core",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "6.5.3",
|
||||
"moduleVersion": "6.5.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-oauth2-jose",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "6.5.3",
|
||||
"moduleVersion": "6.5.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-saml2-service-provider",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "6.5.3",
|
||||
"moduleVersion": "6.5.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-web",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "6.5.3",
|
||||
"moduleVersion": "6.5.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.session:spring-session-core",
|
||||
"moduleUrl": "https://spring.io/projects/spring-session",
|
||||
"moduleVersion": "3.5.2",
|
||||
"moduleVersion": "3.5.3",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-aop",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-aspects",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-beans",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-context",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-context-support",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-core",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-expression",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-jcl",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-jdbc",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-orm",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-tx",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-web",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework:spring-webmvc",
|
||||
"moduleUrl": "https://github.com/spring-projects/spring-framework",
|
||||
"moduleVersion": "6.2.10",
|
||||
"moduleVersion": "6.2.12",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
@@ -1878,13 +1906,13 @@
|
||||
{
|
||||
"moduleName": "org.webjars:swagger-ui",
|
||||
"moduleUrl": "https://www.webjars.org",
|
||||
"moduleVersion": "5.28.0",
|
||||
"moduleVersion": "5.28.1",
|
||||
"moduleLicense": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.webjars:webjars-locator-lite",
|
||||
"moduleUrl": "https://webjars.org",
|
||||
"moduleVersion": "1.1.0",
|
||||
"moduleVersion": "1.1.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://github.com/webjars/webjars-locator-lite/blob/main/LICENSE.md"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
visbility 0.1s linear,
|
||||
visibility 0.1s linear,
|
||||
background-color 0.1s linear;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,6 @@
|
||||
position: absolute;
|
||||
background-color: red; /* Line color */
|
||||
}
|
||||
#pageToSplitSection {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,147 @@
|
||||
const PDFJS_DEFAULT_OPTIONS = {
|
||||
cMapUrl: pdfjsPath + 'cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: pdfjsPath + 'standard_fonts/',
|
||||
};
|
||||
|
||||
function formatProblemDetailsJson(input) {
|
||||
try {
|
||||
const obj = typeof input === 'string' ? JSON.parse(input) : input;
|
||||
const preferredOrder = [
|
||||
'errorCode',
|
||||
'title',
|
||||
'status',
|
||||
'type',
|
||||
'detail',
|
||||
'instance',
|
||||
'path',
|
||||
'timestamp',
|
||||
'hints',
|
||||
'actionRequired'
|
||||
];
|
||||
|
||||
const ordered = {};
|
||||
preferredOrder.forEach((key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
ordered[key] = obj[key];
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(ordered, key)) {
|
||||
ordered[key] = obj[key];
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify(ordered, null, 2);
|
||||
} catch (err) {
|
||||
if (typeof input === 'string') return input;
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch (jsonErr) {
|
||||
return String(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatUserFriendlyError(json) {
|
||||
if (!json || typeof json !== 'object') {
|
||||
return typeof json === 'string' ? json : '';
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
const title = json.title || json.error || '';
|
||||
const detail = json.detail || json.message || '';
|
||||
|
||||
const primaryLine = title && detail ? `${title}: ${detail}` : title || detail;
|
||||
|
||||
if (primaryLine) {
|
||||
lines.push(primaryLine);
|
||||
}
|
||||
|
||||
if (json.errorCode) {
|
||||
lines.push('');
|
||||
lines.push(`Error Code: ${json.errorCode}`);
|
||||
}
|
||||
|
||||
const detailAlreadyIncluded = detail && primaryLine && primaryLine.includes(detail);
|
||||
if (detail && !detailAlreadyIncluded) {
|
||||
lines.push('');
|
||||
lines.push(detail);
|
||||
}
|
||||
|
||||
if (json.hints && Array.isArray(json.hints) && json.hints.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('How to fix:');
|
||||
json.hints.forEach((hint, index) => {
|
||||
lines.push(` ${index + 1}. ${hint}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (json.actionRequired) {
|
||||
lines.push('');
|
||||
lines.push(json.actionRequired);
|
||||
}
|
||||
|
||||
if (json.supportId) {
|
||||
lines.push('');
|
||||
lines.push(`Support ID: ${json.supportId}`);
|
||||
}
|
||||
|
||||
return lines
|
||||
.filter((line, index, arr) => {
|
||||
if (line !== '') return true;
|
||||
if (index === 0 || index === arr.length - 1) return false;
|
||||
return arr[index - 1] !== '';
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildPdfPasswordProblemDetail(fileName) {
|
||||
const stirling = window.stirlingPDF || {};
|
||||
const detailTemplate = stirling.pdfPasswordDetail || 'The PDF Document is passworded and either the password was not provided or was incorrect';
|
||||
const title = stirling.pdfPasswordTitle || 'PDF Password Required';
|
||||
const hints = [
|
||||
stirling.pdfPasswordHint1,
|
||||
stirling.pdfPasswordHint2,
|
||||
stirling.pdfPasswordHint3,
|
||||
stirling.pdfPasswordHint4,
|
||||
stirling.pdfPasswordHint5,
|
||||
stirling.pdfPasswordHint6
|
||||
].filter((hint) => typeof hint === 'string' && hint.trim().length > 0);
|
||||
const actionRequired = stirling.pdfPasswordAction || 'Provide the owner/permissions password, not just the document open password.';
|
||||
|
||||
return {
|
||||
errorCode: 'E004',
|
||||
title,
|
||||
detail: detailTemplate.replace('{0}', fileName),
|
||||
type: '/errors/pdf-password',
|
||||
path: '/api/v1/security/remove-password',
|
||||
hints,
|
||||
actionRequired
|
||||
};
|
||||
}
|
||||
|
||||
function buildCorruptedPdfProblemDetail(fileName) {
|
||||
const stirling = window.stirlingPDF || {};
|
||||
const detailTemplate = stirling.pdfCorruptedMessage || 'The PDF file "{0}" appears to be corrupted or has an invalid structure.';
|
||||
const hints = [
|
||||
stirling.pdfCorruptedHint1,
|
||||
stirling.pdfCorruptedHint2,
|
||||
stirling.pdfCorruptedHint3
|
||||
].filter((hint) => typeof hint === 'string' && hint.trim().length > 0);
|
||||
const actionRequired = stirling.pdfCorruptedAction || stirling.tryRepairMessage || '';
|
||||
|
||||
return {
|
||||
errorCode: 'E001',
|
||||
title: stirling.pdfCorruptedTitle || 'PDF File Corrupted',
|
||||
detail: detailTemplate.replace('{0}', fileName),
|
||||
type: '/errors/pdf-corrupted',
|
||||
hints,
|
||||
actionRequired
|
||||
};
|
||||
}
|
||||
|
||||
export class DecryptFile {
|
||||
|
||||
constructor(){
|
||||
@@ -35,11 +179,8 @@ export class DecryptFile {
|
||||
if (!password) {
|
||||
// No password provided
|
||||
console.error(`No password provided for encrypted PDF: ${file.name}`);
|
||||
this.showErrorBanner(
|
||||
`${window.decrypt.noPassword.replace('{0}', file.name)}`,
|
||||
'',
|
||||
`${window.decrypt.unexpectedError}`
|
||||
);
|
||||
const problemDetail = buildPdfPasswordProblemDetail(file.name);
|
||||
this.showProblemDetail(problemDetail);
|
||||
return null; // No file to return
|
||||
}
|
||||
|
||||
@@ -51,30 +192,48 @@ export class DecryptFile {
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.removeErrorBanner();
|
||||
const decryptedBlob = await response.blob();
|
||||
return new File([decryptedBlob], file.name, {
|
||||
type: "application/pdf",
|
||||
});
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error(`${window.decrypt.invalidPassword} ${errorText}`);
|
||||
this.showErrorBanner(
|
||||
`${window.decrypt.invalidPassword}`,
|
||||
errorText,
|
||||
`${window.decrypt.invalidPasswordHeader.replace('{0}', file.name)}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json') || contentType.includes('application/problem+json')) {
|
||||
const errorJson = await response.json();
|
||||
this.showProblemDetail(errorJson);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error(`${window.decrypt.invalidPassword} ${errorText}`);
|
||||
const fallbackProblem = buildPdfPasswordProblemDetail(file.name);
|
||||
if (errorText && errorText.trim().length > 0) {
|
||||
fallbackProblem.detail = errorText.trim();
|
||||
}
|
||||
this.showProblemDetail(fallbackProblem);
|
||||
}
|
||||
return null; // No file to return
|
||||
}
|
||||
|
||||
this.removeErrorBanner();
|
||||
const decryptedBlob = await response.blob();
|
||||
return new File([decryptedBlob], file.name, {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle network or unexpected errors
|
||||
console.error(`Failed to decrypt PDF: ${file.name}`, error);
|
||||
this.showErrorBanner(
|
||||
`${window.decrypt.unexpectedError.replace('{0}', file.name)}`,
|
||||
`${error.message || window.decrypt.unexpectedError}`,
|
||||
error
|
||||
);
|
||||
const fallbackDetail =
|
||||
(error && error.message) ||
|
||||
window.decrypt.unexpectedError ||
|
||||
'There was an error processing the file. Please try again.';
|
||||
|
||||
const unexpectedProblem = {
|
||||
title: (window.stirlingPDF && window.stirlingPDF.errorUnexpectedTitle) || 'Unexpected Error',
|
||||
detail: fallbackDetail,
|
||||
};
|
||||
|
||||
if (window.decrypt.serverError) {
|
||||
unexpectedProblem.hints = [
|
||||
window.decrypt.serverError.replace('{0}', file.name),
|
||||
];
|
||||
}
|
||||
|
||||
this.showProblemDetail(unexpectedProblem);
|
||||
return null; // No file to return
|
||||
}
|
||||
}
|
||||
@@ -85,7 +244,7 @@ export class DecryptFile {
|
||||
return {isEncrypted: false, requiresPassword: false};
|
||||
}
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = './pdfjs-legacy/pdf.worker.mjs';
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const arrayBufferForPdfLib = arrayBuffer.slice(0);
|
||||
@@ -93,12 +252,14 @@ export class DecryptFile {
|
||||
|
||||
if(this.decryptWorker == null){
|
||||
loadingTask = pdfjsLib.getDocument({
|
||||
...PDFJS_DEFAULT_OPTIONS,
|
||||
data: arrayBuffer,
|
||||
});
|
||||
this.decryptWorker = loadingTask._worker
|
||||
|
||||
}else {
|
||||
loadingTask = pdfjsLib.getDocument({
|
||||
...PDFJS_DEFAULT_OPTIONS,
|
||||
data: arrayBuffer,
|
||||
worker: this.decryptWorker
|
||||
});
|
||||
@@ -129,11 +290,8 @@ export class DecryptFile {
|
||||
// Handle corrupted PDF files
|
||||
console.error('Corrupted PDF detected:', error);
|
||||
if (window.stirlingPDF.currentPage !== 'repair') {
|
||||
this.showErrorBanner(
|
||||
`${window.stirlingPDF.pdfCorruptedMessage.replace('{0}', file.name)}`,
|
||||
error.stack || '',
|
||||
`${window.stirlingPDF.tryRepairMessage}`
|
||||
);
|
||||
const corruptedProblem = buildCorruptedPdfProblemDetail(file.name);
|
||||
this.showProblemDetail(corruptedProblem);
|
||||
} else {
|
||||
console.log('Suppressing corrupted PDF warning banner on repair page');
|
||||
}
|
||||
@@ -145,19 +303,62 @@ export class DecryptFile {
|
||||
}
|
||||
}
|
||||
|
||||
showErrorBanner(message, stackTrace, error) {
|
||||
showProblemDetail(problemDetail) {
|
||||
const errorContainer = document.getElementById('errorContainer');
|
||||
errorContainer.style.display = 'block'; // Display the banner
|
||||
errorContainer.querySelector('.alert-heading').textContent = error;
|
||||
errorContainer.querySelector('p').textContent = message;
|
||||
document.querySelector('#traceContent').textContent = stackTrace;
|
||||
if (!errorContainer) {
|
||||
console.error('Error container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
errorContainer.style.display = 'block';
|
||||
|
||||
const heading = errorContainer.querySelector('.alert-heading');
|
||||
const messageEl = errorContainer.querySelector('p');
|
||||
const traceEl = document.querySelector('#traceContent');
|
||||
|
||||
const fallbackHeading = (window.stirlingPDF && window.stirlingPDF.error) || 'Error';
|
||||
|
||||
if (heading) {
|
||||
heading.textContent =
|
||||
(problemDetail && typeof problemDetail === 'object' && problemDetail.title) ||
|
||||
fallbackHeading;
|
||||
}
|
||||
|
||||
if (messageEl) {
|
||||
messageEl.style.whiteSpace = 'pre-wrap';
|
||||
messageEl.textContent =
|
||||
typeof problemDetail === 'object'
|
||||
? formatUserFriendlyError(problemDetail)
|
||||
: String(problemDetail || '');
|
||||
}
|
||||
|
||||
if (traceEl) {
|
||||
traceEl.textContent =
|
||||
typeof problemDetail === 'object' ? formatProblemDetailsJson(problemDetail) : '';
|
||||
}
|
||||
}
|
||||
|
||||
removeErrorBanner() {
|
||||
const errorContainer = document.getElementById('errorContainer');
|
||||
errorContainer.style.display = 'none'; // Hide the banner
|
||||
errorContainer.querySelector('.alert-heading').textContent = '';
|
||||
errorContainer.querySelector('p').textContent = '';
|
||||
document.querySelector('#traceContent').textContent = '';
|
||||
if (!errorContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
errorContainer.style.display = 'none';
|
||||
|
||||
const heading = errorContainer.querySelector('.alert-heading');
|
||||
if (heading) {
|
||||
heading.textContent = (window.stirlingPDF && window.stirlingPDF.error) || 'Error';
|
||||
}
|
||||
|
||||
const messageEl = errorContainer.querySelector('p');
|
||||
if (messageEl) {
|
||||
messageEl.textContent = '';
|
||||
}
|
||||
|
||||
const traceEl = document.querySelector('#traceContent');
|
||||
if (traceEl) {
|
||||
traceEl.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
importScripts('./diff.js');
|
||||
|
||||
let complexMessage = 'One or both of the provided documents are large files, accuracy of comparison may be reduced';
|
||||
let largeFilesMessage = 'One or Both of the provided documents are too large to process';
|
||||
|
||||
// Early: Listener for SET messages (before onmessage)
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'SET_COMPLEX_MESSAGE') {
|
||||
complexMessage = event.data.message;
|
||||
} else if (event.data.type === 'SET_TOO_LARGE_MESSAGE') {
|
||||
largeFilesMessage = event.data.message;
|
||||
}
|
||||
});
|
||||
|
||||
self.onmessage = async function (e) {
|
||||
const { text1, text2, color1, color2 } = e.data;
|
||||
console.log('Received text for comparison:', { text1, text2 });
|
||||
const data = e.data;
|
||||
if (data.type !== 'COMPARE') {
|
||||
console.log('Worker ignored non-COMPARE message');
|
||||
return;
|
||||
}
|
||||
|
||||
const { text1, text2, color1, color2 } = data;
|
||||
console.log('Received text for comparison:', { lengths: { text1: text1.length, text2: text2.length } }); // Safe Log
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
if (text1.trim() === "" || text2.trim() === "") {
|
||||
// Safe Trim
|
||||
if (!text1 || !text2 || text1.trim() === "" || text2.trim() === "") {
|
||||
self.postMessage({ status: 'error', message: 'One or both of the texts are empty.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const words1 = text1.split(' ');
|
||||
const words2 = text2.split(' ');
|
||||
// Robust Word-Split (handles spaces/punctuation better)
|
||||
const words1 = text1.trim().split(/\s+/).filter(w => w.length > 0);
|
||||
const words2 = text2.trim().split(/\s+/).filter(w => w.length > 0);
|
||||
|
||||
const MAX_WORD_COUNT = 150000;
|
||||
const COMPLEX_WORD_COUNT = 50000;
|
||||
const BATCH_SIZE = 5000; // Define a suitable batch size for processing
|
||||
@@ -21,44 +42,28 @@ self.onmessage = async function (e) {
|
||||
const isComplex = words1.length > COMPLEX_WORD_COUNT || words2.length > COMPLEX_WORD_COUNT;
|
||||
const isTooLarge = words1.length > MAX_WORD_COUNT || words2.length > MAX_WORD_COUNT;
|
||||
|
||||
let complexMessage = 'One or both of the provided documents are large files, accuracy of comparison may be reduced';
|
||||
let tooLargeMessage = 'One or Both of the provided documents are too large to process';
|
||||
|
||||
// Listen for messages from the main thread
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'SET_TOO_LARGE_MESSAGE') {
|
||||
tooLargeMessage = event.data.message;
|
||||
}
|
||||
if (event.data.type === 'SET_COMPLEX_MESSAGE') {
|
||||
complexMessage = event.data.message;
|
||||
}
|
||||
});
|
||||
|
||||
if (isTooLarge) {
|
||||
self.postMessage({
|
||||
status: 'warning',
|
||||
message: tooLargeMessage,
|
||||
});
|
||||
self.postMessage({ status: 'error', message: largeFilesMessage });
|
||||
return;
|
||||
} else {
|
||||
|
||||
if (isComplex) {
|
||||
self.postMessage({
|
||||
status: 'warning',
|
||||
message: complexMessage,
|
||||
});
|
||||
}
|
||||
// Perform diff operation depending on document size
|
||||
const differences = isComplex
|
||||
? await staggeredBatchDiff(words1, words2, color1, color2, BATCH_SIZE, OVERLAP_SIZE)
|
||||
: diff(words1, words2, color1, color2);
|
||||
|
||||
console.log(`Diff operation took ${performance.now() - startTime} milliseconds`);
|
||||
self.postMessage({ status: 'success', differences });
|
||||
}
|
||||
|
||||
if (isComplex) {
|
||||
self.postMessage({ status: 'warning', message: complexMessage });
|
||||
}
|
||||
|
||||
// Diff based on size
|
||||
let differences;
|
||||
if (isComplex) {
|
||||
differences = await staggeredBatchDiff(words1, words2, color1 || '#ff0000', color2 || '#008000', BATCH_SIZE, OVERLAP_SIZE);
|
||||
} else {
|
||||
differences = diff(words1, words2, color1 || '#ff0000', color2 || '#008000');
|
||||
}
|
||||
|
||||
console.log(`Diff took ${performance.now() - startTime} ms for ${words1.length + words2.length} words`);
|
||||
self.postMessage({ status: 'success', differences });
|
||||
};
|
||||
|
||||
//Splits text into smaller batches to run through diff checking algorithms. overlaps the batches to help ensure
|
||||
// Splits text into smaller batches to run through diff checking algorithms. overlaps the batches to help ensure
|
||||
async function staggeredBatchDiff(words1, words2, color1, color2, batchSize, overlapSize) {
|
||||
const differences = [];
|
||||
const totalWords1 = words1.length;
|
||||
@@ -67,10 +72,9 @@ async function staggeredBatchDiff(words1, words2, color1, color2, batchSize, ove
|
||||
let previousEnd1 = 0; // Track where the last batch ended in words1
|
||||
let previousEnd2 = 0; // Track where the last batch ended in words2
|
||||
|
||||
// Function to determine if differences are large, differences that are too large indicate potential error in batching
|
||||
const isLargeDifference = (differences) => {
|
||||
return differences.length > 50;
|
||||
};
|
||||
// Track processed indices to dedupe overlaps
|
||||
const processed1 = new Set();
|
||||
const processed2 = new Set();
|
||||
|
||||
while (previousEnd1 < totalWords1 || previousEnd2 < totalWords2) {
|
||||
// Define the next chunk boundaries
|
||||
@@ -80,66 +84,130 @@ async function staggeredBatchDiff(words1, words2, color1, color2, batchSize, ove
|
||||
const start2 = previousEnd2;
|
||||
const end2 = Math.min(start2 + batchSize, totalWords2);
|
||||
|
||||
//If difference is too high decrease batch size for more granular check
|
||||
const dynamicBatchSize = isLargeDifference(differences) ? batchSize / 2 : batchSize;
|
||||
// Adaptive: If many diffs, smaller batch (max 3x downscale)
|
||||
const recentDiffs = differences.slice(-100).filter(([c]) => c !== 'black').length;
|
||||
// If difference is too high decrease batch size for more granular check
|
||||
const dynamicBatchSize = Math.max(batchSize / Math.min(8, 1 + recentDiffs / 50), batchSize / 8);
|
||||
|
||||
// Adjust the size of the current chunk using dynamic batch size
|
||||
const batchWords1 = words1.slice(start1, end1 + dynamicBatchSize);
|
||||
const batchWords2 = words2.slice(start2, end2 + dynamicBatchSize);
|
||||
const extendedEnd1 = Math.min(end1 + dynamicBatchSize, totalWords1);
|
||||
const extendedEnd2 = Math.min(end2 + dynamicBatchSize, totalWords2);
|
||||
|
||||
const batchWords1 = words1.slice(start1, extendedEnd1);
|
||||
const batchWords2 = words2.slice(start2, extendedEnd2);
|
||||
|
||||
// Include overlap from the previous chunk
|
||||
const overlapWords1 = previousEnd1 > 0 ? words1.slice(Math.max(0, previousEnd1 - overlapSize), previousEnd1) : [];
|
||||
const overlapWords2 = previousEnd2 > 0 ? words2.slice(Math.max(0, previousEnd2 - overlapSize), previousEnd2) : [];
|
||||
const overlapStart1 = Math.max(0, previousEnd1 - overlapSize);
|
||||
const overlapStart2 = Math.max(0, previousEnd2 - overlapSize);
|
||||
const overlapWords1 = previousEnd1 > 0 ? words1.slice(overlapStart1, previousEnd1) : [];
|
||||
const overlapWords2 = previousEnd2 > 0 ? words2.slice(overlapStart2, previousEnd2) : [];
|
||||
|
||||
|
||||
// Combine overlaps and current batches for comparison
|
||||
const combinedWords1 = overlapWords1.concat(batchWords1);
|
||||
const combinedWords2 = overlapWords2.concat(batchWords2);
|
||||
const combinedWords1 = [...overlapWords1, ...batchWords1];
|
||||
const combinedWords2 = [...overlapWords2, ...batchWords2];
|
||||
|
||||
// Perform the diff on the combined words
|
||||
const batchDifferences = diff(combinedWords1, combinedWords2, color1, color2);
|
||||
differences.push(...batchDifferences);
|
||||
|
||||
// Update the previous end indices based on the results of this batch
|
||||
const combinedIndices1 = [];
|
||||
for (let i = overlapStart1; i < previousEnd1; i++) {
|
||||
combinedIndices1.push(i);
|
||||
}
|
||||
for (let i = start1; i < extendedEnd1; i++) {
|
||||
combinedIndices1.push(i);
|
||||
}
|
||||
|
||||
const combinedIndices2 = [];
|
||||
for (let i = overlapStart2; i < previousEnd2; i++) {
|
||||
combinedIndices2.push(i);
|
||||
}
|
||||
for (let i = start2; i < extendedEnd2; i++) {
|
||||
combinedIndices2.push(i);
|
||||
}
|
||||
|
||||
let pointer1 = 0;
|
||||
let pointer2 = 0;
|
||||
|
||||
const filteredBatch = [];
|
||||
batchDifferences.forEach(([color, word]) => {
|
||||
if (color === color1) {
|
||||
const globalIndex1 = combinedIndices1[pointer1];
|
||||
if (globalIndex1 === undefined || !processed1.has(globalIndex1)) {
|
||||
filteredBatch.push([color, word]);
|
||||
}
|
||||
if (globalIndex1 !== undefined) {
|
||||
processed1.add(globalIndex1);
|
||||
}
|
||||
pointer1++;
|
||||
} else if (color === color2) {
|
||||
const globalIndex2 = combinedIndices2[pointer2];
|
||||
if (globalIndex2 === undefined || !processed2.has(globalIndex2)) {
|
||||
filteredBatch.push([color, word]);
|
||||
}
|
||||
if (globalIndex2 !== undefined) {
|
||||
processed2.add(globalIndex2);
|
||||
}
|
||||
pointer2++;
|
||||
} else {
|
||||
const globalIndex1 = combinedIndices1[pointer1];
|
||||
const globalIndex2 = combinedIndices2[pointer2];
|
||||
const alreadyProcessed = (globalIndex1 !== undefined && processed1.has(globalIndex1)) && (globalIndex2 !== undefined && processed2.has(globalIndex2));
|
||||
if (!alreadyProcessed) {
|
||||
filteredBatch.push([color, word]);
|
||||
}
|
||||
if (globalIndex1 !== undefined) {
|
||||
processed1.add(globalIndex1);
|
||||
}
|
||||
if (globalIndex2 !== undefined) {
|
||||
processed2.add(globalIndex2);
|
||||
}
|
||||
pointer1++;
|
||||
pointer2++;
|
||||
}
|
||||
});
|
||||
|
||||
differences.push(...filteredBatch);
|
||||
|
||||
// Mark as processed
|
||||
for (let k = start1; k < end1; k++) processed1.add(k);
|
||||
for (let k = start2; k < end2; k++) processed2.add(k);
|
||||
|
||||
previousEnd1 = end1;
|
||||
previousEnd2 = end2;
|
||||
|
||||
// Yield for async (avoids blocking)
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
return differences;
|
||||
}
|
||||
|
||||
|
||||
// Standard diff function for small text comparisons
|
||||
function diff(words1, words2, color1, color2) {
|
||||
console.log(`Starting diff between ${words1.length} words and ${words2.length} words`);
|
||||
const matrix = Array.from({ length: words1.length + 1 }, () => Array(words2.length + 1).fill(0));
|
||||
console.log(`Diff: ${words1.length} vs ${words2.length} words`);
|
||||
const oldStr = words1.join(' '); // As string for diff.js
|
||||
const newStr = words2.join(' ');
|
||||
// Static method: No 'new' needed, avoids constructor error
|
||||
const changes = Diff.diffWords(oldStr, newStr, { ignoreWhitespace: true });
|
||||
|
||||
for (let i = 1; i <= words1.length; i++) {
|
||||
for (let j = 1; j <= words2.length; j++) {
|
||||
matrix[i][j] = words1[i - 1] === words2[j - 1]
|
||||
? matrix[i - 1][j - 1] + 1
|
||||
: Math.max(matrix[i][j - 1], matrix[i - 1][j]);
|
||||
}
|
||||
}
|
||||
return backtrack(matrix, words1, words2, color1, color2);
|
||||
}
|
||||
|
||||
// Backtrack function to find differences
|
||||
function backtrack(matrix, words1, words2, color1, color2) {
|
||||
let i = words1.length, j = words2.length;
|
||||
// Map changes to [color, word] format (change.value and added/removed)
|
||||
const differences = [];
|
||||
changes.forEach(change => {
|
||||
const value = change.value;
|
||||
const op = change.added ? 1 : change.removed ? -1 : 0;
|
||||
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && words1[i - 1] === words2[j - 1]) {
|
||||
differences.unshift(['black', words1[i - 1]]);
|
||||
i--; j--;
|
||||
} else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) {
|
||||
differences.unshift([color2, words2[j - 1]]);
|
||||
j--;
|
||||
} else {
|
||||
differences.unshift([color1, words1[i - 1]]);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
// Split value into words and process
|
||||
const words = value.split(/\s+/).filter(w => w.length > 0);
|
||||
words.forEach(word => {
|
||||
if (op === 0) { // Equal
|
||||
differences.push(['black', word]);
|
||||
} else if (op === 1) { // Insert
|
||||
differences.push([color2, word]);
|
||||
} else if (op === -1) { // Delete
|
||||
differences.push([color1, word]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return differences;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
if (window.isDownloadScriptInitialized) return; // Prevent re-execution
|
||||
window.isDownloadScriptInitialized = true;
|
||||
|
||||
const PDFJS_DEFAULT_OPTIONS = {
|
||||
cMapUrl: pdfjsPath + 'cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: pdfjsPath + 'standard_fonts/',
|
||||
};
|
||||
|
||||
// Global PDF processing count tracking for survey system
|
||||
window.incrementPdfProcessingCount = function() {
|
||||
let pdfProcessingCount = parseInt(localStorage.getItem('pdfProcessingCount') || '0');
|
||||
@@ -19,12 +25,87 @@
|
||||
error,
|
||||
} = window.stirlingPDF;
|
||||
|
||||
// Format Problem Details JSON with consistent key order and pretty-printing
|
||||
function formatProblemDetailsJson(input) {
|
||||
try {
|
||||
const obj = typeof input === 'string' ? JSON.parse(input) : input;
|
||||
const preferredOrder = [
|
||||
'errorCode',
|
||||
'title',
|
||||
'status',
|
||||
'type',
|
||||
'detail',
|
||||
'instance',
|
||||
'path',
|
||||
'timestamp',
|
||||
'hints',
|
||||
'actionRequired'
|
||||
];
|
||||
|
||||
const out = {};
|
||||
// Place preferred keys first if present
|
||||
preferredOrder.forEach((k) => {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
||||
out[k] = obj[k];
|
||||
}
|
||||
});
|
||||
// Append remaining keys preserving their original order
|
||||
Object.keys(obj).forEach((k) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(out, k)) {
|
||||
out[k] = obj[k];
|
||||
}
|
||||
});
|
||||
return JSON.stringify(out, null, 2);
|
||||
} catch (e) {
|
||||
// Fallback: if it's already a string, return as-is; otherwise pretty-print best effort
|
||||
if (typeof input === 'string') return input;
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showErrorBanner(message, stackTrace) {
|
||||
const errorContainer = document.getElementById('errorContainer');
|
||||
if (!errorContainer) {
|
||||
console.error('Error container not found');
|
||||
return;
|
||||
}
|
||||
errorContainer.style.display = 'block'; // Display the banner
|
||||
errorContainer.querySelector('.alert-heading').textContent = error;
|
||||
errorContainer.querySelector('p').textContent = message;
|
||||
document.querySelector('#traceContent').textContent = stackTrace;
|
||||
const heading = errorContainer.querySelector('.alert-heading');
|
||||
const messageEl = errorContainer.querySelector('p');
|
||||
const traceEl = document.querySelector('#traceContent');
|
||||
|
||||
if (heading) heading.textContent = error;
|
||||
if (messageEl) {
|
||||
messageEl.style.whiteSpace = 'pre-wrap';
|
||||
messageEl.textContent = message;
|
||||
}
|
||||
|
||||
// Format stack trace: if it looks like JSON, pretty-print with consistent key order; otherwise clean it up
|
||||
if (traceEl) {
|
||||
if (stackTrace) {
|
||||
// Check if stackTrace is already JSON formatted
|
||||
if (stackTrace.trim().startsWith('{') || stackTrace.trim().startsWith('[')) {
|
||||
traceEl.textContent = formatProblemDetailsJson(stackTrace);
|
||||
} else {
|
||||
// Filter out unhelpful stack traces (internal browser/library paths)
|
||||
// Only show if it contains meaningful error info
|
||||
const lines = stackTrace.split('\n');
|
||||
const meaningfulLines = lines.filter(line =>
|
||||
!line.includes('pdfjs-legacy') &&
|
||||
!line.includes('pdf.worker') &&
|
||||
!line.includes('pdf.mjs') &&
|
||||
line.trim().length > 0
|
||||
);
|
||||
traceEl.textContent = meaningfulLines.length > 0 ? meaningfulLines.join('\n') : 'No additional trace information available';
|
||||
}
|
||||
} else {
|
||||
traceEl.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showSessionExpiredPrompt() {
|
||||
@@ -74,6 +155,12 @@
|
||||
showGameBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Log fileOrder for debugging
|
||||
const fileOrderValue = formData.get('fileOrder');
|
||||
if (fileOrderValue) {
|
||||
console.log('FormData fileOrder:', fileOrderValue);
|
||||
}
|
||||
|
||||
// Remove empty file entries
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (value instanceof File && !value.name) {
|
||||
@@ -153,8 +240,13 @@
|
||||
async function getPDFPageCount(file) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = './pdfjs-legacy/pdf.worker.mjs';
|
||||
const pdf = await pdfjsLib.getDocument({data: arrayBuffer}).promise;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
|
||||
const pdf = await pdfjsLib
|
||||
.getDocument({
|
||||
...PDFJS_DEFAULT_OPTIONS,
|
||||
data: arrayBuffer,
|
||||
})
|
||||
.promise;
|
||||
return pdf.numPages;
|
||||
} catch (error) {
|
||||
console.error('Error getting PDF page count:', error);
|
||||
@@ -164,7 +256,7 @@
|
||||
|
||||
async function checkAndDecryptFiles(url, files) {
|
||||
const decryptedFiles = [];
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = './pdfjs-legacy/pdf.worker.mjs';
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
|
||||
|
||||
// Extract the base URL
|
||||
const baseUrl = new URL(url);
|
||||
@@ -190,7 +282,10 @@
|
||||
}
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const loadingTask = pdfjsLib.getDocument({data: arrayBuffer});
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
...PDFJS_DEFAULT_OPTIONS,
|
||||
data: arrayBuffer,
|
||||
});
|
||||
|
||||
console.log(`Attempting to load PDF: ${file.name}`);
|
||||
const pdf = await loadingTask.promise;
|
||||
@@ -204,11 +299,38 @@
|
||||
|
||||
if (!password) {
|
||||
console.error(`No password provided for encrypted PDF: ${file.name}`);
|
||||
showErrorBanner(
|
||||
`${window.decrypt.noPassword.replace('{0}', file.name)}`,
|
||||
`${window.decrypt.unexpectedError}`
|
||||
);
|
||||
throw error;
|
||||
|
||||
// Create a Problem Detail object matching the server's E004 response using localized strings
|
||||
const passwordDetailTemplate =
|
||||
window.stirlingPDF?.pdfPasswordDetail ||
|
||||
`The PDF file "${file.name}" requires a password to proceed.`;
|
||||
const hints = [
|
||||
window.stirlingPDF?.pdfPasswordHint1,
|
||||
window.stirlingPDF?.pdfPasswordHint2,
|
||||
window.stirlingPDF?.pdfPasswordHint3,
|
||||
window.stirlingPDF?.pdfPasswordHint4,
|
||||
window.stirlingPDF?.pdfPasswordHint5,
|
||||
window.stirlingPDF?.pdfPasswordHint6
|
||||
].filter(Boolean);
|
||||
const noProblemDetail = {
|
||||
errorCode: 'E004',
|
||||
title: window.stirlingPDF?.pdfPasswordTitle || 'PDF Password Required',
|
||||
detail: passwordDetailTemplate.includes('{0}')
|
||||
? passwordDetailTemplate.replace('{0}', file.name)
|
||||
: passwordDetailTemplate,
|
||||
hints,
|
||||
actionRequired:
|
||||
window.stirlingPDF?.pdfPasswordAction ||
|
||||
'Provide the owner/permissions password, not just the document open password.'
|
||||
};
|
||||
|
||||
const bannerMessage = formatUserFriendlyError(noProblemDetail);
|
||||
const debugInfo = formatProblemDetailsJson(noProblemDetail);
|
||||
showErrorBanner(bannerMessage, debugInfo);
|
||||
|
||||
const err = new Error(noProblemDetail.detail);
|
||||
err.alreadyHandled = true;
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -220,18 +342,30 @@
|
||||
// Use handleSingleDownload to send the request
|
||||
const decryptionResult = await fetchWithCsrf(removePasswordUrl, {method: 'POST', body: formData});
|
||||
|
||||
// Check if we got an error response (RFC 7807 Problem Details)
|
||||
if (!decryptionResult.ok) {
|
||||
const contentType = decryptionResult.headers.get('content-type');
|
||||
if (contentType && (contentType.includes('application/json') || contentType.includes('application/problem+json'))) {
|
||||
// Parse the RFC 7807 error response
|
||||
const errorJson = await decryptionResult.json();
|
||||
const formattedError = formatUserFriendlyError(errorJson);
|
||||
const debugInfo = formatProblemDetailsJson(errorJson);
|
||||
const title = errorJson.title || 'Decryption Failed';
|
||||
const detail = errorJson.detail || 'Failed to decrypt PDF';
|
||||
const bannerMessage = formattedError || `${title}: ${detail}`;
|
||||
showErrorBanner(bannerMessage, debugInfo);
|
||||
const err = new Error(detail);
|
||||
err.alreadyHandled = true; // Mark error as already handled
|
||||
throw err;
|
||||
} else {
|
||||
throw new Error('Decryption failed: Invalid server response');
|
||||
}
|
||||
}
|
||||
|
||||
if (decryptionResult && decryptionResult.blob) {
|
||||
const decryptedBlob = await decryptionResult.blob();
|
||||
const decryptedFile = new File([decryptedBlob], file.name, {type: 'application/pdf'});
|
||||
|
||||
/* // Create a link element to download the file
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(decryptedBlob);
|
||||
link.download = 'test.pdf';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
*/
|
||||
decryptedFiles.push(decryptedFile);
|
||||
console.log(`Successfully decrypted PDF: ${file.name}`);
|
||||
} else {
|
||||
@@ -239,10 +373,7 @@
|
||||
}
|
||||
} catch (decryptError) {
|
||||
console.error(`Failed to decrypt PDF: ${file.name}`, decryptError);
|
||||
showErrorBanner(
|
||||
`${window.decrypt.invalidPasswordHeader.replace('{0}', file.name)}`,
|
||||
`${window.decrypt.invalidPassword}`
|
||||
);
|
||||
// Error banner already shown above with formatted hints/actions
|
||||
throw decryptError;
|
||||
}
|
||||
} else if (error.name === 'InvalidPDFException' ||
|
||||
@@ -250,10 +381,34 @@
|
||||
// Handle corrupted PDF files
|
||||
console.log(`Corrupted PDF detected: ${file.name}`, error);
|
||||
if (window.stirlingPDF.currentPage !== 'repair') {
|
||||
showErrorBanner(
|
||||
`${window.stirlingPDF.pdfCorruptedMessage.replace('{0}', file.name)}`,
|
||||
`${window.stirlingPDF.tryRepairMessage}`
|
||||
);
|
||||
// Create a formatted error message using properties from language files
|
||||
const errorMessage = window.stirlingPDF.pdfCorruptedMessage.replace('{0}', file.name);
|
||||
const hints = [
|
||||
window.stirlingPDF.pdfCorruptedHint1,
|
||||
window.stirlingPDF.pdfCorruptedHint2,
|
||||
window.stirlingPDF.pdfCorruptedHint3
|
||||
].filter((hint) => typeof hint === 'string' && hint.trim().length > 0);
|
||||
const action = window.stirlingPDF.pdfCorruptedAction || window.stirlingPDF.tryRepairMessage;
|
||||
|
||||
const problemDetails = {
|
||||
title: window.stirlingPDF.pdfCorruptedTitle || window.stirlingPDF.error || 'Error',
|
||||
detail: errorMessage
|
||||
};
|
||||
|
||||
if (hints.length > 0) {
|
||||
problemDetails.hints = hints;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
problemDetails.actionRequired = action;
|
||||
}
|
||||
|
||||
const bannerMessage = formatUserFriendlyError(problemDetails);
|
||||
const debugInfo = formatProblemDetailsJson(problemDetails);
|
||||
|
||||
showErrorBanner(bannerMessage, debugInfo);
|
||||
// Mark error as already handled to prevent double display
|
||||
error.alreadyHandled = true;
|
||||
} else {
|
||||
// On repair page, suppress banner; user already knows and is repairing
|
||||
console.log('Suppressing corrupted PDF banner on repair page');
|
||||
@@ -280,15 +435,33 @@
|
||||
|
||||
if (!response.ok) {
|
||||
errorMessage = response.status;
|
||||
// Check for JSON error responses first (including RFC 7807 Problem Details)
|
||||
if (contentType && (contentType.includes('application/json') || contentType.includes('application/problem+json'))) {
|
||||
console.error('Throwing error banner, response was not okay');
|
||||
await handleJsonResponse(response);
|
||||
// Return early - error banner already shown by handleJsonResponse
|
||||
// Don't throw to avoid double error display
|
||||
return null;
|
||||
}
|
||||
// Only show session expired for 401 without JSON body (actual auth failure)
|
||||
if (response.status === 401) {
|
||||
showSessionExpiredPrompt();
|
||||
return;
|
||||
}
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
console.error('Throwing error banner, response was not okay');
|
||||
return handleJsonResponse(response);
|
||||
// For non-JSON errors, try to extract error message from response body
|
||||
try {
|
||||
const errorText = await response.text();
|
||||
if (errorText && errorText.trim().length > 0) {
|
||||
showErrorBanner(`HTTP ${response.status}`, errorText);
|
||||
// Return early - error already shown
|
||||
return null;
|
||||
}
|
||||
} catch (textError) {
|
||||
// If we can't read the response body, show generic error
|
||||
const errorMsg = `HTTP ${response.status} - ${response.statusText || 'Request failed'}`;
|
||||
showErrorBanner('Error', errorMsg);
|
||||
return null;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
@@ -345,20 +518,100 @@
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error details in a user-friendly way
|
||||
* Extracts key information and presents hints/actions prominently
|
||||
*/
|
||||
function formatUserFriendlyError(json) {
|
||||
if (!json || typeof json !== 'object') {
|
||||
return typeof json === 'string' ? json : '';
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
const title = json.title || json.error || '';
|
||||
const detail = json.detail || json.message || '';
|
||||
|
||||
const primaryLine = title && detail
|
||||
? `${title}: ${detail}`
|
||||
: title || detail;
|
||||
|
||||
if (primaryLine) {
|
||||
lines.push(primaryLine);
|
||||
}
|
||||
|
||||
if (json.errorCode) {
|
||||
lines.push('');
|
||||
lines.push(`Error Code: ${json.errorCode}`);
|
||||
}
|
||||
|
||||
const detailAlreadyIncluded = detail && primaryLine && primaryLine.includes(detail);
|
||||
|
||||
if (detail && !detailAlreadyIncluded) {
|
||||
lines.push('');
|
||||
lines.push(detail);
|
||||
}
|
||||
|
||||
if (json.hints && Array.isArray(json.hints) && json.hints.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('How to fix:');
|
||||
json.hints.forEach((hint, index) => {
|
||||
lines.push(` ${index + 1}. ${hint}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (json.actionRequired) {
|
||||
lines.push('');
|
||||
lines.push(json.actionRequired);
|
||||
}
|
||||
|
||||
if (json.supportId) {
|
||||
lines.push('');
|
||||
lines.push(`Support ID: ${json.supportId}`);
|
||||
}
|
||||
|
||||
return lines
|
||||
.filter((line, index, arr) => {
|
||||
if (line !== '') return true;
|
||||
if (index === 0 || index === arr.length - 1) return false;
|
||||
return arr[index - 1] !== '';
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function handleJsonResponse(response) {
|
||||
const json = await response.json();
|
||||
const errorMessage = JSON.stringify(json, null, 2);
|
||||
if (
|
||||
errorMessage.toLowerCase().includes('the password is incorrect') ||
|
||||
errorMessage.toLowerCase().includes('Password is not provided') ||
|
||||
errorMessage.toLowerCase().includes('PDF contains an encryption dictionary')
|
||||
) {
|
||||
|
||||
// Format the full JSON response for display in stack trace with errorCode first
|
||||
const formattedJson = formatProblemDetailsJson(json);
|
||||
|
||||
// Check for PDF password errors using RFC 7807 fields
|
||||
const isPdfPasswordError =
|
||||
json.type === '/errors/pdf-password' ||
|
||||
json.errorCode === 'E004' ||
|
||||
(json.detail && (
|
||||
json.detail.toLowerCase().includes('pdf document is passworded') ||
|
||||
json.detail.toLowerCase().includes('password is incorrect') ||
|
||||
json.detail.toLowerCase().includes('password was not provided') ||
|
||||
json.detail.toLowerCase().includes('pdf contains an encryption dictionary')
|
||||
));
|
||||
|
||||
const fallbackTitle = json.title || json.error || 'Error';
|
||||
const fallbackDetail = json.detail || json.message || '';
|
||||
const fallbackMessage = fallbackDetail ? `${fallbackTitle}: ${fallbackDetail}` : fallbackTitle;
|
||||
const bannerMessage = formatUserFriendlyError(json) || fallbackMessage;
|
||||
|
||||
if (isPdfPasswordError) {
|
||||
showErrorBanner(bannerMessage, formattedJson);
|
||||
|
||||
// Show alert only once for user attention
|
||||
if (!firstErrorOccurred) {
|
||||
firstErrorOccurred = true;
|
||||
alert(pdfPasswordPrompt);
|
||||
const detail = json.detail || 'The PDF document requires a password to open.';
|
||||
alert(pdfPasswordPrompt + '\n\n' + detail);
|
||||
}
|
||||
} else {
|
||||
showErrorBanner(json.error + ':' + json.message, json.trace);
|
||||
// Show user-friendly error, fallback to full JSON for debugging
|
||||
showErrorBanner(bannerMessage, formattedJson);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +636,10 @@
|
||||
}
|
||||
|
||||
function handleDownloadError(error) {
|
||||
// Skip if error was already handled and displayed
|
||||
if (error.alreadyHandled) {
|
||||
return;
|
||||
}
|
||||
const errorMessage = error.message;
|
||||
showErrorBanner(errorMessage);
|
||||
}
|
||||
@@ -463,12 +720,15 @@
|
||||
try {
|
||||
const downloadDetails = await handleSingleDownload(url, fileFormData, true, zipFiles);
|
||||
console.log(downloadDetails);
|
||||
if (zipFiles) {
|
||||
jszip.file(downloadDetails.filename, downloadDetails.blob);
|
||||
} else {
|
||||
//downloadFile(downloadDetails.blob, downloadDetails.filename);
|
||||
// If downloadDetails is null, error was already shown, skip processing
|
||||
if (downloadDetails) {
|
||||
if (zipFiles) {
|
||||
jszip.file(downloadDetails.filename, downloadDetails.blob);
|
||||
} else {
|
||||
//downloadFile(downloadDetails.blob, downloadDetails.filename);
|
||||
}
|
||||
updateProgressBar(progressBar, Array.from(files).length);
|
||||
}
|
||||
updateProgressBar(progressBar, Array.from(files).length);
|
||||
} catch (error) {
|
||||
handleDownloadError(error);
|
||||
console.error(error);
|
||||
|
||||
@@ -3,7 +3,7 @@ var traceVisible = false;
|
||||
function toggletrace() {
|
||||
var traceDiv = document.getElementById("trace");
|
||||
if (!traceVisible) {
|
||||
traceDiv.style.maxHeight = "500px";
|
||||
traceDiv.style.maxHeight = "100vh";
|
||||
traceVisible = true;
|
||||
} else {
|
||||
traceDiv.style.maxHeight = "0px";
|
||||
|
||||
@@ -29,6 +29,54 @@ const mimeTypes = {
|
||||
"pdf": "application/pdf",
|
||||
};
|
||||
|
||||
const isMultiToolPage = () => window.location.pathname?.includes('multi-tool');
|
||||
|
||||
const isSvgFile = (file) => {
|
||||
if (!file) return false;
|
||||
const type = (file.type || '').toLowerCase();
|
||||
if (type === 'image/svg+xml') {
|
||||
return true;
|
||||
}
|
||||
const name = (file.name || '').toLowerCase();
|
||||
return name.endsWith('.svg');
|
||||
};
|
||||
|
||||
function filterSvgFiles(files) {
|
||||
if (!Array.isArray(files) || !isMultiToolPage()) {
|
||||
return { allowed: files ?? [], rejected: [] };
|
||||
}
|
||||
|
||||
const allowed = [];
|
||||
const rejected = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
if (isSvgFile(file)) {
|
||||
rejected.push(file);
|
||||
} else {
|
||||
allowed.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
return { allowed, rejected };
|
||||
}
|
||||
|
||||
function showSvgWarning(rejectedFiles = []) {
|
||||
if (!rejectedFiles.length) return;
|
||||
|
||||
const message = window.multiTool?.svgNotSupported ||
|
||||
'SVG files are not supported in Multi Tool and were ignored.';
|
||||
const rejectedNames = rejectedFiles
|
||||
.map((file) => file?.name)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
if (rejectedNames) {
|
||||
alert(`${message}\n${rejectedNames}`);
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
function setupFileInput(chooser) {
|
||||
const elementId = chooser.getAttribute('data-bs-element-id');
|
||||
const filesSelected = chooser.getAttribute('data-bs-files-selected');
|
||||
@@ -198,6 +246,24 @@ function setupFileInput(chooser) {
|
||||
|
||||
await checkZipFile();
|
||||
|
||||
const { allowed: nonSvgFiles, rejected: rejectedSvgFiles } = filterSvgFiles(allFiles);
|
||||
if (rejectedSvgFiles.length > 0) {
|
||||
showSvgWarning(rejectedSvgFiles);
|
||||
allFiles = nonSvgFiles;
|
||||
|
||||
const updatedTransfer = toDataTransfer(allFiles);
|
||||
element.files = updatedTransfer.files;
|
||||
if (allFiles.length === 0) {
|
||||
element.value = '';
|
||||
}
|
||||
|
||||
if (allFiles.length === 0) {
|
||||
inputContainer.querySelector('#fileInputText').innerHTML = originalText;
|
||||
showOrHideSelectedFilesContainer(allFiles);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const uploadLimit = window.stirlingPDF?.uploadLimit ?? 0;
|
||||
if (uploadLimit > 0) {
|
||||
const oversizedFiles = allFiles.filter(f => f.size > uploadLimit);
|
||||
|
||||
@@ -186,7 +186,9 @@ function sortNavElements(criteria) {
|
||||
async function fetchPopularityData(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const errorText = await response.text().catch(() => '');
|
||||
const errorMsg = errorText || response.statusText || 'Request failed';
|
||||
throw new Error(`HTTP ${response.status}: ${errorMsg}`);
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
@@ -218,9 +220,11 @@ document.addEventListener('DOMContentLoaded', async function () {
|
||||
});
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/files/popularity.txt');
|
||||
const response = await fetch('./files/popularity.txt');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const errorText = await response.text().catch(() => '');
|
||||
const errorMsg = errorText || response.statusText || 'Request failed';
|
||||
throw new Error(`HTTP ${response.status}: ${errorMsg}`);
|
||||
}
|
||||
const popularityData = await response.json();
|
||||
applyPopularityData(popularityData);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
const PDFJS_DEFAULT_OPTIONS = {
|
||||
cMapUrl: pdfjsPath + 'cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: pdfjsPath + 'standard_fonts/',
|
||||
};
|
||||
|
||||
let currentSort = {
|
||||
field: null,
|
||||
descending: false,
|
||||
@@ -73,7 +79,13 @@ async function displayFiles(files) {
|
||||
|
||||
async function getPDFPageCount(file) {
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
const pdf = await pdfjsLib.getDocument(blobUrl).promise;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
|
||||
const pdf = await pdfjsLib
|
||||
.getDocument({
|
||||
...PDFJS_DEFAULT_OPTIONS,
|
||||
url: blobUrl,
|
||||
})
|
||||
.promise;
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return pdf.numPages;
|
||||
}
|
||||
@@ -123,39 +135,38 @@ function attachMoveButtons() {
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("sortByNameBtn").addEventListener("click", function () {
|
||||
document.getElementById("sortByNameBtn").addEventListener("click", async function () {
|
||||
if (currentSort.field === "name" && !currentSort.descending) {
|
||||
currentSort.descending = true;
|
||||
sortFiles((a, b) => b.name.localeCompare(a.name));
|
||||
await sortFiles((a, b) => b.name.localeCompare(a.name));
|
||||
} else {
|
||||
currentSort.field = "name";
|
||||
currentSort.descending = false;
|
||||
sortFiles((a, b) => a.name.localeCompare(b.name));
|
||||
await sortFiles((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("sortByDateBtn").addEventListener("click", function () {
|
||||
document.getElementById("sortByDateBtn").addEventListener("click", async function () {
|
||||
if (currentSort.field === "lastModified" && !currentSort.descending) {
|
||||
currentSort.descending = true;
|
||||
sortFiles((a, b) => b.lastModified - a.lastModified);
|
||||
await sortFiles((a, b) => b.lastModified - a.lastModified);
|
||||
} else {
|
||||
currentSort.field = "lastModified";
|
||||
currentSort.descending = false;
|
||||
sortFiles((a, b) => a.lastModified - b.lastModified);
|
||||
await sortFiles((a, b) => a.lastModified - b.lastModified);
|
||||
}
|
||||
});
|
||||
|
||||
function sortFiles(comparator) {
|
||||
async function sortFiles(comparator) {
|
||||
// Convert FileList to array and sort
|
||||
const sortedFilesArray = Array.from(document.getElementById("fileInput-input").files).sort(comparator);
|
||||
|
||||
// Refresh displayed list
|
||||
displayFiles(sortedFilesArray);
|
||||
// Refresh displayed list (wait for it to complete since it's async)
|
||||
await displayFiles(sortedFilesArray);
|
||||
|
||||
// Update the files property
|
||||
const dataTransfer = new DataTransfer();
|
||||
sortedFilesArray.forEach((file) => dataTransfer.items.add(file));
|
||||
document.getElementById("fileInput-input").files = dataTransfer.files;
|
||||
// Update the file input and fileOrder based on the current display order
|
||||
// This ensures consistency between display and file input
|
||||
updateFiles();
|
||||
}
|
||||
|
||||
function updateFiles() {
|
||||
@@ -163,25 +174,36 @@ function updateFiles() {
|
||||
var liElements = document.querySelectorAll("#selectedFiles li");
|
||||
const files = document.getElementById("fileInput-input").files;
|
||||
|
||||
console.log("updateFiles: found", liElements.length, "LI elements and", files.length, "files");
|
||||
|
||||
for (var i = 0; i < liElements.length; i++) {
|
||||
var fileNameFromList = liElements[i].querySelector(".filename").innerText;
|
||||
var fileFromFiles;
|
||||
var found = false;
|
||||
for (var j = 0; j < files.length; j++) {
|
||||
var file = files[j];
|
||||
if (file.name === fileNameFromList) {
|
||||
dataTransfer.items.add(file);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
console.warn("updateFiles: Could not find file:", fileNameFromList);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("fileInput-input").files = dataTransfer.files;
|
||||
console.log("updateFiles: Updated file input with", dataTransfer.files.length, "files");
|
||||
|
||||
// Also populate hidden fileOrder to preserve visible order
|
||||
const order = Array.from(liElements)
|
||||
.map((li) => li.querySelector(".filename").innerText)
|
||||
.join("\n");
|
||||
const orderInput = document.getElementById("fileOrder");
|
||||
if (orderInput) orderInput.value = order;
|
||||
if (orderInput) {
|
||||
orderInput.value = order;
|
||||
console.log("Updated fileOrder:", order);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("#resetFileInputBtn").addEventListener("click", ()=>{
|
||||
|
||||
@@ -42,25 +42,6 @@ class ImageHighlighter {
|
||||
img.addEventListener("click", this.imageHighlightCallback);
|
||||
return div;
|
||||
}
|
||||
|
||||
async addImageFile(file, nextSiblingElement) {
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("page-container");
|
||||
|
||||
var img = document.createElement("img");
|
||||
img.classList.add("page-image");
|
||||
img.src = URL.createObjectURL(file);
|
||||
div.appendChild(img);
|
||||
|
||||
this.pdfAdapters.forEach((adapter) => {
|
||||
adapter.adapt?.(div);
|
||||
});
|
||||
if (nextSiblingElement) {
|
||||
this.pagesContainer.insertBefore(div, nextSiblingElement);
|
||||
} else {
|
||||
this.pagesContainer.appendChild(div);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageHighlighter;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DeletePageCommand } from "./commands/delete-page.js";
|
||||
import { DuplicatePageCommand } from "./commands/duplicate-page.js";
|
||||
import { SelectPageCommand } from "./commands/select.js";
|
||||
import { SplitFileCommand } from "./commands/split.js";
|
||||
import { UndoManager } from "./UndoManager.js";
|
||||
@@ -78,6 +79,18 @@ class PdfActionsManager {
|
||||
this._pushUndoClearRedo(deletePageCommand);
|
||||
}
|
||||
|
||||
duplicatePageButtonCallback(e) {
|
||||
let imgContainer = this.getPageContainer(e.target);
|
||||
let duplicatePageCommand = new DuplicatePageCommand(
|
||||
imgContainer,
|
||||
this.duplicatePage,
|
||||
this.pagesContainer
|
||||
);
|
||||
duplicatePageCommand.execute();
|
||||
|
||||
this._pushUndoClearRedo(duplicatePageCommand);
|
||||
}
|
||||
|
||||
insertFileButtonCallback(e) {
|
||||
var imgContainer = this.getPageContainer(e.target);
|
||||
this.addFiles(imgContainer);
|
||||
@@ -101,10 +114,11 @@ class PdfActionsManager {
|
||||
this.undoManager.pushUndoClearRedo(command);
|
||||
}
|
||||
|
||||
setActions({ movePageTo, addFiles, rotateElement }) {
|
||||
setActions({ movePageTo, addFiles, rotateElement, duplicatePage }) {
|
||||
this.movePageTo = movePageTo;
|
||||
this.addFiles = addFiles;
|
||||
this.rotateElement = rotateElement;
|
||||
this.duplicatePage = duplicatePage;
|
||||
|
||||
this.moveUpButtonCallback = this.moveUpButtonCallback.bind(this);
|
||||
this.moveDownButtonCallback = this.moveDownButtonCallback.bind(this);
|
||||
@@ -114,6 +128,7 @@ class PdfActionsManager {
|
||||
this.insertFileButtonCallback = this.insertFileButtonCallback.bind(this);
|
||||
this.insertFileBlankButtonCallback = this.insertFileBlankButtonCallback.bind(this);
|
||||
this.splitFileButtonCallback = this.splitFileButtonCallback.bind(this);
|
||||
this.duplicatePageButtonCallback = this.duplicatePageButtonCallback.bind(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +169,13 @@ class PdfActionsManager {
|
||||
rotateCW.onclick = this.rotateCWButtonCallback;
|
||||
buttonContainer.appendChild(rotateCW);
|
||||
|
||||
const duplicatePage = document.createElement("button");
|
||||
duplicatePage.classList.add("btn", "btn-secondary");
|
||||
duplicatePage.setAttribute('title', window.translations.duplicate);
|
||||
duplicatePage.innerHTML = `<span class="material-symbols-rounded">control_point_duplicate</span>`;
|
||||
duplicatePage.onclick = this.duplicatePageButtonCallback;
|
||||
buttonContainer.appendChild(duplicatePage);
|
||||
|
||||
const deletePage = document.createElement("button");
|
||||
deletePage.classList.add("btn", "btn-danger");
|
||||
deletePage.setAttribute('title', window.translations.delete);
|
||||
@@ -195,7 +217,7 @@ class PdfActionsManager {
|
||||
|
||||
const insertFileButton = document.createElement("button");
|
||||
insertFileButton.classList.add("btn", "btn-primary");
|
||||
moveUp.setAttribute('title', window.translations.addFile);
|
||||
insertFileButton.setAttribute('title', window.translations.addFile);
|
||||
insertFileButton.innerHTML = `<span class="material-symbols-rounded">add</span>`;
|
||||
insertFileButton.onclick = this.insertFileButtonCallback;
|
||||
insertFileButtonContainer.appendChild(insertFileButton);
|
||||
|
||||
@@ -8,6 +8,55 @@ import { AddFilesCommand } from './commands/add-page.js';
|
||||
import { DecryptFile } from '../DecryptFiles.js';
|
||||
import { CommandSequence } from './commands/commands-sequence.js';
|
||||
|
||||
const PDFJS_DEFAULT_OPTIONS = {
|
||||
cMapUrl: pdfjsPath + 'cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: pdfjsPath + 'standard_fonts/',
|
||||
};
|
||||
|
||||
const isSvgFile = (file) => {
|
||||
if (!file) return false;
|
||||
const type = (file.type || '').toLowerCase();
|
||||
if (type === 'image/svg+xml') {
|
||||
return true;
|
||||
}
|
||||
const name = (file.name || '').toLowerCase();
|
||||
return name.endsWith('.svg');
|
||||
};
|
||||
|
||||
const partitionSvgFiles = (files = []) => {
|
||||
const allowed = [];
|
||||
const rejected = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
if (isSvgFile(file)) {
|
||||
rejected.push(file);
|
||||
} else {
|
||||
allowed.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
return { allowed, rejected };
|
||||
};
|
||||
|
||||
const notifySvgUnsupported = (files = []) => {
|
||||
if (!files.length) return;
|
||||
if (!window.location.pathname?.includes('multi-tool')) return;
|
||||
|
||||
const message = window.multiTool?.svgNotSupported ||
|
||||
'SVG files are not supported in Multi Tool and were ignored.';
|
||||
const names = files
|
||||
.map((file) => file?.name)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
if (names) {
|
||||
alert(`${message}\n${names}`);
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
class PdfContainer {
|
||||
fileName;
|
||||
pagesContainer;
|
||||
@@ -30,6 +79,7 @@ class PdfContainer {
|
||||
this.setDownloadAttribute = this.setDownloadAttribute.bind(this);
|
||||
this.preventIllegalChars = this.preventIllegalChars.bind(this);
|
||||
this.addImageFile = this.addImageFile.bind(this);
|
||||
this.duplicatePage = this.duplicatePage.bind(this);
|
||||
this.nameAndArchiveFiles = this.nameAndArchiveFiles.bind(this);
|
||||
this.splitPDF = this.splitPDF.bind(this);
|
||||
this.splitAll = this.splitAll.bind(this);
|
||||
@@ -56,6 +106,7 @@ class PdfContainer {
|
||||
rotateElement: this.rotateElement,
|
||||
updateFilename: this.updateFilename,
|
||||
deleteSelected: this.deleteSelected,
|
||||
duplicatePage: this.duplicatePage,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,19 +205,31 @@ class PdfContainer {
|
||||
return movePageCommand;
|
||||
}
|
||||
|
||||
async addFiles(element) {
|
||||
let addFilesCommand = new AddFilesCommand(
|
||||
/**
|
||||
* Adds files or a single blank page (when blank=true) near an anchor element.
|
||||
* @param {HTMLElement|null} element - Anchor element (insert before its nextSibling).
|
||||
* @param {boolean} [blank=false] - When true, insert a single blank page.
|
||||
*/
|
||||
async addFiles(element, blank = false) {
|
||||
// Choose the action: real file picker or blank page generator.
|
||||
const action = blank
|
||||
? async (nextSiblingElement) => {
|
||||
// Create exactly one blank page and return the created elements array.
|
||||
const pages = await this.addFilesBlank(nextSiblingElement, []);
|
||||
return pages; // array of inserted elements
|
||||
}
|
||||
: this.addFilesAction.bind(this);
|
||||
|
||||
const addFilesCommand = new AddFilesCommand(
|
||||
element,
|
||||
window.selectedPages,
|
||||
this.addFilesAction.bind(this),
|
||||
action,
|
||||
this.pagesContainer
|
||||
);
|
||||
|
||||
await addFilesCommand.execute();
|
||||
|
||||
this.undoManager.pushUndoClearRedo(addFilesCommand);
|
||||
window.tooltipSetup();
|
||||
|
||||
}
|
||||
|
||||
async addFilesAction(nextSiblingElement) {
|
||||
@@ -180,10 +243,18 @@ class PdfContainer {
|
||||
input.onchange = async (e) => {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
pages = await this.addFilesFromFiles(files, nextSiblingElement, pages);
|
||||
this.updateFilename(files[0].name);
|
||||
const {
|
||||
pages: updatedPages,
|
||||
acceptedFileCount,
|
||||
} = await this.addFilesFromFiles(files, nextSiblingElement, pages);
|
||||
|
||||
if(window.selectPage){
|
||||
pages = updatedPages;
|
||||
|
||||
if (acceptedFileCount > 0) {
|
||||
this.updateFilename();
|
||||
}
|
||||
|
||||
if (window.selectPage && acceptedFileCount > 0) {
|
||||
this.showButton(document.getElementById('select-pages-container'), true);
|
||||
}
|
||||
}
|
||||
@@ -196,11 +267,17 @@ class PdfContainer {
|
||||
|
||||
async handleDroppedFiles(files, nextSiblingElement = null) {
|
||||
if (files.length > 0) {
|
||||
const pages = await this.addFilesFromFiles(files, nextSiblingElement, []);
|
||||
this.updateFilename(files[0]?.name || 'untitled');
|
||||
const {
|
||||
pages,
|
||||
acceptedFileCount,
|
||||
} = await this.addFilesFromFiles(files, nextSiblingElement, []);
|
||||
|
||||
if(window.selectPage) {
|
||||
this.showButton(document.getElementById('select-pages-container'), true);
|
||||
if (acceptedFileCount > 0) {
|
||||
this.updateFilename();
|
||||
|
||||
if (window.selectPage) {
|
||||
this.showButton(document.getElementById('select-pages-container'), true);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
@@ -209,14 +286,24 @@ class PdfContainer {
|
||||
|
||||
async addFilesFromFiles(files, nextSiblingElement, pages) {
|
||||
this.fileName = files[0].name;
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
const fileArray = Array.from(files || []);
|
||||
const { allowed: allowedFiles, rejected: rejectedSvgFiles } = partitionSvgFiles(fileArray);
|
||||
|
||||
if (allowedFiles.length > 0) {
|
||||
this.fileName = allowedFiles[0].name || 'untitled';
|
||||
}
|
||||
|
||||
let acceptedFileCount = 0;
|
||||
|
||||
for (let i = 0; i < allowedFiles.length; i++) {
|
||||
const file = allowedFiles[i];
|
||||
const startTime = Date.now();
|
||||
let processingTime,
|
||||
errorMessage = null,
|
||||
pageCount = 0;
|
||||
|
||||
try {
|
||||
let decryptedFile = files[i];
|
||||
let decryptedFile = file;
|
||||
let isEncrypted = false;
|
||||
let requiresPassword = false;
|
||||
await this.decryptFile
|
||||
@@ -245,18 +332,27 @@ class PdfContainer {
|
||||
|
||||
processingTime = Date.now() - startTime;
|
||||
this.captureFileProcessingEvent(true, decryptedFile, processingTime, null, pageCount);
|
||||
acceptedFileCount++;
|
||||
} catch (error) {
|
||||
processingTime = Date.now() - startTime;
|
||||
errorMessage = error.message || 'Unknown error';
|
||||
this.captureFileProcessingEvent(false, files[i], processingTime, errorMessage, pageCount);
|
||||
this.captureFileProcessingEvent(false, file, processingTime, errorMessage, pageCount);
|
||||
|
||||
if (isSvgFile(file)) {
|
||||
rejectedSvgFiles.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rejectedSvgFiles.length > 0) {
|
||||
notifySvgUnsupported(rejectedSvgFiles);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.enable-on-file').forEach((element) => {
|
||||
element.disabled = false;
|
||||
});
|
||||
|
||||
return pages;
|
||||
return { pages, acceptedFileCount };
|
||||
}
|
||||
|
||||
captureFileProcessingEvent(success, file, processingTime, errorMessage, pageCount) {
|
||||
@@ -329,12 +425,20 @@ class PdfContainer {
|
||||
}
|
||||
|
||||
async addImageFile(file, nextSiblingElement, pages) {
|
||||
// Ensure the provided file is a safe image type to prevent DOM XSS when
|
||||
// rendering user-supplied content. Reject SVG and non-image files that could
|
||||
// contain executable scripts.
|
||||
if (!(file instanceof File) || !file.type.startsWith('image/') || file.type === 'image/svg+xml') {
|
||||
throw new Error('Invalid image file');
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('page-container');
|
||||
|
||||
var img = document.createElement('img');
|
||||
const img = document.createElement('img');
|
||||
img.classList.add('page-image');
|
||||
img.src = URL.createObjectURL(file);
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
img.src = objectUrl;
|
||||
img.onload = () => URL.revokeObjectURL(objectUrl);
|
||||
div.appendChild(img);
|
||||
|
||||
this.pdfAdapters.forEach((adapter) => {
|
||||
@@ -349,6 +453,30 @@ class PdfContainer {
|
||||
return pages;
|
||||
}
|
||||
|
||||
duplicatePage(element) {
|
||||
const clone = document.createElement('div');
|
||||
clone.classList.add('page-container');
|
||||
|
||||
const originalImg = element.querySelector('img');
|
||||
const img = document.createElement('img');
|
||||
img.classList.add('page-image');
|
||||
img.src = originalImg.src;
|
||||
img.pageIdx = originalImg.pageIdx;
|
||||
img.rend = originalImg.rend;
|
||||
img.doc = originalImg.doc;
|
||||
img.style.rotate = originalImg.style.rotate;
|
||||
clone.appendChild(img);
|
||||
|
||||
this.pdfAdapters.forEach((adapter) => {
|
||||
adapter?.adapt?.(clone);
|
||||
});
|
||||
|
||||
const nextSibling = element.nextSibling;
|
||||
this.pagesContainer.insertBefore(clone, nextSibling);
|
||||
this.updatePageNumbersAndCheckboxes();
|
||||
return clone;
|
||||
}
|
||||
|
||||
async loadFile(file) {
|
||||
var objectUrl = URL.createObjectURL(file);
|
||||
var pdfDocument = await this.toPdfLib(objectUrl);
|
||||
@@ -357,8 +485,11 @@ class PdfContainer {
|
||||
}
|
||||
|
||||
async toRenderer(objectUrl) {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = './pdfjs-legacy/pdf.worker.mjs';
|
||||
const pdf = await pdfjsLib.getDocument(objectUrl).promise;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
|
||||
const pdf = await pdfjsLib.getDocument({
|
||||
url: objectUrl,
|
||||
...PDFJS_DEFAULT_OPTIONS,
|
||||
}).promise;
|
||||
return {
|
||||
document: pdf,
|
||||
pageCount: pdf.numPages,
|
||||
@@ -609,7 +740,7 @@ class PdfContainer {
|
||||
this.showButton(selectIcon, true);
|
||||
}
|
||||
} else {
|
||||
console.log("Page Select off. Hidding buttons");
|
||||
console.log("Page Select off. Hiding buttons");
|
||||
this.showButton(selectIcon, false);
|
||||
this.showButton(deselectIcon, false);
|
||||
}
|
||||
@@ -703,20 +834,48 @@ class PdfContainer {
|
||||
async exportPdf(selected) {
|
||||
const pdfDoc = await PDFLib.PDFDocument.create();
|
||||
const pageContainers = this.pagesContainer.querySelectorAll('.page-container'); // Select all .page-container elements
|
||||
|
||||
const docPageMap = new Map();
|
||||
|
||||
pageContainers.forEach((container, index) => {
|
||||
if (selected && !window.selectedPages.includes(index + 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const img = container.querySelector('img');
|
||||
if (!img?.doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
let entry = docPageMap.get(img.doc);
|
||||
if (!entry) {
|
||||
entry = { indices: [], copiedPages: [], cursor: 0 };
|
||||
docPageMap.set(img.doc, entry);
|
||||
}
|
||||
|
||||
entry.indices.push(img.pageIdx);
|
||||
});
|
||||
|
||||
for (const [doc, entry] of docPageMap.entries()) {
|
||||
entry.copiedPages = await pdfDoc.copyPages(doc, entry.indices);
|
||||
}
|
||||
|
||||
for (var i = 0; i < pageContainers.length; i++) {
|
||||
if (!selected || window.selectedPages.includes(i + 1)) {
|
||||
const img = pageContainers[i].querySelector('img'); // Find the img element within each .page-container
|
||||
if (!img) continue;
|
||||
let page;
|
||||
if (img.doc) {
|
||||
const pages = await pdfDoc.copyPages(img.doc, [img.pageIdx]);
|
||||
page = pages[0];
|
||||
const entry = docPageMap.get(img.doc);
|
||||
page = entry.copiedPages[entry.cursor++];
|
||||
pdfDoc.addPage(page);
|
||||
} else {
|
||||
page = pdfDoc.addPage([img.naturalWidth, img.naturalHeight]);
|
||||
const imageBytes = await fetch(img.src).then((res) => res.arrayBuffer());
|
||||
|
||||
// NEU: Bildbytes robust ermitteln (Canvas für blob:, fetch für http/https)
|
||||
const { bytes: imageBytes, forcedType } = await bytesFromImageElement(img);
|
||||
const uint8Array = new Uint8Array(imageBytes);
|
||||
const imageType = detectImageType(uint8Array);
|
||||
const imageType = forcedType || detectImageType(uint8Array);
|
||||
|
||||
let image;
|
||||
switch (imageType) {
|
||||
@@ -941,6 +1100,36 @@ class PdfContainer {
|
||||
}
|
||||
}
|
||||
|
||||
async function bytesFromImageElement(img) {
|
||||
// Handle Blob URLs without using fetch()
|
||||
if (img.src.startsWith('blob:')) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
if (!blob) throw new Error('Canvas toBlob() failed');
|
||||
const buf = await blob.arrayBuffer();
|
||||
return { bytes: buf, forcedType: 'PNG' }; // Canvas always generates PNG
|
||||
}
|
||||
|
||||
// Fetch http(s)/data:-URLs normally (if necessary)
|
||||
const res = await fetch(img.src, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} beim Laden von ${img.src}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
|
||||
// Use Content-Type as a hint (optional)
|
||||
let forcedType = null;
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('png')) forcedType = 'PNG';
|
||||
else if (ct.includes('jpeg') || ct.includes('jpg')) forcedType = 'JPEG';
|
||||
else if (ct.includes('tiff')) forcedType = 'TIFF';
|
||||
else if (ct.includes('gif')) forcedType = 'GIF';
|
||||
|
||||
return { bytes: buf, forcedType };
|
||||
}
|
||||
|
||||
function detectImageType(uint8Array) {
|
||||
// Check for PNG signature
|
||||
if (uint8Array[0] === 137 && uint8Array[1] === 80 && uint8Array[2] === 78 && uint8Array[3] === 71) {
|
||||
|
||||
@@ -1,51 +1,84 @@
|
||||
import {Command} from './command.js';
|
||||
import { CommandWithAnchors } from './command.js';
|
||||
|
||||
export class AddFilesCommand extends Command {
|
||||
export class AddFilesCommand extends CommandWithAnchors {
|
||||
/**
|
||||
* @param {HTMLElement|null} element - Anchor element (optional, forwarded to addFilesAction)
|
||||
* @param {number[]} selectedPages
|
||||
* @param {Function} addFilesAction - async (nextSiblingElement|false) => HTMLElement[]|HTMLElement|null
|
||||
* @param {HTMLElement} pagesContainer
|
||||
*/
|
||||
constructor(element, selectedPages, addFilesAction, pagesContainer) {
|
||||
super();
|
||||
this.element = element;
|
||||
this.selectedPages = selectedPages;
|
||||
this.addFilesAction = addFilesAction;
|
||||
this.pagesContainer = pagesContainer;
|
||||
|
||||
/** @type {HTMLElement[]} */
|
||||
this.addedElements = [];
|
||||
|
||||
/**
|
||||
* Anchors captured on undo to support redo reinsertion.
|
||||
* @type {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }[]}
|
||||
*/
|
||||
this._anchors = [];
|
||||
}
|
||||
|
||||
async execute() {
|
||||
const undoBtn = document.getElementById('undo-btn');
|
||||
undoBtn.disabled = true;
|
||||
if (this.element) {
|
||||
const newElement = await this.addFilesAction(this.element);
|
||||
if (newElement) {
|
||||
this.addedElements = newElement;
|
||||
}
|
||||
if (undoBtn) undoBtn.disabled = true;
|
||||
|
||||
const result = await this.addFilesAction(this.element || false);
|
||||
if (Array.isArray(result)) {
|
||||
this.addedElements = result;
|
||||
} else if (result) {
|
||||
this.addedElements = [result];
|
||||
} else {
|
||||
const newElement = await this.addFilesAction(false);
|
||||
if (newElement) {
|
||||
this.addedElements = newElement;
|
||||
}
|
||||
this.addedElements = [];
|
||||
}
|
||||
undoBtn.disabled = false;
|
||||
|
||||
// Capture anchors right after insertion so redo does not depend on undo.
|
||||
this._anchors = this.addedElements.map((el) => this.captureAnchor(el, this.pagesContainer));
|
||||
|
||||
if (undoBtn) undoBtn.disabled = false;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.addedElements.forEach((element) => {
|
||||
const nextSibling = element.nextSibling;
|
||||
this.pagesContainer.removeChild(element);
|
||||
this._anchors = [];
|
||||
|
||||
if (this.pagesContainer.childElementCount === 0) {
|
||||
const filenameInput = document.getElementById('filename-input');
|
||||
const downloadBtn = document.getElementById('export-button');
|
||||
for (const el of this.addedElements) {
|
||||
this._anchors.push(this.captureAnchor(el, this.pagesContainer));
|
||||
this.pagesContainer.removeChild(el);
|
||||
}
|
||||
|
||||
if (this.pagesContainer.childElementCount === 0) {
|
||||
const filenameInput = document.getElementById('filename-input');
|
||||
const downloadBtn = document.getElementById('export-button');
|
||||
if (filenameInput) {
|
||||
filenameInput.disabled = true;
|
||||
filenameInput.value = '';
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = true;
|
||||
}
|
||||
|
||||
element._nextSibling = nextSibling;
|
||||
});
|
||||
this.addedElements = [];
|
||||
}
|
||||
}
|
||||
|
||||
redo() {
|
||||
this.execute();
|
||||
if (!this.addedElements.length) return;
|
||||
// If the elements are already in the DOM (no prior undo), do nothing.
|
||||
const alreadyInDom =
|
||||
this.addedElements[0].parentNode === this.pagesContainer;
|
||||
if (alreadyInDom) return;
|
||||
|
||||
// Use pre-captured anchors (from execute) or fall back to capturing now.
|
||||
const anchors = (this._anchors && this._anchors.length)
|
||||
? this._anchors
|
||||
: this.addedElements.map((el) =>
|
||||
this.captureAnchor(el, this.pagesContainer));
|
||||
|
||||
for (const anchor of anchors) {
|
||||
this.insertWithAnchor(this.pagesContainer, anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,63 @@ export class Command {
|
||||
undo() {}
|
||||
redo() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class that provides anchor capture and reinsertion helpers
|
||||
* to avoid storing custom state on DOM nodes.
|
||||
*/
|
||||
export class CommandWithAnchors extends Command {
|
||||
constructor() {
|
||||
super();
|
||||
/** @type {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }[]} */
|
||||
this._anchors = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the child index of an element in a container.
|
||||
* @param {HTMLElement} container
|
||||
* @param {HTMLElement} el
|
||||
* @returns {number}
|
||||
*/
|
||||
_indexOf(container, el) {
|
||||
return Array.prototype.indexOf.call(container.children, el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures an anchor for later reinsertion.
|
||||
* @param {HTMLElement} el
|
||||
* @param {HTMLElement} container
|
||||
* @returns {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }}
|
||||
*/
|
||||
captureAnchor(el, container) {
|
||||
return {
|
||||
el,
|
||||
nextSibling: el.nextSibling,
|
||||
index: this._indexOf(container, el),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinserts an element using a previously captured anchor.
|
||||
* Prefers stored nextSibling when still valid; otherwise falls back to index.
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }} anchor
|
||||
*/
|
||||
insertWithAnchor(container, anchor) {
|
||||
const { el, nextSibling, index } = anchor;
|
||||
const nextValid = nextSibling && nextSibling.parentNode === container;
|
||||
|
||||
let ref = null;
|
||||
if (nextValid) {
|
||||
ref = nextSibling;
|
||||
} else if (
|
||||
Number.isInteger(index) &&
|
||||
index >= 0 &&
|
||||
index < container.children.length
|
||||
) {
|
||||
ref = container.children[index] || null;
|
||||
}
|
||||
|
||||
container.insertBefore(el, ref || null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import {Command} from './command.js';
|
||||
import { Command } from './command.js';
|
||||
|
||||
/**
|
||||
* Composes multiple commands into a single atomic operation.
|
||||
* Executes in order; undo in reverse order.
|
||||
*/
|
||||
export class CommandSequence extends Command {
|
||||
/** @param {Command[]} commands - Commands to be executed/undone/redone as a group. */
|
||||
constructor(commands) {
|
||||
super();
|
||||
this.commands = commands;
|
||||
|
||||
}
|
||||
|
||||
/** Execute: run each command in order. */
|
||||
execute() {
|
||||
this.commands.forEach((command) => command.execute())
|
||||
this.commands.forEach((command) => command.execute());
|
||||
}
|
||||
|
||||
/** Undo: undo in reverse order. */
|
||||
undo() {
|
||||
this.commands.slice().reverse().forEach((command) => command.undo())
|
||||
this.commands.slice().reverse().forEach((command) => command.undo());
|
||||
}
|
||||
|
||||
/** Redo: simply execute again. */
|
||||
redo() {
|
||||
this.execute();
|
||||
}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { Command } from "./command.js";
|
||||
|
||||
/**
|
||||
* Removes a page from the container and restores it on undo.
|
||||
*/
|
||||
export class DeletePageCommand extends Command {
|
||||
/**
|
||||
* @param {HTMLElement} element - Page container to delete.
|
||||
* @param {HTMLElement} pagesContainer - Parent container holding all pages.
|
||||
*/
|
||||
constructor(element, pagesContainer) {
|
||||
super();
|
||||
|
||||
this.element = element;
|
||||
this.pagesContainer = pagesContainer;
|
||||
|
||||
this.filenameInputValue = document.getElementById("filename-input").value;
|
||||
/** @type {ChildNode|null} */
|
||||
this.nextSibling = null;
|
||||
|
||||
const filenameInput = document.getElementById("filename-input");
|
||||
/** @type {string} */
|
||||
this.filenameInputValue = filenameInput ? filenameInput.value : "";
|
||||
|
||||
const filenameParagraph = document.getElementById("filename");
|
||||
this.filenameParagraphText = filenameParagraph
|
||||
? filenameParagraph.innerText
|
||||
: "";
|
||||
/** @type {string} */
|
||||
this.filenameParagraphText = filenameParagraph ? filenameParagraph.innerText : "";
|
||||
}
|
||||
|
||||
/** Execute: remove the page and update empty-state UI if needed. */
|
||||
execute() {
|
||||
this.nextSibling = this.element.nextSibling;
|
||||
|
||||
@@ -23,15 +35,19 @@ export class DeletePageCommand extends Command {
|
||||
const filenameInput = document.getElementById("filename-input");
|
||||
const downloadBtn = document.getElementById("export-button");
|
||||
|
||||
filenameInput.disabled = true;
|
||||
filenameInput.value = "";
|
||||
|
||||
downloadBtn.disabled = true;
|
||||
if (filenameInput) {
|
||||
filenameInput.disabled = true;
|
||||
filenameInput.value = "";
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Undo: reinsert the page at its original position. */
|
||||
undo() {
|
||||
let node = this.nextSibling;
|
||||
const node = /** @type {ChildNode|null} */ (this.nextSibling);
|
||||
if (node) this.pagesContainer.insertBefore(this.element, node);
|
||||
else this.pagesContainer.appendChild(this.element);
|
||||
|
||||
@@ -43,12 +59,16 @@ export class DeletePageCommand extends Command {
|
||||
const filenameInput = document.getElementById("filename-input");
|
||||
const downloadBtn = document.getElementById("export-button");
|
||||
|
||||
filenameInput.disabled = false;
|
||||
filenameInput.value = this.filenameInputValue;
|
||||
|
||||
downloadBtn.disabled = false;
|
||||
if (filenameInput) {
|
||||
filenameInput.disabled = false;
|
||||
filenameInput.value = this.filenameInputValue;
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Redo: remove again and maintain empty-state UI. */
|
||||
redo() {
|
||||
const pageNumberElement = this.element.querySelector(".page-number");
|
||||
if (pageNumberElement) {
|
||||
@@ -60,10 +80,13 @@ export class DeletePageCommand extends Command {
|
||||
const filenameInput = document.getElementById("filename-input");
|
||||
const downloadBtn = document.getElementById("export-button");
|
||||
|
||||
filenameInput.disabled = true;
|
||||
filenameInput.value = "";
|
||||
|
||||
downloadBtn.disabled = true;
|
||||
if (filenameInput) {
|
||||
filenameInput.disabled = true;
|
||||
filenameInput.value = "";
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { CommandWithAnchors } from './command.js';
|
||||
|
||||
export class DuplicatePageCommand extends CommandWithAnchors {
|
||||
/**
|
||||
* @param {HTMLElement} element - The page element to duplicate.
|
||||
* @param {Function} duplicatePageAction - (element) => HTMLElement (new clone already inserted)
|
||||
* @param {HTMLElement} pagesContainer
|
||||
*/
|
||||
constructor(element, duplicatePageAction, pagesContainer) {
|
||||
super();
|
||||
this.element = element;
|
||||
this.duplicatePageAction = duplicatePageAction;
|
||||
this.pagesContainer = pagesContainer;
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
this.newElement = null;
|
||||
|
||||
/** @type {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }|null} */
|
||||
this._anchor = null;
|
||||
}
|
||||
|
||||
execute() {
|
||||
// Create and insert a duplicate next to the original
|
||||
this.newElement = this.duplicatePageAction(this.element);
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this.newElement) return;
|
||||
|
||||
// Capture anchor before removal so redo can reinsert at the same position
|
||||
this._anchor = this.captureAnchor(this.newElement, this.pagesContainer);
|
||||
|
||||
this.pagesContainer.removeChild(this.newElement);
|
||||
|
||||
if (this.pagesContainer.childElementCount === 0) {
|
||||
const filenameInput = document.getElementById('filename-input');
|
||||
const downloadBtn = document.getElementById('export-button');
|
||||
if (filenameInput) {
|
||||
filenameInput.disabled = true;
|
||||
filenameInput.value = '';
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
window.updatePageNumbersAndCheckboxes?.();
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (!this.newElement) {
|
||||
this.execute();
|
||||
return;
|
||||
}
|
||||
if (this._anchor) {
|
||||
this.insertWithAnchor(this.pagesContainer, this._anchor);
|
||||
} else {
|
||||
// Fallback: insert relative to the original element
|
||||
this.pagesContainer.insertBefore(this.newElement, this.element.nextSibling || null);
|
||||
}
|
||||
window.updatePageNumbersAndCheckboxes?.();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import {Command} from './command.js';
|
||||
import { Command } from './command.js';
|
||||
|
||||
/**
|
||||
* Moves a page (or multiple pages, via PdfContainer wrapper) inside the container.
|
||||
*/
|
||||
export class MovePageCommand extends Command {
|
||||
/**
|
||||
* @param {HTMLElement} startElement - Dragged page container.
|
||||
* @param {HTMLElement|null} endElement - Destination reference; insert before this node. Null = append.
|
||||
* @param {HTMLElement} pagesContainer - Parent container with all pages.
|
||||
* @param {HTMLElement} pagesContainerWrapper - Scrollable wrapper element.
|
||||
* @param {boolean} [scrollTo=false] - Whether to apply a subtle scroll after move.
|
||||
*/
|
||||
constructor(startElement, endElement, pagesContainer, pagesContainerWrapper, scrollTo = false) {
|
||||
super();
|
||||
|
||||
this.pagesContainer = pagesContainer;
|
||||
const childArray = Array.from(this.pagesContainer.childNodes);
|
||||
|
||||
/** @type {number} */
|
||||
this.startIndex = childArray.indexOf(startElement);
|
||||
/** @type {number} */
|
||||
this.endIndex = childArray.indexOf(endElement);
|
||||
|
||||
this.startElement = startElement;
|
||||
@@ -16,8 +28,10 @@ export class MovePageCommand extends Command {
|
||||
this.scrollTo = scrollTo;
|
||||
this.pagesContainerWrapper = pagesContainerWrapper;
|
||||
}
|
||||
|
||||
/** Execute: perform DOM move and optional scroll. */
|
||||
execute() {
|
||||
// Check & remove page number elements here too if they exist because Firefox doesn't fire the relevant event on page move.
|
||||
// Remove stale page number badge if present (Firefox sometimes misses the event)
|
||||
const pageNumberElement = this.startElement.querySelector('.page-number');
|
||||
if (pageNumberElement) {
|
||||
this.startElement.removeChild(pageNumberElement);
|
||||
@@ -31,7 +45,7 @@ export class MovePageCommand extends Command {
|
||||
}
|
||||
|
||||
if (this.scrollTo) {
|
||||
const {width} = this.startElement.getBoundingClientRect();
|
||||
const { width } = this.startElement.getBoundingClientRect();
|
||||
const vector = this.endIndex !== -1 && this.startIndex > this.endIndex ? 0 - width : width;
|
||||
|
||||
this.pagesContainerWrapper.scroll({
|
||||
@@ -40,16 +54,17 @@ export class MovePageCommand extends Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** Undo: restore original order and optional scroll back. */
|
||||
undo() {
|
||||
if (this.startElement) {
|
||||
this.pagesContainer.removeChild(this.startElement);
|
||||
let previousNeighbour = Array.from(this.pagesContainer.childNodes)[this.startIndex];
|
||||
const previousNeighbour = Array.from(this.pagesContainer.childNodes)[this.startIndex];
|
||||
previousNeighbour?.insertAdjacentElement('beforebegin', this.startElement)
|
||||
?? this.pagesContainer.append(this.startElement);
|
||||
}
|
||||
|
||||
if (this.scrollTo) {
|
||||
const {width} = this.startElement.getBoundingClientRect();
|
||||
const { width } = this.startElement.getBoundingClientRect();
|
||||
const vector = this.endIndex === -1 || this.startIndex <= this.endIndex ? 0 - width : width;
|
||||
|
||||
this.pagesContainerWrapper.scroll({
|
||||
@@ -58,6 +73,7 @@ export class MovePageCommand extends Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** Redo: same as execute. */
|
||||
redo() {
|
||||
this.execute();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import {Command} from './command.js';
|
||||
import { CommandWithAnchors } from './command.js';
|
||||
|
||||
export class PageBreakCommand extends Command {
|
||||
export class PageBreakCommand extends CommandWithAnchors {
|
||||
/**
|
||||
* @param {HTMLElement[]} elements
|
||||
* @param {boolean} isSelectedInWindow
|
||||
* @param {number[]} selectedPages - 0-based indices of selected pages
|
||||
* @param {Function} pageBreakCallback - async (element, addedSoFar) => HTMLElement[]|HTMLElement|null
|
||||
* @param {HTMLElement} pagesContainer
|
||||
*/
|
||||
constructor(elements, isSelectedInWindow, selectedPages, pageBreakCallback, pagesContainer) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
@@ -8,7 +15,17 @@ export class PageBreakCommand extends Command {
|
||||
this.selectedPages = selectedPages;
|
||||
this.pageBreakCallback = pageBreakCallback;
|
||||
this.pagesContainer = pagesContainer;
|
||||
|
||||
/** @type {HTMLElement[]} */
|
||||
this.addedElements = [];
|
||||
|
||||
/**
|
||||
* Anchors captured on undo to support redo reinsertion.
|
||||
* @type {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }[]}
|
||||
*/
|
||||
this._anchors = [];
|
||||
|
||||
// Keep content snapshot if needed for future enhancements
|
||||
this.originalStates = Array.from(elements, (element) => ({
|
||||
element,
|
||||
hasContent: element.innerHTML.trim() !== '',
|
||||
@@ -17,43 +34,72 @@ export class PageBreakCommand extends Command {
|
||||
|
||||
async execute() {
|
||||
const undoBtn = document.getElementById('undo-btn');
|
||||
undoBtn.disabled = true;
|
||||
for (const [index, element] of this.elements.entries()) {
|
||||
if (!this.isSelectedInWindow || this.selectedPages.includes(index)) {
|
||||
if (index !== 0) {
|
||||
const newElement = await this.pageBreakCallback(element, this.addedElements);
|
||||
if (undoBtn) undoBtn.disabled = true;
|
||||
|
||||
if (newElement) {
|
||||
this.addedElements = newElement;
|
||||
}
|
||||
for (const [index, element] of this.elements.entries()) {
|
||||
const withinSelection = !this.isSelectedInWindow || this.selectedPages.includes(index);
|
||||
if (!withinSelection) continue;
|
||||
|
||||
if (index !== 0) {
|
||||
const result = await this.pageBreakCallback(element, this.addedElements);
|
||||
if (!Array.isArray(this.addedElements)) {
|
||||
this.addedElements = [];
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
this.addedElements.push(...result);
|
||||
} else if (result) {
|
||||
this.addedElements.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
undoBtn.disabled = false;
|
||||
|
||||
// Capture anchors right after insertion so redo does not depend on undo.
|
||||
this._anchors = this.addedElements.map((el) => this.captureAnchor(el, this.pagesContainer));
|
||||
|
||||
if (undoBtn) undoBtn.disabled = false;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.addedElements.forEach((element) => {
|
||||
const nextSibling = element.nextSibling;
|
||||
this._anchors = [];
|
||||
|
||||
this.pagesContainer.removeChild(element);
|
||||
for (const el of this.addedElements) {
|
||||
this._anchors.push(this.captureAnchor(el, this.pagesContainer));
|
||||
this.pagesContainer.removeChild(el);
|
||||
}
|
||||
|
||||
if (this.pagesContainer.childElementCount === 0) {
|
||||
const filenameInput = document.getElementById('filename-input');
|
||||
const filenameParagraph = document.getElementById('filename');
|
||||
const downloadBtn = document.getElementById('export-button');
|
||||
if (this.pagesContainer.childElementCount === 0) {
|
||||
const filenameInput = document.getElementById('filename-input');
|
||||
const filenameParagraph = document.getElementById('filename');
|
||||
const downloadBtn = document.getElementById('export-button');
|
||||
|
||||
if (filenameInput) {
|
||||
filenameInput.disabled = true;
|
||||
filenameInput.value = '';
|
||||
}
|
||||
if (filenameParagraph) {
|
||||
filenameParagraph.innerText = '';
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = true;
|
||||
}
|
||||
|
||||
element._nextSibling = nextSibling;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
redo() {
|
||||
this.execute();
|
||||
// If elements are already present (no prior undo), do nothing.
|
||||
if (!this.addedElements.length) return;
|
||||
const alreadyInDom =
|
||||
this.addedElements[0].parentNode === this.pagesContainer;
|
||||
if (alreadyInDom) return;
|
||||
|
||||
// Use pre-captured anchors (from execute) or fall back to current ones.
|
||||
const anchors = (this._anchors && this._anchors.length)
|
||||
? this._anchors
|
||||
: this.addedElements.map((el) => this.captureAnchor(el, this.pagesContainer));
|
||||
|
||||
for (const anchor of anchors) {
|
||||
this.insertWithAnchor(this.pagesContainer, anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,45 @@
|
||||
import { Command } from "./command.js";
|
||||
|
||||
/**
|
||||
* Deletes a set of selected pages and restores them on undo.
|
||||
*/
|
||||
export class RemoveSelectedCommand extends Command {
|
||||
/**
|
||||
* @param {HTMLElement} pagesContainer - Parent container.
|
||||
* @param {number[]} selectedPages - 1-based page numbers to remove.
|
||||
* @param {Function} updatePageNumbersAndCheckboxes - Callback to refresh UI state.
|
||||
*/
|
||||
constructor(pagesContainer, selectedPages, updatePageNumbersAndCheckboxes) {
|
||||
super();
|
||||
this.pagesContainer = pagesContainer;
|
||||
this.selectedPages = selectedPages;
|
||||
|
||||
/** @type {{idx:number, childNode:HTMLElement}[]} */
|
||||
this.deletedChildren = [];
|
||||
|
||||
if (updatePageNumbersAndCheckboxes) {
|
||||
this.updatePageNumbersAndCheckboxes = updatePageNumbersAndCheckboxes;
|
||||
} else {
|
||||
this.updatePageNumbersAndCheckboxes = updatePageNumbersAndCheckboxes || (() => {
|
||||
const pageDivs = document.querySelectorAll(".pdf-actions_container");
|
||||
|
||||
pageDivs.forEach((div, index) => {
|
||||
const pageNumber = index + 1;
|
||||
const checkbox = div.querySelector(".pdf-actions_checkbox");
|
||||
checkbox.id = `selectPageCheckbox-${pageNumber}`;
|
||||
checkbox.setAttribute("data-page-number", pageNumber);
|
||||
checkbox.checked = window.selectedPages.includes(pageNumber);
|
||||
if (checkbox) {
|
||||
checkbox.id = `selectPageCheckbox-${pageNumber}`;
|
||||
checkbox.setAttribute("data-page-number", pageNumber);
|
||||
checkbox.checked = window.selectedPages.includes(pageNumber);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const filenameInput = document.getElementById("filename-input");
|
||||
const filenameParagraph = document.getElementById("filename");
|
||||
|
||||
/** @type {string} */
|
||||
this.originalFilenameInputValue = filenameInput ? filenameInput.value : "";
|
||||
if (filenameParagraph)
|
||||
this.originalFilenameParagraphText = filenameParagraph.innerText;
|
||||
/** @type {string|undefined} */
|
||||
this.originalFilenameParagraphText = filenameParagraph?.innerText;
|
||||
}
|
||||
|
||||
/** Execute: remove selected pages and update empty state. */
|
||||
execute() {
|
||||
let deletions = 0;
|
||||
|
||||
@@ -53,10 +63,9 @@ export class RemoveSelectedCommand extends Command {
|
||||
const downloadBtn = document.getElementById("export-button");
|
||||
|
||||
if (filenameInput) filenameInput.disabled = true;
|
||||
filenameInput.value = "";
|
||||
if (filenameInput) filenameInput.value = "";
|
||||
if (filenameParagraph) filenameParagraph.innerText = "";
|
||||
|
||||
downloadBtn.disabled = true;
|
||||
if (downloadBtn) downloadBtn.disabled = true;
|
||||
}
|
||||
|
||||
window.selectedPages = [];
|
||||
@@ -64,12 +73,13 @@ export class RemoveSelectedCommand extends Command {
|
||||
document.dispatchEvent(new Event("selectedPagesUpdated"));
|
||||
}
|
||||
|
||||
/** Undo: restore all removed nodes at their original indices. */
|
||||
undo() {
|
||||
while (this.deletedChildren.length > 0) {
|
||||
let deletedChild = this.deletedChildren.pop();
|
||||
if (this.pagesContainer.children.length <= deletedChild.idx)
|
||||
const deletedChild = this.deletedChildren.pop();
|
||||
if (this.pagesContainer.children.length <= deletedChild.idx) {
|
||||
this.pagesContainer.appendChild(deletedChild.childNode);
|
||||
else {
|
||||
} else {
|
||||
this.pagesContainer.insertBefore(
|
||||
deletedChild.childNode,
|
||||
this.pagesContainer.children[deletedChild.idx]
|
||||
@@ -83,11 +93,11 @@ export class RemoveSelectedCommand extends Command {
|
||||
const downloadBtn = document.getElementById("export-button");
|
||||
|
||||
if (filenameInput) filenameInput.disabled = false;
|
||||
filenameInput.value = this.originalFilenameInputValue;
|
||||
if (filenameParagraph)
|
||||
if (filenameInput) filenameInput.value = this.originalFilenameInputValue;
|
||||
if (filenameParagraph && this.originalFilenameParagraphText !== undefined) {
|
||||
filenameParagraph.innerText = this.originalFilenameParagraphText;
|
||||
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
if (downloadBtn) downloadBtn.disabled = false;
|
||||
}
|
||||
|
||||
window.selectedPages = this.selectedPages;
|
||||
@@ -95,6 +105,7 @@ export class RemoveSelectedCommand extends Command {
|
||||
document.dispatchEvent(new Event("selectedPagesUpdated"));
|
||||
}
|
||||
|
||||
/** Redo mirrors execute. */
|
||||
redo() {
|
||||
this.execute();
|
||||
}
|
||||
|
||||
@@ -1,53 +1,61 @@
|
||||
import { Command } from "./command.js";
|
||||
|
||||
/**
|
||||
* Rotates a single image element by a relative degree.
|
||||
*/
|
||||
export class RotateElementCommand extends Command {
|
||||
/**
|
||||
* @param {HTMLElement} element - The <img> element to rotate.
|
||||
* @param {number|string} degree - Relative degrees to add (e.g., 90 or "-90").
|
||||
*/
|
||||
constructor(element, degree) {
|
||||
super();
|
||||
this.element = element;
|
||||
this.degree = degree;
|
||||
}
|
||||
|
||||
/** Execute: apply rotation. */
|
||||
execute() {
|
||||
let lastTransform = this.element.style.rotate;
|
||||
if (!lastTransform) {
|
||||
lastTransform = "0";
|
||||
}
|
||||
let lastTransform = this.element.style.rotate || "0";
|
||||
const lastAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
||||
const newAngle = lastAngle + parseInt(this.degree);
|
||||
|
||||
this.element.style.rotate = newAngle + "deg";
|
||||
}
|
||||
|
||||
/** Undo: revert by subtracting the same delta. */
|
||||
undo() {
|
||||
let lastTransform = this.element.style.rotate;
|
||||
if (!lastTransform) {
|
||||
lastTransform = "0";
|
||||
}
|
||||
|
||||
let lastTransform = this.element.style.rotate || "0";
|
||||
const currentAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
||||
const undoAngle = currentAngle + -parseInt(this.degree);
|
||||
|
||||
this.element.style.rotate = undoAngle + "deg";
|
||||
}
|
||||
|
||||
/** Redo mirrors execute. */
|
||||
redo() {
|
||||
this.execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a set of image elements by a relative degree.
|
||||
*/
|
||||
export class RotateAllCommand extends Command {
|
||||
/**
|
||||
* @param {HTMLElement[]} elements - Image elements to rotate.
|
||||
* @param {number} degree - Relative degrees to add for all.
|
||||
*/
|
||||
constructor(elements, degree) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.degree = degree;
|
||||
}
|
||||
|
||||
/** Execute: apply rotation to all. */
|
||||
execute() {
|
||||
for (let element of this.elements) {
|
||||
let lastTransform = element.style.rotate;
|
||||
if (!lastTransform) {
|
||||
lastTransform = "0";
|
||||
}
|
||||
for (const element of this.elements) {
|
||||
let lastTransform = element.style.rotate || "0";
|
||||
const lastAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
||||
const newAngle = lastAngle + this.degree;
|
||||
|
||||
@@ -55,12 +63,10 @@ export class RotateAllCommand extends Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** Undo: revert rotation for all. */
|
||||
undo() {
|
||||
for (let element of this.elements) {
|
||||
let lastTransform = element.style.rotate;
|
||||
if (!lastTransform) {
|
||||
lastTransform = "0";
|
||||
}
|
||||
for (const element of this.elements) {
|
||||
let lastTransform = element.style.rotate || "0";
|
||||
const currentAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
||||
const undoAngle = currentAngle + -this.degree;
|
||||
|
||||
@@ -68,6 +74,7 @@ export class RotateAllCommand extends Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** Redo mirrors execute. */
|
||||
redo() {
|
||||
this.execute();
|
||||
}
|
||||
|
||||
@@ -1,57 +1,45 @@
|
||||
import { Command } from "./command.js";
|
||||
|
||||
/**
|
||||
* Toggles selection state of a single page via its checkbox.
|
||||
*/
|
||||
export class SelectPageCommand extends Command {
|
||||
/**
|
||||
* @param {number} pageNumber - 1-based page number.
|
||||
* @param {HTMLInputElement} checkbox - Checkbox linked to the page.
|
||||
*/
|
||||
constructor(pageNumber, checkbox) {
|
||||
super();
|
||||
this.pageNumber = pageNumber;
|
||||
this.selectCheckbox = checkbox;
|
||||
}
|
||||
|
||||
/** Execute: apply current checkbox state to global selection. */
|
||||
execute() {
|
||||
if (this.selectCheckbox.checked) {
|
||||
//adds to array of selected pages
|
||||
window.selectedPages.push(this.pageNumber);
|
||||
} else {
|
||||
//remove page from selected pages array
|
||||
const index = window.selectedPages.indexOf(this.pageNumber);
|
||||
if (index !== -1) {
|
||||
window.selectedPages.splice(index, 1);
|
||||
}
|
||||
if (index !== -1) window.selectedPages.splice(index, 1);
|
||||
}
|
||||
|
||||
if (window.selectedPages.length > 0 && !window.selectPage) {
|
||||
window.toggleSelectPageVisibility();
|
||||
}
|
||||
if (window.selectedPages.length == 0 && window.selectPage) {
|
||||
if (window.selectedPages.length === 0 && window.selectPage) {
|
||||
window.toggleSelectPageVisibility();
|
||||
}
|
||||
|
||||
window.updateSelectedPagesDisplay();
|
||||
}
|
||||
|
||||
/** Undo: invert checkbox and apply same logic as execute. */
|
||||
undo() {
|
||||
this.selectCheckbox.checked = !this.selectCheckbox.checked;
|
||||
if (this.selectCheckbox.checked) {
|
||||
//adds to array of selected pages
|
||||
window.selectedPages.push(this.pageNumber);
|
||||
} else {
|
||||
//remove page from selected pages array
|
||||
const index = window.selectedPages.indexOf(this.pageNumber);
|
||||
if (index !== -1) {
|
||||
window.selectedPages.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (window.selectedPages.length > 0 && !window.selectPage) {
|
||||
window.toggleSelectPageVisibility();
|
||||
}
|
||||
if (window.selectedPages.length == 0 && window.selectPage) {
|
||||
window.toggleSelectPageVisibility();
|
||||
}
|
||||
|
||||
window.updateSelectedPagesDisplay();
|
||||
this.execute();
|
||||
}
|
||||
|
||||
/** Redo: invert again then execute. */
|
||||
redo() {
|
||||
this.selectCheckbox.checked = !this.selectCheckbox.checked;
|
||||
this.execute();
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
import { Command } from "./command.js";
|
||||
|
||||
/**
|
||||
* Toggles a split class on a single page element.
|
||||
*/
|
||||
export class SplitFileCommand extends Command {
|
||||
/**
|
||||
* @param {HTMLElement} element - Target page container.
|
||||
* @param {string} splitClass - CSS class to toggle for split markers.
|
||||
*/
|
||||
constructor(element, splitClass) {
|
||||
super();
|
||||
this.element = element;
|
||||
this.splitClass = splitClass;
|
||||
}
|
||||
|
||||
/** Execute: toggle split class. */
|
||||
execute() {
|
||||
this.element.classList.toggle(this.splitClass);
|
||||
}
|
||||
|
||||
/** Undo: toggle split class back. */
|
||||
undo() {
|
||||
this.element.classList.toggle(this.splitClass);
|
||||
}
|
||||
|
||||
/** Redo: same as execute. */
|
||||
redo() {
|
||||
this.execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles split class across a set of elements, optionally limited by selection.
|
||||
*/
|
||||
export class SplitAllCommand extends Command {
|
||||
/**
|
||||
* @param {NodeListOf<HTMLElement>|HTMLElement[]} elements - All page containers.
|
||||
* @param {boolean} isSelectedInWindow - Whether multi-select mode is active.
|
||||
* @param {number[]} selectedPages - 0-based indices of selected pages (when active).
|
||||
* @param {string} splitClass - CSS class used as split marker.
|
||||
*/
|
||||
constructor(elements, isSelectedInWindow, selectedPages, splitClass) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
@@ -29,72 +48,41 @@ export class SplitAllCommand extends Command {
|
||||
this.splitClass = splitClass;
|
||||
}
|
||||
|
||||
/** Execute: toggle split for all or selected pages. */
|
||||
execute() {
|
||||
if (!this.isSelectedInWindow) {
|
||||
const hasSplit = this._hasSplit(this.elements, this.splitClass);
|
||||
if (hasSplit) {
|
||||
this.elements.forEach((page) => {
|
||||
const hasSplit = this._hasSplit();
|
||||
(this.elements || []).forEach((page) => {
|
||||
if (hasSplit) {
|
||||
page.classList.remove(this.splitClass);
|
||||
});
|
||||
} else {
|
||||
this.elements.forEach((page) => {
|
||||
} else {
|
||||
page.classList.add(this.splitClass);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.forEach((page, index) => {
|
||||
const pageIndex = index;
|
||||
if (this.isSelectedInWindow && !this.selectedPages.includes(pageIndex))
|
||||
return;
|
||||
|
||||
if (page.classList.contains(this.splitClass)) {
|
||||
page.classList.remove(this.splitClass);
|
||||
} else {
|
||||
page.classList.add(this.splitClass);
|
||||
}
|
||||
if (!this.selectedPages.includes(index)) return;
|
||||
page.classList.toggle(this.splitClass);
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {boolean} true if any element currently has the split class. */
|
||||
_hasSplit() {
|
||||
if (!this.elements || this.elements.length == 0) return false;
|
||||
|
||||
if (!this.elements || this.elements.length === 0) return false;
|
||||
for (const node of this.elements) {
|
||||
if (node.classList.contains(this.splitClass)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Undo mirrors execute logic. */
|
||||
undo() {
|
||||
if (!this.isSelectedInWindow) {
|
||||
const hasSplit = this._hasSplit(this.elements, this.splitClass);
|
||||
if (hasSplit) {
|
||||
this.elements.forEach((page) => {
|
||||
page.classList.remove(this.splitClass);
|
||||
});
|
||||
} else {
|
||||
this.elements.forEach((page) => {
|
||||
page.classList.add(this.splitClass);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.forEach((page, index) => {
|
||||
const pageIndex = index;
|
||||
if (this.isSelectedInWindow && !this.selectedPages.includes(pageIndex))
|
||||
return;
|
||||
|
||||
if (page.classList.contains(this.splitClass)) {
|
||||
page.classList.remove(this.splitClass);
|
||||
} else {
|
||||
page.classList.add(this.splitClass);
|
||||
}
|
||||
});
|
||||
this.execute();
|
||||
}
|
||||
|
||||
/** Redo mirrors execute logic. */
|
||||
redo() {
|
||||
this.execute();
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ function tooltipSetup() {
|
||||
element.addEventListener("mousemove", (event) => updateTooltipPosition(event, tooltipText));
|
||||
element.addEventListener("mouseleave", hideTooltip);
|
||||
|
||||
// in case UI moves and mouseleave is not triggered, tooltip is readded when mouse is moved over the element
|
||||
// in case UI moves and mouseleave is not triggered, the tooltip is re-added when the mouse is moved over the element
|
||||
element.addEventListener("click", hideTooltip);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
const PDFJS_DEFAULT_OPTIONS = {
|
||||
cMapUrl: pdfjsPath + 'cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: pdfjsPath + 'standard_fonts/',
|
||||
};
|
||||
|
||||
window.goToFirstOrLastPage = goToFirstOrLastPage;
|
||||
|
||||
document.getElementById('download-pdf').addEventListener('click', async () => {
|
||||
@@ -31,8 +37,11 @@ document.querySelector('input[name=pdf-upload]').addEventListener('change', asyn
|
||||
const file = allFiles[0];
|
||||
originalFileName = file.name.replace(/\.[^/.]+$/, '');
|
||||
const pdfData = await file.arrayBuffer();
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = './pdfjs-legacy/pdf.worker.mjs';
|
||||
const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
|
||||
const pdfDoc = await pdfjsLib.getDocument({
|
||||
...PDFJS_DEFAULT_OPTIONS,
|
||||
data: pdfData,
|
||||
}).promise;
|
||||
await DraggableUtils.renderPage(pdfDoc, 0);
|
||||
|
||||
document.querySelectorAll('.show-on-file-selected').forEach((el) => {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
const PDFJS_DEFAULT_OPTIONS = {
|
||||
cMapUrl: pdfjsPath + 'cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: pdfjsPath + 'standard_fonts/',
|
||||
};
|
||||
|
||||
var canvas = document.getElementById('contrast-pdf-canvas');
|
||||
var context = canvas.getContext('2d');
|
||||
var originalImageData = null;
|
||||
@@ -9,8 +15,11 @@ async function renderPDFAndSaveOriginalImageData(file) {
|
||||
var fileReader = new FileReader();
|
||||
fileReader.onload = async function () {
|
||||
var data = new Uint8Array(this.result);
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = './pdfjs-legacy/pdf.worker.mjs';
|
||||
pdf = await pdfjsLib.getDocument({data: data}).promise;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
|
||||
pdf = await pdfjsLib.getDocument({
|
||||
...PDFJS_DEFAULT_OPTIONS,
|
||||
data: data,
|
||||
}).promise;
|
||||
|
||||
// Get the number of pages in the PDF
|
||||
var numPages = pdf.numPages;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
const PDFJS_DEFAULT_OPTIONS = {
|
||||
cMapUrl: pdfjsPath + 'cmaps/',
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: pdfjsPath + 'standard_fonts/',
|
||||
};
|
||||
|
||||
const deleteAllCheckbox = document.querySelector('#deleteAll');
|
||||
let inputs = document.querySelectorAll('input');
|
||||
const customMetadataDiv = document.getElementById('customMetadata');
|
||||
@@ -43,8 +49,13 @@ fileInput.addEventListener('change', async function () {
|
||||
customMetadataFormContainer.removeChild(customMetadataFormContainer.firstChild);
|
||||
}
|
||||
var url = URL.createObjectURL(file);
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = './pdfjs-legacy/pdf.worker.mjs';
|
||||
const pdf = await pdfjsLib.getDocument(url).promise;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath + 'pdf.worker.mjs';
|
||||
const pdf = await pdfjsLib
|
||||
.getDocument({
|
||||
...PDFJS_DEFAULT_OPTIONS,
|
||||
url: url,
|
||||
})
|
||||
.promise;
|
||||
const pdfMetadata = await pdf.getMetadata();
|
||||
lastPDFFile = pdfMetadata?.info;
|
||||
console.log(pdfMetadata);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user