Multi module refactor (#3640)

# Description of Changes

Migrated Stirling PDF to a multi-module structure:

* Introduced new `:stirling-pdf` module
* Moved all the core logic and features of Stirling PDF into
`:stirling-pdf`
* Updated paths of jobs and scripts

---

## 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/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/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/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/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Dario Ghunney Ware
2025-06-09 12:51:41 +01:00
committed by GitHub
parent baaaa5a0b2
commit c7d6a063d7
921 changed files with 2480 additions and 7648 deletions
@@ -0,0 +1,39 @@
package stirling.software.SPDF.Factories;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.api.misc.HighContrastColorCombination;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
import stirling.software.common.util.misc.CustomColorReplaceStrategy;
import stirling.software.common.util.misc.InvertFullColorStrategy;
import stirling.software.common.util.misc.ReplaceAndInvertColorStrategy;
@Component
public class ReplaceAndInvertColorFactory {
public ReplaceAndInvertColorStrategy replaceAndInvert(
MultipartFile file,
ReplaceAndInvert replaceAndInvertOption,
HighContrastColorCombination highContrastColorCombination,
String backGroundColor,
String textColor) {
if (replaceAndInvertOption == ReplaceAndInvert.CUSTOM_COLOR
|| replaceAndInvertOption == ReplaceAndInvert.HIGH_CONTRAST_COLOR) {
return new CustomColorReplaceStrategy(
file,
replaceAndInvertOption,
textColor,
backGroundColor,
highContrastColorCombination);
} else if (replaceAndInvertOption == ReplaceAndInvert.FULL_INVERSION) {
return new InvertFullColorStrategy(file, replaceAndInvertOption);
}
return null;
}
}
@@ -0,0 +1,99 @@
package stirling.software.SPDF;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import io.github.pixee.security.SystemCommand;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class LibreOfficeListener {
private static final long ACTIVITY_TIMEOUT = 20L * 60 * 1000; // 20 minutes
private static final LibreOfficeListener INSTANCE = new LibreOfficeListener();
private static final int LISTENER_PORT = 2002;
private ExecutorService executorService;
private long lastActivityTime;
private Process process;
private LibreOfficeListener() {}
public static LibreOfficeListener getInstance() {
return INSTANCE;
}
private boolean isListenerRunning() {
log.info("waiting for listener to start");
try (Socket socket = new Socket()) {
socket.connect(
new InetSocketAddress("localhost", LISTENER_PORT),
1000); // Timeout after 1 second
return true;
} catch (Exception e) {
return false;
}
}
public void start() throws IOException {
// Check if the listener is already running
if (process != null && process.isAlive()) {
return;
}
// Start the listener process
process = SystemCommand.runCommand(Runtime.getRuntime(), "unoconv --listener");
lastActivityTime = System.currentTimeMillis();
// Start a background thread to monitor the activity timeout
executorService = Executors.newSingleThreadExecutor();
executorService.submit(
() -> {
while (true) {
long idleTime = System.currentTimeMillis() - lastActivityTime;
if (idleTime >= ACTIVITY_TIMEOUT) {
// If there has been no activity for too long, tear down the listener
process.destroy();
break;
}
try {
Thread.sleep(5000); // Check for inactivity every 5 seconds
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
// Wait for the listener to start up
long startTime = System.currentTimeMillis();
long timeout = 30000; // Timeout after 30 seconds
while (System.currentTimeMillis() - startTime < timeout) {
if (isListenerRunning()) {
lastActivityTime = System.currentTimeMillis();
return;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("exception", e);
} // Check every 1 second
}
}
public synchronized void stop() {
// Stop the activity timeout monitor thread
executorService.shutdownNow();
// Stop the listener process
if (process != null && process.isAlive()) {
process.destroy();
}
}
}
@@ -0,0 +1,235 @@
package stirling.software.SPDF;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling;
import io.github.pixee.security.SystemCommand;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.UI.WebBrowser;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.ConfigInitializer;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.UrlUtils;
@Slf4j
@EnableScheduling
@SpringBootApplication(
scanBasePackages = {
"stirling.software.SPDF",
"stirling.software.common",
"stirling.software.proprietary"
},
exclude = {
DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class
})
public class SPDFApplication {
private static String serverPortStatic;
private static String baseUrlStatic;
private static String contextPathStatic;
private final AppConfig appConfig;
private final Environment env;
private final ApplicationProperties applicationProperties;
private final WebBrowser webBrowser;
public SPDFApplication(
AppConfig appConfig,
Environment env,
ApplicationProperties applicationProperties,
@Autowired(required = false) WebBrowser webBrowser) {
this.appConfig = appConfig;
this.env = env;
this.applicationProperties = applicationProperties;
this.webBrowser = webBrowser;
}
public static void main(String[] args) throws IOException, InterruptedException {
SpringApplication app = new SpringApplication(SPDFApplication.class);
Properties props = new Properties();
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
System.setProperty("java.awt.headless", "false");
app.setHeadless(false);
props.put("java.awt.headless", "false");
props.put("spring.main.web-application-type", "servlet");
int desiredPort = 8080;
String port = UrlUtils.findAvailablePort(desiredPort);
props.put("server.port", port);
System.setProperty("server.port", port);
log.info("Desktop UI mode: Using port {}", port);
}
app.setAdditionalProfiles(getActiveProfile(args));
ConfigInitializer initializer = new ConfigInitializer();
try {
initializer.ensureConfigExists();
} catch (IOException | URISyntaxException e) {
log.error("Error initialising configuration", e);
}
Map<String, String> propertyFiles = new HashMap<>();
// External config files
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
log.info("Settings file: {}", settingsPath.toString());
if (Files.exists(settingsPath)) {
propertyFiles.put(
"spring.config.additional-location", "file:" + settingsPath.toString());
} else {
log.warn("External configuration file '{}' does not exist.", settingsPath.toString());
}
Path customSettingsPath = Paths.get(InstallationPathConfig.getCustomSettingsPath());
log.info("Custom settings file: {}", customSettingsPath.toString());
if (Files.exists(customSettingsPath)) {
String existingLocation =
propertyFiles.getOrDefault("spring.config.additional-location", "");
if (!existingLocation.isEmpty()) {
existingLocation += ",";
}
propertyFiles.put(
"spring.config.additional-location",
existingLocation + "file:" + customSettingsPath.toString());
} else {
log.warn(
"Custom configuration file '{}' does not exist.",
customSettingsPath.toString());
}
Properties finalProps = new Properties();
if (!propertyFiles.isEmpty()) {
finalProps.putAll(
Collections.singletonMap(
"spring.config.additional-location",
propertyFiles.get("spring.config.additional-location")));
}
if (!props.isEmpty()) {
finalProps.putAll(props);
}
app.setDefaultProperties(finalProps);
app.run(args);
// Ensure directories are created
try {
Files.createDirectories(Path.of(InstallationPathConfig.getTemplatesPath()));
Files.createDirectories(Path.of(InstallationPathConfig.getStaticPath()));
} catch (IOException e) {
log.error("Error creating directories: {}", e.getMessage());
}
printStartupLogs();
}
@PostConstruct
public void init() {
String baseUrl = appConfig.getBaseUrl();
String contextPath = appConfig.getContextPath();
String serverPort = appConfig.getServerPort();
baseUrlStatic = baseUrl;
contextPathStatic = contextPath;
serverPortStatic = serverPort;
String url = baseUrl + ":" + getStaticPort() + contextPath;
if (webBrowser != null
&& Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
webBrowser.initWebUI(url);
} else {
String browserOpenEnv = env.getProperty("BROWSER_OPEN");
boolean browserOpen = browserOpenEnv != null && "true".equalsIgnoreCase(browserOpenEnv);
if (browserOpen) {
try {
String os = System.getProperty("os.name").toLowerCase();
Runtime rt = Runtime.getRuntime();
if (os.contains("win")) {
// For Windows
SystemCommand.runCommand(rt, "rundll32 url.dll,FileProtocolHandler " + url);
} else if (os.contains("mac")) {
SystemCommand.runCommand(rt, "open " + url);
} else if (os.contains("nix") || os.contains("nux")) {
SystemCommand.runCommand(rt, "xdg-open " + url);
}
} catch (IOException e) {
log.error("Error opening browser: {}", e.getMessage());
}
}
}
log.info("Running configs {}", applicationProperties.toString());
}
public static void setServerPortStatic(String port) {
if ("auto".equalsIgnoreCase(port)) {
// Use Spring Boot's automatic port assignment (server.port=0)
SPDFApplication.serverPortStatic =
"0"; // This will let Spring Boot assign an available port
} else {
SPDFApplication.serverPortStatic = port;
}
}
@PreDestroy
public void cleanup() {
if (webBrowser != null) {
webBrowser.cleanup();
}
}
private static void printStartupLogs() {
log.info("Stirling-PDF Started.");
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
log.info("Navigate to {}", url);
}
private static String[] getActiveProfile(String[] args) {
if (args == null) {
return new String[] {"default"};
}
for (String arg : args) {
if (arg.contains("spring.profiles.active")) {
return arg.substring(args[0].indexOf('=') + 1).split(", ");
}
}
return new String[] {"default"};
}
public static String getStaticBaseUrl() {
return baseUrlStatic;
}
public static String getStaticPort() {
return serverPortStatic;
}
public static String getStaticContextPath() {
return contextPathStatic;
}
}
@@ -0,0 +1,7 @@
package stirling.software.SPDF.UI;
public interface WebBrowser {
void initWebUI(String url);
void cleanup();
}
@@ -0,0 +1,497 @@
package stirling.software.SPDF.UI.impl;
import java.awt.AWTException;
import java.awt.BorderLayout;
import java.awt.Frame;
import java.awt.Image;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.event.WindowEvent;
import java.awt.event.WindowStateListener;
import java.io.File;
import java.io.InputStream;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import javax.imageio.ImageIO;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import org.cef.CefApp;
import org.cef.CefClient;
import org.cef.CefSettings;
import org.cef.browser.CefBrowser;
import org.cef.callback.CefBeforeDownloadCallback;
import org.cef.callback.CefDownloadItem;
import org.cef.callback.CefDownloadItemCallback;
import org.cef.handler.CefDownloadHandlerAdapter;
import org.cef.handler.CefLoadHandlerAdapter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import me.friwi.jcefmaven.CefAppBuilder;
import me.friwi.jcefmaven.EnumProgress;
import me.friwi.jcefmaven.MavenCefAppHandlerAdapter;
import me.friwi.jcefmaven.impl.progress.ConsoleProgressHandler;
import stirling.software.SPDF.UI.WebBrowser;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.util.UIScaling;
@Component
@Slf4j
@ConditionalOnProperty(
name = "STIRLING_PDF_DESKTOP_UI",
havingValue = "true",
matchIfMissing = false)
public class DesktopBrowser implements WebBrowser {
private static CefApp cefApp;
private static CefClient client;
private static CefBrowser browser;
private static JFrame frame;
private static LoadingWindow loadingWindow;
private static volatile boolean browserInitialized = false;
private static TrayIcon trayIcon;
private static SystemTray systemTray;
public DesktopBrowser() {
SwingUtilities.invokeLater(
() -> {
loadingWindow = new LoadingWindow(null, "Initializing...");
loadingWindow.setVisible(true);
});
}
public void initWebUI(String url) {
CompletableFuture.runAsync(
() -> {
try {
CefAppBuilder builder = new CefAppBuilder();
configureCefSettings(builder);
builder.setProgressHandler(createProgressHandler());
builder.setInstallDir(
new File(InstallationPathConfig.getClientWebUIPath()));
// Build and initialize CEF
cefApp = builder.build();
client = cefApp.createClient();
// Set up download handler
setupDownloadHandler();
// Create browser and frame on EDT
SwingUtilities.invokeAndWait(
() -> {
browser = client.createBrowser(url, false, false);
setupMainFrame();
setupLoadHandler();
// Force initialize UI after 7 seconds if not already done
Timer timeoutTimer =
new Timer(
2500,
e -> {
log.warn(
"Loading timeout reached. Forcing"
+ " UI transition.");
if (!browserInitialized) {
// Force UI initialization
forceInitializeUI();
}
});
timeoutTimer.setRepeats(false);
timeoutTimer.start();
});
} catch (Exception e) {
log.error("Error initializing JCEF browser: ", e);
cleanup();
}
});
}
private void configureCefSettings(CefAppBuilder builder) {
CefSettings settings = builder.getCefSettings();
String basePath = InstallationPathConfig.getClientWebUIPath();
log.info("basePath " + basePath);
settings.cache_path = new File(basePath + "cache").getAbsolutePath();
settings.root_cache_path = new File(basePath + "root_cache").getAbsolutePath();
// settings.browser_subprocess_path = new File(basePath +
// "subprocess").getAbsolutePath();
// settings.resources_dir_path = new File(basePath + "resources").getAbsolutePath();
// settings.locales_dir_path = new File(basePath + "locales").getAbsolutePath();
settings.log_file = new File(basePath, "debug.log").getAbsolutePath();
settings.persist_session_cookies = true;
settings.windowless_rendering_enabled = false;
settings.log_severity = CefSettings.LogSeverity.LOGSEVERITY_INFO;
builder.setAppHandler(
new MavenCefAppHandlerAdapter() {
@Override
public void stateHasChanged(org.cef.CefApp.CefAppState state) {
log.info("CEF state changed: " + state);
if (state == CefApp.CefAppState.TERMINATED) {
System.exit(0);
}
}
});
}
private void setupDownloadHandler() {
client.addDownloadHandler(
new CefDownloadHandlerAdapter() {
@Override
public boolean onBeforeDownload(
CefBrowser browser,
CefDownloadItem downloadItem,
String suggestedName,
CefBeforeDownloadCallback callback) {
callback.Continue("", true);
return true;
}
@Override
public void onDownloadUpdated(
CefBrowser browser,
CefDownloadItem downloadItem,
CefDownloadItemCallback callback) {
if (downloadItem.isComplete()) {
log.info("Download completed: " + downloadItem.getFullPath());
} else if (downloadItem.isCanceled()) {
log.info("Download canceled: " + downloadItem.getFullPath());
}
}
});
}
private ConsoleProgressHandler createProgressHandler() {
return new ConsoleProgressHandler() {
@Override
public void handleProgress(EnumProgress state, float percent) {
Objects.requireNonNull(state, "state cannot be null");
SwingUtilities.invokeLater(
() -> {
if (loadingWindow != null) {
switch (state) {
case LOCATING:
loadingWindow.setStatus("Locating Files...");
loadingWindow.setProgress(0);
break;
case DOWNLOADING:
if (percent >= 0) {
loadingWindow.setStatus(
String.format(
"Downloading additional files: %.0f%%",
percent));
loadingWindow.setProgress((int) percent);
}
break;
case EXTRACTING:
loadingWindow.setStatus("Extracting files...");
loadingWindow.setProgress(60);
break;
case INITIALIZING:
loadingWindow.setStatus("Initializing UI...");
loadingWindow.setProgress(80);
break;
case INITIALIZED:
loadingWindow.setStatus("Finalising startup...");
loadingWindow.setProgress(90);
break;
}
}
});
}
};
}
private void setupMainFrame() {
frame = new JFrame("Stirling-PDF");
frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
frame.setUndecorated(true);
frame.setOpacity(0.0f);
JPanel contentPane = new JPanel(new BorderLayout());
contentPane.setDoubleBuffered(true);
contentPane.add(browser.getUIComponent(), BorderLayout.CENTER);
frame.setContentPane(contentPane);
frame.addWindowListener(
new java.awt.event.WindowAdapter() {
@Override
public void windowClosing(java.awt.event.WindowEvent windowEvent) {
cleanup();
System.exit(0);
}
});
frame.setSize(UIScaling.scaleWidth(1280), UIScaling.scaleHeight(800));
frame.setLocationRelativeTo(null);
loadIcon();
}
private void setupLoadHandler() {
final long initStartTime = System.currentTimeMillis();
log.info("Setting up load handler at: {}", initStartTime);
client.addLoadHandler(
new CefLoadHandlerAdapter() {
@Override
public void onLoadingStateChange(
CefBrowser browser,
boolean isLoading,
boolean canGoBack,
boolean canGoForward) {
log.debug(
"Loading state change - isLoading: {}, canGoBack: {}, canGoForward:"
+ " {}, browserInitialized: {}, Time elapsed: {}ms",
isLoading,
canGoBack,
canGoForward,
browserInitialized,
System.currentTimeMillis() - initStartTime);
if (!isLoading && !browserInitialized) {
log.info(
"Browser finished loading, preparing to initialize UI"
+ " components");
browserInitialized = true;
SwingUtilities.invokeLater(
() -> {
try {
if (loadingWindow != null) {
log.info("Starting UI initialization sequence");
// Close loading window first
loadingWindow.setVisible(false);
loadingWindow.dispose();
loadingWindow = null;
log.info("Loading window disposed");
// Then setup the main frame
frame.setVisible(false);
frame.dispose();
frame.setOpacity(1.0f);
frame.setUndecorated(false);
frame.pack();
frame.setSize(
UIScaling.scaleWidth(1280),
UIScaling.scaleHeight(800));
frame.setLocationRelativeTo(null);
log.debug("Frame reconfigured");
// Show the main frame
frame.setVisible(true);
frame.requestFocus();
frame.toFront();
log.info("Main frame displayed and focused");
// Focus the browser component
Timer focusTimer =
new Timer(
100,
e -> {
try {
browser.getUIComponent()
.requestFocus();
log.info(
"Browser component"
+ " focused");
} catch (Exception ex) {
log.error(
"Error focusing"
+ " browser",
ex);
}
});
focusTimer.setRepeats(false);
focusTimer.start();
}
} catch (Exception e) {
log.error("Error during UI initialization", e);
// Attempt cleanup on error
if (loadingWindow != null) {
loadingWindow.dispose();
loadingWindow = null;
}
if (frame != null) {
frame.setVisible(true);
frame.requestFocus();
}
}
});
}
}
});
}
private void setupTrayIcon(Image icon) {
if (!SystemTray.isSupported()) {
log.warn("System tray is not supported");
return;
}
try {
systemTray = SystemTray.getSystemTray();
// Create popup menu
PopupMenu popup = new PopupMenu();
// Create menu items
MenuItem showItem = new MenuItem("Show");
showItem.addActionListener(
e -> {
frame.setVisible(true);
frame.setState(Frame.NORMAL);
});
MenuItem exitItem = new MenuItem("Exit");
exitItem.addActionListener(
e -> {
cleanup();
System.exit(0);
});
// Add menu items to popup menu
popup.add(showItem);
popup.addSeparator();
popup.add(exitItem);
// Create tray icon
trayIcon = new TrayIcon(icon, "Stirling-PDF", popup);
trayIcon.setImageAutoSize(true);
// Add double-click behavior
trayIcon.addActionListener(
e -> {
frame.setVisible(true);
frame.setState(Frame.NORMAL);
});
// Add tray icon to system tray
systemTray.add(trayIcon);
// Modify frame behavior to minimize to tray
frame.addWindowStateListener(
new WindowStateListener() {
public void windowStateChanged(WindowEvent e) {
if (e.getNewState() == Frame.ICONIFIED) {
frame.setVisible(false);
}
}
});
} catch (AWTException e) {
log.error("Error setting up system tray icon", e);
}
}
private void loadIcon() {
try {
Image icon = null;
String[] iconPaths = {"/static/favicon.ico"};
for (String path : iconPaths) {
if (icon != null) break;
try {
try (InputStream is = getClass().getResourceAsStream(path)) {
if (is != null) {
icon = ImageIO.read(is);
break;
}
}
} catch (Exception e) {
log.debug("Could not load icon from " + path, e);
}
}
if (icon != null) {
frame.setIconImage(icon);
setupTrayIcon(icon);
} else {
log.warn("Could not load icon from any source");
}
} catch (Exception e) {
log.error("Error loading icon", e);
}
}
@PreDestroy
public void cleanup() {
if (browser != null) browser.close(true);
if (client != null) client.dispose();
if (cefApp != null) cefApp.dispose();
if (loadingWindow != null) loadingWindow.dispose();
}
public static void forceInitializeUI() {
try {
if (loadingWindow != null) {
log.info("Forcing start of UI initialization sequence");
// Close loading window first
loadingWindow.setVisible(false);
loadingWindow.dispose();
loadingWindow = null;
log.info("Loading window disposed");
// Then setup the main frame
frame.setVisible(false);
frame.dispose();
frame.setOpacity(1.0f);
frame.setUndecorated(false);
frame.pack();
frame.setSize(UIScaling.scaleWidth(1280), UIScaling.scaleHeight(800));
frame.setLocationRelativeTo(null);
log.debug("Frame reconfigured");
// Show the main frame
frame.setVisible(true);
frame.requestFocus();
frame.toFront();
log.info("Main frame displayed and focused");
// Focus the browser component if available
if (browser != null) {
Timer focusTimer =
new Timer(
100,
e -> {
try {
browser.getUIComponent().requestFocus();
log.info("Browser component focused");
} catch (Exception ex) {
log.error(
"Error focusing browser during force ui"
+ " initialization.",
ex);
}
});
focusTimer.setRepeats(false);
focusTimer.start();
}
}
} catch (Exception e) {
log.error("Error during Forced UI initialization.", e);
// Attempt cleanup on error
if (loadingWindow != null) {
loadingWindow.dispose();
loadingWindow = null;
}
if (frame != null) {
frame.setVisible(true);
frame.setOpacity(1.0f);
frame.setUndecorated(false);
frame.requestFocus();
}
}
}
}
@@ -0,0 +1,351 @@
package stirling.software.SPDF.UI.impl;
import java.awt.*;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.imageio.ImageIO;
import javax.swing.*;
import io.github.pixee.security.BoundedLineReader;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.UIScaling;
@Slf4j
public class LoadingWindow extends JDialog {
private final JProgressBar progressBar;
private final JLabel statusLabel;
private final JPanel mainPanel;
private final JLabel brandLabel;
private long startTime;
private Timer stuckTimer;
private long stuckThreshold = 4000;
private long timeAt90Percent = -1;
private volatile Process explorerProcess;
private static final boolean IS_WINDOWS =
System.getProperty("os.name").toLowerCase().contains("win");
public LoadingWindow(Frame parent, String initialUrl) {
super(parent, "Initializing Stirling-PDF", true);
startTime = System.currentTimeMillis();
log.info("Creating LoadingWindow - initialization started at: {}", startTime);
// Initialize components
mainPanel = new JPanel();
mainPanel.setBackground(Color.WHITE);
mainPanel.setBorder(BorderFactory.createEmptyBorder(20, 30, 20, 30));
mainPanel.setLayout(new GridBagLayout());
GridBagConstraints gbc = new GridBagConstraints();
// Configure GridBagConstraints
gbc.gridwidth = GridBagConstraints.REMAINDER;
gbc.fill = GridBagConstraints.HORIZONTAL;
gbc.insets = new Insets(5, 5, 5, 5);
gbc.weightx = 1.0;
gbc.weighty = 0.0;
// Add icon
try {
try (InputStream is = getClass().getResourceAsStream("/static/favicon.ico")) {
if (is != null) {
Image img = ImageIO.read(is);
if (img != null) {
Image scaledImg = UIScaling.scaleIcon(img, 48, 48);
JLabel iconLabel = new JLabel(new ImageIcon(scaledImg));
iconLabel.setHorizontalAlignment(SwingConstants.CENTER);
gbc.gridy = 0;
mainPanel.add(iconLabel, gbc);
log.info("Icon loaded and scaled successfully");
}
}
}
} catch (Exception e) {
log.error("Failed to load icon", e);
}
// URL Label with explicit size
brandLabel = new JLabel(initialUrl);
brandLabel.setHorizontalAlignment(SwingConstants.CENTER);
brandLabel.setPreferredSize(new Dimension(300, 25));
brandLabel.setText("Stirling-PDF");
gbc.gridy = 1;
mainPanel.add(brandLabel, gbc);
// Status label with explicit size
statusLabel = new JLabel("Initializing...");
statusLabel.setHorizontalAlignment(SwingConstants.CENTER);
statusLabel.setPreferredSize(new Dimension(300, 25));
gbc.gridy = 2;
mainPanel.add(statusLabel, gbc);
// Progress bar with explicit size
progressBar = new JProgressBar(0, 100);
progressBar.setStringPainted(true);
progressBar.setPreferredSize(new Dimension(300, 25));
gbc.gridy = 3;
mainPanel.add(progressBar, gbc);
// Set dialog properties
setContentPane(mainPanel);
setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
setResizable(false);
setUndecorated(false);
// Set size and position
setSize(UIScaling.scaleWidth(400), UIScaling.scaleHeight(200));
setLocationRelativeTo(parent);
setAlwaysOnTop(true);
setProgress(0);
setStatus("Starting...");
log.info(
"LoadingWindow initialization completed in {}ms",
System.currentTimeMillis() - startTime);
}
private void checkAndRefreshExplorer() {
if (!IS_WINDOWS) {
return;
}
if (timeAt90Percent == -1) {
timeAt90Percent = System.currentTimeMillis();
stuckTimer =
new Timer(
1000,
e -> {
long currentTime = System.currentTimeMillis();
if (currentTime - timeAt90Percent > stuckThreshold) {
try {
log.debug(
"Attempting Windows explorer refresh due to 90% stuck state");
String currentDir = System.getProperty("user.dir");
// Store current explorer PIDs before we start new one
Set<String> existingPids = new HashSet<>();
ProcessBuilder listExplorer =
new ProcessBuilder(
"cmd",
"/c",
"wmic",
"process",
"where",
"name='explorer.exe'",
"get",
"ProcessId",
"/format:csv");
Process process = listExplorer.start();
BufferedReader reader =
new BufferedReader(
new InputStreamReader(
process.getInputStream()));
String line;
while ((line =
BoundedLineReader.readLine(
reader, 5_000_000))
!= null) {
if (line.matches(".*\\d+.*")) { // Contains numbers
String[] parts = line.trim().split(",");
if (parts.length >= 2) {
existingPids.add(
parts[parts.length - 1].trim());
}
}
}
process.waitFor(2, TimeUnit.SECONDS);
// Start new explorer
ProcessBuilder pb =
new ProcessBuilder(
"cmd",
"/c",
"start",
"/min",
"/b",
"explorer.exe",
currentDir);
pb.redirectErrorStream(true);
explorerProcess = pb.start();
// Schedule cleanup
Timer cleanupTimer =
new Timer(
2000,
cleanup -> {
try {
// Find new explorer processes
ProcessBuilder findNewExplorer =
new ProcessBuilder(
"cmd",
"/c",
"wmic",
"process",
"where",
"name='explorer.exe'",
"get",
"ProcessId",
"/format:csv");
Process newProcess =
findNewExplorer.start();
BufferedReader newReader =
new BufferedReader(
new InputStreamReader(
newProcess
.getInputStream()));
String newLine;
while ((newLine =
BoundedLineReader
.readLine(
newReader,
5_000_000))
!= null) {
if (newLine.matches(
".*\\d+.*")) {
String[] parts =
newLine.trim()
.split(",");
if (parts.length >= 2) {
String pid =
parts[
parts.length
- 1]
.trim();
if (!existingPids
.contains(
pid)) {
log.debug(
"Found new explorer.exe with PID: "
+ pid);
ProcessBuilder
killProcess =
new ProcessBuilder(
"taskkill",
"/PID",
pid,
"/F");
killProcess
.redirectErrorStream(
true);
Process killResult =
killProcess
.start();
killResult.waitFor(
2,
TimeUnit
.SECONDS);
log.debug(
"Explorer process terminated: "
+ pid);
}
}
}
}
newProcess.waitFor(
2, TimeUnit.SECONDS);
} catch (Exception ex) {
log.error(
"Error cleaning up Windows explorer process",
ex);
}
});
cleanupTimer.setRepeats(false);
cleanupTimer.start();
stuckTimer.stop();
} catch (Exception ex) {
log.error("Error refreshing Windows explorer", ex);
}
}
});
stuckTimer.setRepeats(true);
stuckTimer.start();
}
}
public void setProgress(final int progress) {
SwingUtilities.invokeLater(
() -> {
try {
int validProgress = Math.min(Math.max(progress, 0), 100);
log.info(
"Setting progress to {}% at {}ms since start",
validProgress, System.currentTimeMillis() - startTime);
// Log additional details when near 90%
if (validProgress >= 85 && validProgress <= 95) {
log.info(
"Near 90% progress - Current status: {}, Window visible: {}, "
+ "Progress bar responding: {}, Memory usage: {}MB",
statusLabel.getText(),
isVisible(),
progressBar.isEnabled(),
Runtime.getRuntime().totalMemory() / (1024 * 1024));
// Add thread state logging
Thread currentThread = Thread.currentThread();
log.info(
"Current thread state - Name: {}, State: {}, Priority: {}",
currentThread.getName(),
currentThread.getState(),
currentThread.getPriority());
if (validProgress >= 90 && validProgress < 95) {
checkAndRefreshExplorer();
} else {
// Reset the timer if we move past 95%
if (validProgress >= 95) {
if (stuckTimer != null) {
stuckTimer.stop();
}
timeAt90Percent = -1;
}
}
}
progressBar.setValue(validProgress);
progressBar.setString(validProgress + "%");
mainPanel.revalidate();
mainPanel.repaint();
} catch (Exception e) {
log.error("Error updating progress to " + progress, e);
}
});
}
public void setStatus(final String status) {
log.info(
"Status update at {}ms - Setting status to: {}",
System.currentTimeMillis() - startTime,
status);
SwingUtilities.invokeLater(
() -> {
try {
String validStatus = status != null ? status : "";
statusLabel.setText(validStatus);
// Log UI state when status changes
log.info(
"UI State - Window visible: {}, Progress: {}%, Status: {}",
isVisible(), progressBar.getValue(), validStatus);
mainPanel.revalidate();
mainPanel.repaint();
} catch (Exception e) {
log.error("Error updating status to: " + status, e);
}
});
}
@Override
public void dispose() {
log.info("LoadingWindow disposing after {}ms", System.currentTimeMillis() - startTime);
super.dispose();
}
}
@@ -0,0 +1,32 @@
package stirling.software.SPDF.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
import stirling.software.common.model.ApplicationProperties;
@Configuration
class AppUpdateService {
private final ApplicationProperties applicationProperties;
private final ShowAdminInterface showAdmin;
public AppUpdateService(
ApplicationProperties applicationProperties,
@Autowired(required = false) ShowAdminInterface showAdmin) {
this.applicationProperties = applicationProperties;
this.showAdmin = showAdmin;
}
@Bean(name = "shouldShow")
@Scope("request")
public boolean shouldShow() {
boolean showUpdate = applicationProperties.getSystem().isShowUpdate();
boolean showAdminResult = (showAdmin != null) ? showAdmin.getShowUpdateOnlyAdmins() : true;
return showUpdate && showAdminResult;
}
}
@@ -0,0 +1,83 @@
package stirling.software.SPDF.config;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class CleanUrlInterceptor implements HandlerInterceptor {
private static final List<String> ALLOWED_PARAMS =
Arrays.asList(
"lang",
"endpoint",
"endpoints",
"logout",
"error",
"errorOAuth",
"file",
"messageType",
"infoMessage");
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String queryString = request.getQueryString();
if (queryString != null && !queryString.isEmpty()) {
String requestURI = request.getRequestURI();
Map<String, String> allowedParameters = new HashMap<>();
// Keep only the allowed parameters
String[] queryParameters = queryString.split("&");
for (String param : queryParameters) {
String[] keyValuePair = param.split("=");
if (keyValuePair.length != 2) {
continue;
}
if (ALLOWED_PARAMS.contains(keyValuePair[0])) {
allowedParameters.put(keyValuePair[0], keyValuePair[1]);
}
}
// If there are any parameters that are not allowed
if (allowedParameters.size() != queryParameters.length) {
// Construct new query string
StringBuilder newQueryString = new StringBuilder();
for (Map.Entry<String, String> entry : allowedParameters.entrySet()) {
if (newQueryString.length() > 0) {
newQueryString.append("&");
}
newQueryString.append(entry.getKey()).append("=").append(entry.getValue());
}
// Redirect to the URL with only allowed query parameters
String redirectUrl = requestURI + "?" + newQueryString;
response.sendRedirect(request.getContextPath() + redirectUrl);
return false;
}
}
return true;
}
@Override
public void postHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) {}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {}
}
@@ -0,0 +1,303 @@
package stirling.software.SPDF.config;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Service
@Slf4j
public class EndpointConfiguration {
private static final String REMOVE_BLANKS = "remove-blanks";
private final ApplicationProperties applicationProperties;
private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
private final boolean runningProOrHigher;
public EndpointConfiguration(
ApplicationProperties applicationProperties,
@Qualifier("runningProOrHigher") boolean runningProOrHigher) {
this.applicationProperties = applicationProperties;
this.runningProOrHigher = runningProOrHigher;
init();
processEnvironmentConfigs();
}
public void enableEndpoint(String endpoint) {
endpointStatuses.put(endpoint, true);
}
public void disableEndpoint(String endpoint) {
if (!endpointStatuses.containsKey(endpoint) || endpointStatuses.get(endpoint) != false) {
log.debug("Disabling {}", endpoint);
endpointStatuses.put(endpoint, false);
}
}
public Map<String, Boolean> getEndpointStatuses() {
return endpointStatuses;
}
public boolean isEndpointEnabled(String endpoint) {
if (endpoint.startsWith("/")) {
endpoint = endpoint.substring(1);
}
return endpointStatuses.getOrDefault(endpoint, true);
}
public boolean isGroupEnabled(String group) {
Set<String> endpoints = endpointGroups.get(group);
if (endpoints == null || endpoints.isEmpty()) {
log.debug("Group '{}' does not exist or has no endpoints", group);
return false;
}
for (String endpoint : endpoints) {
if (!isEndpointEnabled(endpoint)) {
return false;
}
}
return true;
}
public void addEndpointToGroup(String group, String endpoint) {
endpointGroups.computeIfAbsent(group, k -> new HashSet<>()).add(endpoint);
}
public void enableGroup(String group) {
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
for (String endpoint : endpoints) {
enableEndpoint(endpoint);
}
}
}
public void disableGroup(String group) {
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
for (String endpoint : endpoints) {
disableEndpoint(endpoint);
}
}
}
public void logDisabledEndpointsSummary() {
List<String> disabledList =
endpointStatuses.entrySet().stream()
.filter(entry -> !entry.getValue()) // only get disabled endpoints (value
// is false)
.map(Map.Entry::getKey)
.sorted()
.toList();
if (!disabledList.isEmpty()) {
log.info(
"Total disabled endpoints: {}. Disabled endpoints: {}",
disabledList.size(),
String.join(", ", disabledList));
}
}
public void init() {
// Adding endpoints to "PageOps" group
addEndpointToGroup("PageOps", "remove-pages");
addEndpointToGroup("PageOps", "merge-pdfs");
addEndpointToGroup("PageOps", "split-pdfs");
addEndpointToGroup("PageOps", "pdf-organizer");
addEndpointToGroup("PageOps", "rotate-pdf");
addEndpointToGroup("PageOps", "multi-page-layout");
addEndpointToGroup("PageOps", "scale-pages");
addEndpointToGroup("PageOps", "adjust-contrast");
addEndpointToGroup("PageOps", "crop");
addEndpointToGroup("PageOps", "auto-split-pdf");
addEndpointToGroup("PageOps", "extract-page");
addEndpointToGroup("PageOps", "pdf-to-single-page");
addEndpointToGroup("PageOps", "split-by-size-or-count");
addEndpointToGroup("PageOps", "overlay-pdf");
addEndpointToGroup("PageOps", "split-pdf-by-sections");
// Adding endpoints to "Convert" group
addEndpointToGroup("Convert", "pdf-to-img");
addEndpointToGroup("Convert", "img-to-pdf");
addEndpointToGroup("Convert", "pdf-to-pdfa");
addEndpointToGroup("Convert", "file-to-pdf");
addEndpointToGroup("Convert", "pdf-to-word");
addEndpointToGroup("Convert", "pdf-to-presentation");
addEndpointToGroup("Convert", "pdf-to-text");
addEndpointToGroup("Convert", "pdf-to-html");
addEndpointToGroup("Convert", "pdf-to-xml");
addEndpointToGroup("Convert", "html-to-pdf");
addEndpointToGroup("Convert", "url-to-pdf");
addEndpointToGroup("Convert", "markdown-to-pdf");
addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown");
// Adding endpoints to "Security" group
addEndpointToGroup("Security", "add-password");
addEndpointToGroup("Security", "remove-password");
addEndpointToGroup("Security", "change-permissions");
addEndpointToGroup("Security", "add-watermark");
addEndpointToGroup("Security", "cert-sign");
addEndpointToGroup("Security", "remove-cert-sign");
addEndpointToGroup("Security", "sanitize-pdf");
addEndpointToGroup("Security", "auto-redact");
addEndpointToGroup("Security", "redact");
// Adding endpoints to "Other" group
addEndpointToGroup("Other", "ocr-pdf");
addEndpointToGroup("Other", "add-image");
addEndpointToGroup("Other", "compress-pdf");
addEndpointToGroup("Other", "extract-images");
addEndpointToGroup("Other", "change-metadata");
addEndpointToGroup("Other", "extract-image-scans");
addEndpointToGroup("Other", "sign");
addEndpointToGroup("Other", "flatten");
addEndpointToGroup("Other", "repair");
addEndpointToGroup("Other", "unlock-pdf-forms");
addEndpointToGroup("Other", REMOVE_BLANKS);
addEndpointToGroup("Other", "remove-annotations");
addEndpointToGroup("Other", "compare");
addEndpointToGroup("Other", "add-page-numbers");
addEndpointToGroup("Other", "auto-rename");
addEndpointToGroup("Other", "get-info-on-pdf");
addEndpointToGroup("Other", "show-javascript");
addEndpointToGroup("Other", "remove-image-pdf");
// CLI
addEndpointToGroup("CLI", "compress-pdf");
addEndpointToGroup("CLI", "extract-image-scans");
addEndpointToGroup("CLI", "repair");
addEndpointToGroup("CLI", "pdf-to-pdfa");
addEndpointToGroup("CLI", "file-to-pdf");
addEndpointToGroup("CLI", "pdf-to-word");
addEndpointToGroup("CLI", "pdf-to-presentation");
addEndpointToGroup("CLI", "pdf-to-html");
addEndpointToGroup("CLI", "pdf-to-xml");
addEndpointToGroup("CLI", "ocr-pdf");
addEndpointToGroup("CLI", "html-to-pdf");
addEndpointToGroup("CLI", "url-to-pdf");
addEndpointToGroup("CLI", "pdf-to-rtf");
// python
addEndpointToGroup("Python", "extract-image-scans");
addEndpointToGroup("Python", "html-to-pdf");
addEndpointToGroup("Python", "url-to-pdf");
addEndpointToGroup("Python", "file-to-pdf");
// openCV
addEndpointToGroup("OpenCV", "extract-image-scans");
// LibreOffice
addEndpointToGroup("LibreOffice", "file-to-pdf");
addEndpointToGroup("LibreOffice", "pdf-to-word");
addEndpointToGroup("LibreOffice", "pdf-to-presentation");
addEndpointToGroup("LibreOffice", "pdf-to-rtf");
addEndpointToGroup("LibreOffice", "pdf-to-html");
addEndpointToGroup("LibreOffice", "pdf-to-xml");
addEndpointToGroup("LibreOffice", "pdf-to-pdfa");
// Unoconvert
addEndpointToGroup("Unoconvert", "file-to-pdf");
addEndpointToGroup("tesseract", "ocr-pdf");
// Java
addEndpointToGroup("Java", "merge-pdfs");
addEndpointToGroup("Java", "remove-pages");
addEndpointToGroup("Java", "split-pdfs");
addEndpointToGroup("Java", "pdf-organizer");
addEndpointToGroup("Java", "rotate-pdf");
addEndpointToGroup("Java", "pdf-to-img");
addEndpointToGroup("Java", "img-to-pdf");
addEndpointToGroup("Java", "add-password");
addEndpointToGroup("Java", "remove-password");
addEndpointToGroup("Java", "change-permissions");
addEndpointToGroup("Java", "add-watermark");
addEndpointToGroup("Java", "add-image");
addEndpointToGroup("Java", "extract-images");
addEndpointToGroup("Java", "change-metadata");
addEndpointToGroup("Java", "cert-sign");
addEndpointToGroup("Java", "remove-cert-sign");
addEndpointToGroup("Java", "multi-page-layout");
addEndpointToGroup("Java", "scale-pages");
addEndpointToGroup("Java", "add-page-numbers");
addEndpointToGroup("Java", "auto-rename");
addEndpointToGroup("Java", "auto-split-pdf");
addEndpointToGroup("Java", "sanitize-pdf");
addEndpointToGroup("Java", "crop");
addEndpointToGroup("Java", "get-info-on-pdf");
addEndpointToGroup("Java", "extract-page");
addEndpointToGroup("Java", "pdf-to-single-page");
addEndpointToGroup("Java", "markdown-to-pdf");
addEndpointToGroup("Java", "show-javascript");
addEndpointToGroup("Java", "auto-redact");
addEndpointToGroup("Java", "redact");
addEndpointToGroup("Java", "pdf-to-csv");
addEndpointToGroup("Java", "split-by-size-or-count");
addEndpointToGroup("Java", "overlay-pdf");
addEndpointToGroup("Java", "split-pdf-by-sections");
addEndpointToGroup("Java", REMOVE_BLANKS);
addEndpointToGroup("Java", "pdf-to-text");
addEndpointToGroup("Java", "remove-image-pdf");
addEndpointToGroup("Java", "pdf-to-markdown");
// Javascript
addEndpointToGroup("Javascript", "pdf-organizer");
addEndpointToGroup("Javascript", "sign");
addEndpointToGroup("Javascript", "compare");
addEndpointToGroup("Javascript", "adjust-contrast");
// qpdf dependent endpoints
addEndpointToGroup("qpdf", "repair");
// Weasyprint dependent endpoints
addEndpointToGroup("Weasyprint", "html-to-pdf");
addEndpointToGroup("Weasyprint", "url-to-pdf");
addEndpointToGroup("Weasyprint", "markdown-to-pdf");
// Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html");
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
}
private void processEnvironmentConfigs() {
if (applicationProperties != null && applicationProperties.getEndpoints() != null) {
List<String> endpointsToRemove = applicationProperties.getEndpoints().getToRemove();
List<String> groupsToRemove = applicationProperties.getEndpoints().getGroupsToRemove();
if (endpointsToRemove != null) {
for (String endpoint : endpointsToRemove) {
disableEndpoint(endpoint.trim());
}
}
if (groupsToRemove != null) {
for (String group : groupsToRemove) {
disableGroup(group.trim());
}
}
}
if (!runningProOrHigher) {
disableGroup("enterprise");
}
if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
disableEndpoint("url-to-pdf");
}
}
public Set<String> getEndpointsForGroup(String group) {
return endpointGroups.getOrDefault(group, new HashSet<>());
}
}
@@ -0,0 +1,212 @@
package stirling.software.SPDF.config;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class EndpointInspector implements ApplicationListener<ContextRefreshedEvent> {
private static final Logger logger = LoggerFactory.getLogger(EndpointInspector.class);
private final ApplicationContext applicationContext;
private final Set<String> validGetEndpoints = new HashSet<>();
private boolean endpointsDiscovered = false;
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
if (!endpointsDiscovered) {
discoverEndpoints();
endpointsDiscovered = true;
}
}
private void discoverEndpoints() {
try {
Map<String, RequestMappingHandlerMapping> mappings =
applicationContext.getBeansOfType(RequestMappingHandlerMapping.class);
for (Map.Entry<String, RequestMappingHandlerMapping> entry : mappings.entrySet()) {
RequestMappingHandlerMapping mapping = entry.getValue();
Map<RequestMappingInfo, HandlerMethod> handlerMethods = mapping.getHandlerMethods();
for (Map.Entry<RequestMappingInfo, HandlerMethod> handlerEntry :
handlerMethods.entrySet()) {
RequestMappingInfo mappingInfo = handlerEntry.getKey();
HandlerMethod handlerMethod = handlerEntry.getValue();
boolean isGetHandler = false;
try {
Set<RequestMethod> methods = mappingInfo.getMethodsCondition().getMethods();
isGetHandler = methods.isEmpty() || methods.contains(RequestMethod.GET);
} catch (Exception e) {
isGetHandler = true;
}
if (isGetHandler) {
Set<String> patterns = extractPatternsUsingDirectPaths(mappingInfo);
if (patterns.isEmpty()) {
patterns = extractPatternsFromString(mappingInfo);
}
validGetEndpoints.addAll(patterns);
}
}
}
if (validGetEndpoints.isEmpty()) {
logger.warn("No endpoints discovered. Adding common endpoints as fallback.");
validGetEndpoints.add("/");
validGetEndpoints.add("/api/**");
validGetEndpoints.add("/**");
}
} catch (Exception e) {
logger.error("Error discovering endpoints", e);
}
}
private Set<String> extractPatternsUsingDirectPaths(RequestMappingInfo mappingInfo) {
Set<String> patterns = new HashSet<>();
try {
Method getDirectPathsMethod = mappingInfo.getClass().getMethod("getDirectPaths");
Object result = getDirectPathsMethod.invoke(mappingInfo);
if (result instanceof Set) {
@SuppressWarnings("unchecked")
Set<String> resultSet = (Set<String>) result;
patterns.addAll(resultSet);
}
} catch (Exception e) {
// Return empty set if method not found or fails
}
return patterns;
}
private Set<String> extractPatternsFromString(RequestMappingInfo mappingInfo) {
Set<String> patterns = new HashSet<>();
try {
String infoString = mappingInfo.toString();
if (infoString.contains("{")) {
String patternsSection =
infoString.substring(infoString.indexOf("{") + 1, infoString.indexOf("}"));
for (String pattern : patternsSection.split(",")) {
pattern = pattern.trim();
if (!pattern.isEmpty()) {
patterns.add(pattern);
}
}
}
} catch (Exception e) {
// Return empty set if parsing fails
}
return patterns;
}
public boolean isValidGetEndpoint(String uri) {
if (!endpointsDiscovered) {
discoverEndpoints();
endpointsDiscovered = true;
}
if (validGetEndpoints.contains(uri)) {
return true;
}
if (matchesWildcardOrPathVariable(uri)) {
return true;
}
if (matchesPathSegments(uri)) {
return true;
}
return false;
}
private boolean matchesWildcardOrPathVariable(String uri) {
for (String pattern : validGetEndpoints) {
if (pattern.contains("*") || pattern.contains("{")) {
int wildcardIndex = pattern.indexOf('*');
int variableIndex = pattern.indexOf('{');
int cutoffIndex;
if (wildcardIndex < 0) {
cutoffIndex = variableIndex;
} else if (variableIndex < 0) {
cutoffIndex = wildcardIndex;
} else {
cutoffIndex = Math.min(wildcardIndex, variableIndex);
}
String staticPrefix = pattern.substring(0, cutoffIndex);
if (uri.startsWith(staticPrefix)) {
return true;
}
}
}
return false;
}
private boolean matchesPathSegments(String uri) {
for (String pattern : validGetEndpoints) {
if (!pattern.contains("*") && !pattern.contains("{")) {
String[] patternSegments = pattern.split("/");
String[] uriSegments = uri.split("/");
if (uriSegments.length < patternSegments.length) {
continue;
}
boolean match = true;
for (int i = 0; i < patternSegments.length; i++) {
if (!patternSegments[i].equals(uriSegments[i])) {
match = false;
break;
}
}
if (match) {
return true;
}
}
}
return false;
}
public Set<String> getValidGetEndpoints() {
if (!endpointsDiscovered) {
discoverEndpoints();
endpointsDiscovered = true;
}
return new HashSet<>(validGetEndpoints);
}
private void logAllEndpoints() {
Set<String> sortedEndpoints = new TreeSet<>(validGetEndpoints);
logger.info("=== BEGIN: All discovered GET endpoints ===");
for (String endpoint : sortedEndpoints) {
logger.info("Endpoint: {}", endpoint);
}
logger.info("=== END: All discovered GET endpoints ===");
}
}
@@ -0,0 +1,52 @@
package stirling.software.SPDF.config;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Component
@Slf4j
@RequiredArgsConstructor
public class EndpointInterceptor implements HandlerInterceptor {
private final EndpointConfiguration endpointConfiguration;
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String requestURI = request.getRequestURI();
boolean isEnabled;
// Extract the specific endpoint name (e.g: /api/v1/general/remove-pages -> remove-pages)
if (requestURI.contains("/api/v1") && requestURI.split("/").length > 4) {
String[] requestURIParts = requestURI.split("/");
String requestEndpoint;
// Endpoint: /api/v1/convert/pdf/img becomes pdf-to-img
if ("convert".equals(requestURIParts[3]) && requestURIParts.length > 5) {
requestEndpoint = requestURIParts[4] + "-to-" + requestURIParts[5];
} else {
requestEndpoint = requestURIParts[4];
}
log.debug("Request endpoint: {}", requestEndpoint);
isEnabled = endpointConfiguration.isEndpointEnabled(requestEndpoint);
log.debug("Is endpoint enabled: {}", isEnabled);
} else {
isEnabled = endpointConfiguration.isEndpointEnabled(requestURI);
}
if (!isEnabled) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
return false;
}
return true;
}
}
@@ -0,0 +1,158 @@
package stirling.software.SPDF.config;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
@Configuration
@Slf4j
public class ExternalAppDepConfig {
private final EndpointConfiguration endpointConfiguration;
private final String weasyprintPath;
private final String unoconvPath;
private final Map<String, List<String>> commandToGroupMapping;
public ExternalAppDepConfig(
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
this.endpointConfiguration = endpointConfiguration;
weasyprintPath = runtimePathConfig.getWeasyPrintPath();
unoconvPath = runtimePathConfig.getUnoConvertPath();
commandToGroupMapping =
new HashMap<>() {
{
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"));
}
};
}
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(feature.split("\\s+"))
.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);
log.warn(
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
command,
group,
affectedFeatures != null && !affectedFeatures.isEmpty()
? String.join(", ", affectedFeatures)
: "unknown");
}
}
}
}
@PostConstruct
public void checkDependencies() {
// Check core dependencies
checkDependencyAndDisableGroup("tesseract");
checkDependencyAndDisableGroup("soffice");
checkDependencyAndDisableGroup("qpdf");
checkDependencyAndDisableGroup(weasyprintPath);
checkDependencyAndDisableGroup("pdftohtml");
checkDependencyAndDisableGroup(unoconvPath);
// 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");
endpointConfiguration.disableGroup("OpenCV");
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");
log.warn(
"OpenCV not available in Python - Disabling OpenCV features: {}",
String.join(", ", openCVFeatures));
}
} catch (Exception e) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
log.warn(
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
e.getMessage(),
String.join(", ", openCVFeatures));
}
}
endpointConfiguration.logDisabledEndpointsSummary();
}
}
@@ -0,0 +1,101 @@
package stirling.software.SPDF.config;
import java.io.IOException;
import java.util.Properties;
import java.util.UUID;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import io.micrometer.common.util.StringUtils;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
@Component
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
@RequiredArgsConstructor
public class InitialSetup {
private final ApplicationProperties applicationProperties;
@PostConstruct
public void init() throws IOException {
initUUIDKey();
initSecretKey();
initEnableCSRFSecurity();
initLegalUrls();
initSetAppVersion();
}
public void initUUIDKey() throws IOException {
String uuid = applicationProperties.getAutomaticallyGenerated().getUUID();
if (!GeneralUtils.isValidUUID(uuid)) {
// Generating a random UUID as the secret key
uuid = UUID.randomUUID().toString();
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.UUID", uuid);
applicationProperties.getAutomaticallyGenerated().setUUID(uuid);
}
}
public void initSecretKey() throws IOException {
String secretKey = applicationProperties.getAutomaticallyGenerated().getKey();
if (!GeneralUtils.isValidUUID(secretKey)) {
// Generating a random UUID as the secret key
secretKey = UUID.randomUUID().toString();
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.key", secretKey);
applicationProperties.getAutomaticallyGenerated().setKey(secretKey);
}
}
public void initEnableCSRFSecurity() throws IOException {
if (GeneralUtils.isVersionHigher(
"0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
if (!csrf) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
applicationProperties.getSecurity().setCsrfDisabled(false);
}
}
}
public void initLegalUrls() throws IOException {
// Initialize Terms and Conditions
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
if (StringUtils.isEmpty(termsUrl)) {
String defaultTermsUrl = "https://www.stirlingpdf.com/terms";
GeneralUtils.saveKeyToSettings("legal.termsAndConditions", defaultTermsUrl);
applicationProperties.getLegal().setTermsAndConditions(defaultTermsUrl);
}
// Initialize Privacy Policy
String privacyUrl = applicationProperties.getLegal().getPrivacyPolicy();
if (StringUtils.isEmpty(privacyUrl)) {
String defaultPrivacyUrl = "https://www.stirlingpdf.com/privacy-policy";
GeneralUtils.saveKeyToSettings("legal.privacyPolicy", defaultPrivacyUrl);
applicationProperties.getLegal().setPrivacyPolicy(defaultPrivacyUrl);
}
}
public void initSetAppVersion() throws IOException {
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
Properties props = new Properties();
try {
props.load(resource.getInputStream());
appVersion = props.getProperty("version");
} catch (Exception e) {
}
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
}
}
@@ -0,0 +1,61 @@
package stirling.software.SPDF.config;
import java.util.Locale;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
@Configuration
@RequiredArgsConstructor
public class LocaleConfiguration implements WebMvcConfigurer {
private final ApplicationProperties applicationProperties;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
registry.addInterceptor(new CleanUrlInterceptor());
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor lci = new LocaleChangeInterceptor();
lci.setParamName("lang");
return lci;
}
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver slr = new SessionLocaleResolver();
String appLocaleEnv = applicationProperties.getSystem().getDefaultLocale();
Locale defaultLocale = // Fallback to UK locale if environment variable is not set
Locale.UK;
if (appLocaleEnv != null && !appLocaleEnv.isEmpty()) {
Locale tempLocale = Locale.forLanguageTag(appLocaleEnv);
String tempLanguageTag = tempLocale.toLanguageTag();
if (appLocaleEnv.equalsIgnoreCase(tempLanguageTag)) {
defaultLocale = tempLocale;
} else {
tempLocale = Locale.forLanguageTag(appLocaleEnv.replace("_", "-"));
tempLanguageTag = tempLocale.toLanguageTag();
if (appLocaleEnv.equalsIgnoreCase(tempLanguageTag)) {
defaultLocale = tempLocale;
} else {
System.err.println(
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back to default en-GB.");
}
}
}
slr.setDefaultLocale(defaultLocale);
return slr;
}
}
@@ -0,0 +1,12 @@
package stirling.software.SPDF.config;
import stirling.software.common.configuration.InstallationPathConfig;
import ch.qos.logback.core.PropertyDefinerBase;
public class LogbackPropertyLoader extends PropertyDefinerBase {
@Override
public String getPropertyValue() {
return InstallationPathConfig.getLogPath();
}
}
@@ -0,0 +1,25 @@
package stirling.software.SPDF.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.core.instrument.config.MeterFilterReply;
@Configuration
public class MetricsConfig {
@Bean
public MeterFilter meterFilter() {
return new MeterFilter() {
@Override
public MeterFilterReply accept(Meter.Id id) {
if ("http.requests".equals(id.getName())) {
return MeterFilterReply.NEUTRAL;
}
return MeterFilterReply.DENY;
}
};
}
}
@@ -0,0 +1,48 @@
package stirling.software.SPDF.config;
import java.io.IOException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import stirling.software.common.util.RequestUriUtils;
@Component
@RequiredArgsConstructor
public class MetricsFilter extends OncePerRequestFilter {
private final MeterRegistry meterRegistry;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String uri = request.getRequestURI();
if (RequestUriUtils.isTrackableResource(request.getContextPath(), uri)) {
HttpSession session = request.getSession(false);
String sessionId = (session != null) ? session.getId() : "no-session";
Counter counter =
Counter.builder("http.requests")
.tag("session", sessionId)
.tag("method", request.getMethod())
.tag("uri", uri)
.register(meterRegistry);
counter.increment();
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,67 @@
package stirling.software.SPDF.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
@Configuration
@RequiredArgsConstructor
public class OpenApiConfig {
private final ApplicationProperties applicationProperties;
private static final String DEFAULT_TITLE = "Stirling PDF API";
private static final String DEFAULT_DESCRIPTION =
"API documentation for all Server-Side processing.\n"
+ "Please note some functionality might be UI only and missing from here.";
@Bean
public OpenAPI customOpenAPI() {
String version = getClass().getPackage().getImplementationVersion();
if (version == null) {
// default version if all else fails
version = "1.0.0";
}
Info info =
new Info()
.title(DEFAULT_TITLE)
.version(version)
.license(
new License()
.name("MIT")
.url(
"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/LICENSE")
.identifier("MIT"))
.termsOfService("https://www.stirlingpdf.com/terms")
.contact(
new Contact()
.name("Stirling Software")
.url("https://www.stirlingpdf.com")
.email("[email protected]"))
.description(DEFAULT_DESCRIPTION);
if (!applicationProperties.getSecurity().getEnableLogin()) {
return new OpenAPI().components(new Components()).info(info);
} else {
SecurityScheme apiKeyScheme =
new SecurityScheme()
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER)
.name("X-API-KEY");
return new OpenAPI()
.components(new Components().addSecuritySchemes("apiKey", apiKeyScheme))
.info(info)
.addSecurityItem(new SecurityRequirement().addList("apiKey"));
}
}
}
@@ -0,0 +1,18 @@
package stirling.software.SPDF.config;
import java.time.LocalDateTime;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
@Component
public class StartupApplicationListener implements ApplicationListener<ContextRefreshedEvent> {
public static LocalDateTime startTime;
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
startTime = LocalDateTime.now();
}
}
@@ -0,0 +1,35 @@
package stirling.software.SPDF.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.common.configuration.InstallationPathConfig;
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final EndpointInterceptor endpointInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(endpointInterceptor);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Handler for external static resources
registry.addResourceHandler("/**")
.addResourceLocations(
"file:" + InstallationPathConfig.getStaticPath(),
"classpath:/static/"
);
registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/");
registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/");
// .setCachePeriod(0); // Optional: disable caching
}
}
@@ -0,0 +1,68 @@
// package stirling.software.SPDF.config.fingerprint;
//
// import java.io.IOException;
//
// import org.springframework.beans.factory.annotation.Autowired;
// import org.springframework.stereotype.Component;
// import org.springframework.web.filter.OncePerRequestFilter;
//
// import jakarta.servlet.FilterChain;
// import jakarta.servlet.ServletException;
// import jakarta.servlet.http.HttpServletRequest;
// import jakarta.servlet.http.HttpServletResponse;
// import jakarta.servlet.http.HttpSession;
// import lombok.extern.slf4j.Slf4j;
// import stirling.software.common.util.RequestUriUtils;
//
//// @Component
// @Slf4j
// public class FingerprintBasedSessionFilter extends OncePerRequestFilter {
// private final FingerprintGenerator fingerprintGenerator;
// private final FingerprintBasedSessionManager sessionManager;
//
// @Autowired
// public FingerprintBasedSessionFilter(
// FingerprintGenerator fingerprintGenerator,
// FingerprintBasedSessionManager sessionManager) {
// this.fingerprintGenerator = fingerprintGenerator;
// this.sessionManager = sessionManager;
// }
//
// @Override
// protected void doFilterInternal(
// HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
// throws ServletException, IOException {
//
// if (RequestUriUtils.isStaticResource(request.getContextPath(), request.getRequestURI())) {
// filterChain.doFilter(request, response);
// return;
// }
//
// String fingerprint = fingerprintGenerator.generateFingerprint(request);
// log.debug("Generated fingerprint for request: {}", fingerprint);
//
// HttpSession session = request.getSession();
// boolean isNewSession = session.isNew();
// String sessionId = session.getId();
//
// if (isNewSession) {
// log.info("New session created: {}", sessionId);
// }
//
// if (!sessionManager.isFingerPrintAllowed(fingerprint)) {
// log.info("Blocked fingerprint detected, redirecting: {}", fingerprint);
// response.sendRedirect(request.getContextPath() + "/too-many-requests");
// return;
// }
//
// session.setAttribute("userFingerprint", fingerprint);
// session.setAttribute(
// FingerprintBasedSessionManager.STARTUP_TIMESTAMP,
// FingerprintBasedSessionManager.APP_STARTUP_TIME);
//
// sessionManager.registerFingerprint(fingerprint, sessionId);
//
// log.debug("Proceeding with request: {}", request.getRequestURI());
// filterChain.doFilter(request, response);
// }
// }
@@ -0,0 +1,134 @@
// package stirling.software.SPDF.config.fingerprint;
//
// import java.util.Iterator;
// import java.util.Map;
// import java.util.concurrent.ConcurrentHashMap;
// import java.util.concurrent.TimeUnit;
//
// import org.springframework.scheduling.annotation.Scheduled;
// import org.springframework.stereotype.Component;
//
// import jakarta.servlet.http.HttpSession;
// import jakarta.servlet.http.HttpSessionAttributeListener;
// import jakarta.servlet.http.HttpSessionEvent;
// import jakarta.servlet.http.HttpSessionListener;
// import lombok.AllArgsConstructor;
// import lombok.Data;
// import lombok.extern.slf4j.Slf4j;
//
// @Slf4j
// @Component
// public class FingerprintBasedSessionManager
// implements HttpSessionListener, HttpSessionAttributeListener {
// private static final ConcurrentHashMap<String, FingerprintInfo> activeFingerprints =
// new ConcurrentHashMap<>();
//
// // To be reduced in later version to 8~
// private static final int MAX_ACTIVE_FINGERPRINTS = 30;
//
// static final String STARTUP_TIMESTAMP = "appStartupTimestamp";
// static final long APP_STARTUP_TIME = System.currentTimeMillis();
// private static final long FINGERPRINT_EXPIRATION = TimeUnit.MINUTES.toMillis(30);
//
// @Override
// public void sessionCreated(HttpSessionEvent se) {
// HttpSession session = se.getSession();
// String sessionId = session.getId();
// String fingerprint = (String) session.getAttribute("userFingerprint");
//
// if (fingerprint == null) {
// log.warn("Session created without fingerprint: {}", sessionId);
// return;
// }
//
// synchronized (activeFingerprints) {
// if (activeFingerprints.size() >= MAX_ACTIVE_FINGERPRINTS
// && !activeFingerprints.containsKey(fingerprint)) {
// log.info("Max fingerprints reached. Marking session as blocked: {}", sessionId);
// session.setAttribute("blocked", true);
// } else {
// activeFingerprints.put(
// fingerprint, new FingerprintInfo(sessionId, System.currentTimeMillis()));
// log.info(
// "New fingerprint registered: {}. Total active fingerprints: {}",
// fingerprint,
// activeFingerprints.size());
// }
// session.setAttribute(STARTUP_TIMESTAMP, APP_STARTUP_TIME);
// }
// }
//
// @Override
// public void sessionDestroyed(HttpSessionEvent se) {
// HttpSession session = se.getSession();
// String fingerprint = (String) session.getAttribute("userFingerprint");
//
// if (fingerprint != null) {
// synchronized (activeFingerprints) {
// activeFingerprints.remove(fingerprint);
// log.info(
// "Fingerprint removed: {}. Total active fingerprints: {}",
// fingerprint,
// activeFingerprints.size());
// }
// }
// }
//
// public boolean isFingerPrintAllowed(String fingerprint) {
// synchronized (activeFingerprints) {
// return activeFingerprints.size() < MAX_ACTIVE_FINGERPRINTS
// || activeFingerprints.containsKey(fingerprint);
// }
// }
//
// public void registerFingerprint(String fingerprint, String sessionId) {
// synchronized (activeFingerprints) {
// activeFingerprints.put(
// fingerprint, new FingerprintInfo(sessionId, System.currentTimeMillis()));
// }
// }
//
// public void unregisterFingerprint(String fingerprint) {
// synchronized (activeFingerprints) {
// activeFingerprints.remove(fingerprint);
// }
// }
//
// @Scheduled(fixedRate = 1800000) // Run every 30 mins
// public void cleanupStaleFingerprints() {
// log.info("Starting cleanup of stale fingerprints");
// long now = System.currentTimeMillis();
// int removedCount = 0;
//
// synchronized (activeFingerprints) {
// Iterator<Map.Entry<String, FingerprintInfo>> iterator =
// activeFingerprints.entrySet().iterator();
// while (iterator.hasNext()) {
// Map.Entry<String, FingerprintInfo> entry = iterator.next();
// FingerprintInfo info = entry.getValue();
//
// if (now - info.getLastAccessTime() > FINGERPRINT_EXPIRATION) {
// iterator.remove();
// removedCount++;
// log.info("Removed stale fingerprint: {}", entry.getKey());
// }
// }
// }
//
// log.info("Cleanup complete. Removed {} stale fingerprints", removedCount);
// }
//
// public void updateLastAccessTime(String fingerprint) {
// FingerprintInfo info = activeFingerprints.get(fingerprint);
// if (info != null) {
// info.setLastAccessTime(System.currentTimeMillis());
// }
// }
//
// @Data
// @AllArgsConstructor
// private static class FingerprintInfo {
// private String sessionId;
// private long lastAccessTime;
// }
// }
@@ -0,0 +1,77 @@
// package stirling.software.SPDF.config.fingerprint;
//
// import java.security.MessageDigest;
// import java.security.NoSuchAlgorithmException;
//
// import org.springframework.stereotype.Component;
//
// import jakarta.servlet.http.HttpServletRequest;
//
// @Component
// public class FingerprintGenerator {
//
// public String generateFingerprint(HttpServletRequest request) {
// if (request == null) {
// return "";
// }
// StringBuilder fingerprintBuilder = new StringBuilder();
//
// // Add IP address
// fingerprintBuilder.append(request.getRemoteAddr());
//
// // Add X-Forwarded-For header if present (for clients behind proxies)
// String forwardedFor = request.getHeader("X-Forwarded-For");
// if (forwardedFor != null) {
// fingerprintBuilder.append(forwardedFor);
// }
//
// // Add User-Agent
// String userAgent = request.getHeader("User-Agent");
// if (userAgent != null) {
// fingerprintBuilder.append(userAgent);
// }
//
// // Add Accept-Language header
// String acceptLanguage = request.getHeader("Accept-Language");
// if (acceptLanguage != null) {
// fingerprintBuilder.append(acceptLanguage);
// }
//
// // Add Accept header
// String accept = request.getHeader("Accept");
// if (accept != null) {
// fingerprintBuilder.append(accept);
// }
//
// // Add Connection header
// String connection = request.getHeader("Connection");
// if (connection != null) {
// fingerprintBuilder.append(connection);
// }
//
// // Add server port
// fingerprintBuilder.append(request.getServerPort());
//
// // Add secure flag
// fingerprintBuilder.append(request.isSecure());
//
// // Generate a hash of the fingerprint
// return generateHash(fingerprintBuilder.toString());
// }
//
// private String generateHash(String input) {
// try {
// MessageDigest digest = MessageDigest.getInstance("SHA-256");
// byte[] hash = digest.digest(input.getBytes());
// StringBuilder hexString = new StringBuilder();
// for (byte b : hash) {
// String hex = Integer.toHexString(0xff & b);
// if (hex.length() == 1) hexString.append('0');
// hexString.append(hex);
// }
// return hexString.toString();
// } catch (NoSuchAlgorithmException e) {
// throw new RuntimeException("Failed to generate fingerprint hash", e);
// }
// }
// }
@@ -0,0 +1,69 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.service.LanguageService;
@RestController
@RequestMapping("/js")
@RequiredArgsConstructor
public class AdditionalLanguageJsController {
private final LanguageService languageService;
@Hidden
@GetMapping(value = "/additionalLanguageCode.js", produces = "application/javascript")
public void generateAdditionalLanguageJs(HttpServletResponse response) throws IOException {
Set<String> supportedLanguages = languageService.getSupportedLanguages();
response.setContentType("application/javascript");
PrintWriter writer = response.getWriter();
// Erstelle das JavaScript dynamisch
writer.println(
"const supportedLanguages = "
+ toJsonArray(new ArrayList<>(supportedLanguages))
+ ";");
// Generiere die `getDetailedLanguageCode`-Funktion
writer.println(
"""
function getDetailedLanguageCode() {
const userLanguages = navigator.languages ? navigator.languages : [navigator.language];
for (let lang of userLanguages) {
let matchedLang = supportedLanguages.find(supportedLang => supportedLang.startsWith(lang.replace('-', '_')));
if (matchedLang) {
return matchedLang;
}
}
// Fallback
return "en_GB";
}
""");
writer.flush();
}
// Hilfsfunktion zum Konvertieren der Liste in ein JSON-Array
private String toJsonArray(List<String> list) {
StringBuilder jsonArray = new StringBuilder("[");
for (int i = 0; i < list.size(); i++) {
jsonArray.append("\"").append(list.get(i)).append("\"");
if (i < list.size() - 1) {
jsonArray.append(",");
}
}
jsonArray.append("]");
return jsonArray.toString();
}
}
@@ -0,0 +1,203 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.*;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.encryption.PDEncryption;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
@RestController
@RequestMapping("/api/v1/analysis")
@Tag(name = "Analysis", description = "Analysis APIs")
@RequiredArgsConstructor
public class AnalysisController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/page-count", consumes = "multipart/form-data")
@Operation(
summary = "Get PDF page count",
description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO")
public Map<String, Integer> getPageCount(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
return Map.of("pageCount", document.getNumberOfPages());
}
}
@PostMapping(value = "/basic-info", consumes = "multipart/form-data")
@Operation(
summary = "Get basic PDF information",
description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO")
public Map<String, Object> getBasicInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> info = new HashMap<>();
info.put("pageCount", document.getNumberOfPages());
info.put("pdfVersion", document.getVersion());
info.put("fileSize", file.getFileInput().getSize());
return info;
}
}
@PostMapping(value = "/document-properties", consumes = "multipart/form-data")
@Operation(
summary = "Get PDF document properties",
description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO")
public Map<String, String> getDocumentProperties(@ModelAttribute PDFFile file)
throws IOException {
// Load the document in read-only mode to prevent modifications and ensure the integrity of
// the original file.
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput(), true)) {
PDDocumentInformation info = document.getDocumentInformation();
Map<String, String> properties = new HashMap<>();
properties.put("title", info.getTitle());
properties.put("author", info.getAuthor());
properties.put("subject", info.getSubject());
properties.put("keywords", info.getKeywords());
properties.put("creator", info.getCreator());
properties.put("producer", info.getProducer());
properties.put("creationDate", info.getCreationDate().toString());
properties.put("modificationDate", info.getModificationDate().toString());
return properties;
}
}
@PostMapping(value = "/page-dimensions", consumes = "multipart/form-data")
@Operation(
summary = "Get page dimensions for all pages",
description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO")
public List<Map<String, Float>> getPageDimensions(@ModelAttribute PDFFile file)
throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
List<Map<String, Float>> dimensions = new ArrayList<>();
PDPageTree pages = document.getPages();
for (PDPage page : pages) {
Map<String, Float> pageDim = new HashMap<>();
pageDim.put("width", page.getBBox().getWidth());
pageDim.put("height", page.getBBox().getHeight());
dimensions.add(pageDim);
}
return dimensions;
}
}
@PostMapping(value = "/form-fields", consumes = "multipart/form-data")
@Operation(
summary = "Get form field information",
description =
"Returns count and details of form fields. Input:PDF Output:JSON Type:SISO")
public Map<String, Object> getFormFields(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> formInfo = new HashMap<>();
PDAcroForm form = document.getDocumentCatalog().getAcroForm();
if (form != null) {
formInfo.put("fieldCount", form.getFields().size());
formInfo.put("hasXFA", form.hasXFA());
formInfo.put("isSignaturesExist", form.isSignaturesExist());
} else {
formInfo.put("fieldCount", 0);
formInfo.put("hasXFA", false);
formInfo.put("isSignaturesExist", false);
}
return formInfo;
}
}
@PostMapping(value = "/annotation-info", consumes = "multipart/form-data")
@Operation(
summary = "Get annotation information",
description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO")
public Map<String, Object> getAnnotationInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> annotInfo = new HashMap<>();
int totalAnnotations = 0;
Map<String, Integer> annotationTypes = new HashMap<>();
for (PDPage page : document.getPages()) {
for (PDAnnotation annot : page.getAnnotations()) {
totalAnnotations++;
String subType = annot.getSubtype();
annotationTypes.merge(subType, 1, Integer::sum);
}
}
annotInfo.put("totalCount", totalAnnotations);
annotInfo.put("typeBreakdown", annotationTypes);
return annotInfo;
}
}
@PostMapping(value = "/font-info", consumes = "multipart/form-data")
@Operation(
summary = "Get font information",
description =
"Returns list of fonts used in the document. Input:PDF Output:JSON Type:SISO")
public Map<String, Object> getFontInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> fontInfo = new HashMap<>();
Set<String> fontNames = new HashSet<>();
for (PDPage page : document.getPages()) {
for (COSName font : page.getResources().getFontNames()) {
fontNames.add(font.getName());
}
}
fontInfo.put("fontCount", fontNames.size());
fontInfo.put("fonts", fontNames);
return fontInfo;
}
}
@PostMapping(value = "/security-info", consumes = "multipart/form-data")
@Operation(
summary = "Get security information",
description =
"Returns encryption and permission details. Input:PDF Output:JSON Type:SISO")
public Map<String, Object> getSecurityInfo(@ModelAttribute PDFFile file) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
Map<String, Object> securityInfo = new HashMap<>();
PDEncryption encryption = document.getEncryption();
if (encryption != null) {
securityInfo.put("isEncrypted", true);
securityInfo.put("keyLength", encryption.getLength());
// Get permissions
Map<String, Boolean> permissions = new HashMap<>();
permissions.put(
"preventPrinting", !document.getCurrentAccessPermission().canPrint());
permissions.put(
"preventModify", !document.getCurrentAccessPermission().canModify());
permissions.put(
"preventExtractContent",
!document.getCurrentAccessPermission().canExtractContent());
permissions.put(
"preventModifyAnnotations",
!document.getCurrentAccessPermission().canModifyAnnotations());
securityInfo.put("permissions", permissions);
} else {
securityInfo.put("isEncrypted", false);
}
return securityInfo;
}
}
}
@@ -0,0 +1,98 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.multipdf.LayerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.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 stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class CropController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/crop", consumes = "multipart/form-data")
@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 {
PDDocument sourceDocument = pdfDocumentFactory.load(request);
PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
int totalPages = sourceDocument.getNumberOfPages();
LayerUtility layerUtility = new LayerUtility(newDocument);
for (int i = 0; i < totalPages; i++) {
PDPage sourcePage = sourceDocument.getPage(i);
// Create a new page with the size of the source page
PDPage newPage = new PDPage(sourcePage.getMediaBox());
newDocument.addPage(newPage);
PDPageContentStream contentStream =
new PDPageContentStream(newDocument, newPage, AppendMode.OVERWRITE, true, true);
// Import the source page as a form XObject
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, i);
contentStream.saveGraphicsState();
// Define the crop area
contentStream.addRect(
request.getX(), request.getY(), request.getWidth(), request.getHeight());
contentStream.clip();
// Draw the entire formXObject
contentStream.drawForm(formXObject);
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()));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
newDocument.close();
sourceDocument.close();
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
request.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_cropped.pdf");
}
}
@@ -0,0 +1,200 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
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.model.api.general.MergePdfsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@Slf4j
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class MergeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
// Merges a list of PDDocument objects into a single PDDocument
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
PDDocument mergedDoc = pdfDocumentFactory.createNewDocument();
for (PDDocument doc : documents) {
for (PDPage page : doc.getPages()) {
mergedDoc.addPage(page);
}
}
return mergedDoc;
}
// Returns a comparator for sorting MultipartFile arrays based on the given sort type
private Comparator<MultipartFile> getSortComparator(String sortType) {
switch (sortType) {
case "byFileName":
return Comparator.comparing(MultipartFile::getOriginalFilename);
case "byDateModified":
return (file1, file2) -> {
try {
BasicFileAttributes attr1 =
Files.readAttributes(
Paths.get(file1.getOriginalFilename()),
BasicFileAttributes.class);
BasicFileAttributes attr2 =
Files.readAttributes(
Paths.get(file2.getOriginalFilename()),
BasicFileAttributes.class);
return attr1.lastModifiedTime().compareTo(attr2.lastModifiedTime());
} catch (IOException e) {
return 0; // If there's an error, treat them as equal
}
};
case "byDateCreated":
return (file1, file2) -> {
try {
BasicFileAttributes attr1 =
Files.readAttributes(
Paths.get(file1.getOriginalFilename()),
BasicFileAttributes.class);
BasicFileAttributes attr2 =
Files.readAttributes(
Paths.get(file2.getOriginalFilename()),
BasicFileAttributes.class);
return attr1.creationTime().compareTo(attr2.creationTime());
} catch (IOException e) {
return 0; // If there's an error, treat them as equal
}
};
case "byPDFTitle":
return (file1, file2) -> {
try (PDDocument doc1 = pdfDocumentFactory.load(file1);
PDDocument doc2 = pdfDocumentFactory.load(file2)) {
String title1 = doc1.getDocumentInformation().getTitle();
String title2 = doc2.getDocumentInformation().getTitle();
return title1.compareTo(title2);
} catch (IOException e) {
return 0;
}
};
case "orderProvided":
default:
return (file1, file2) -> 0; // Default is the order provided
}
}
@PostMapping(consumes = "multipart/form-data", value = "/merge-pdfs")
@Operation(
summary = "Merge multiple PDF files into one",
description =
"This endpoint merges multiple PDF files into a single PDF file. The merged"
+ " file will contain all pages from the input files in the order they were"
+ " provided. Input:PDF Output:PDF Type:MISO")
public ResponseEntity<byte[]> mergePdfs(@ModelAttribute MergePdfsRequest request)
throws IOException {
List<File> filesToDelete = new ArrayList<>(); // List of temporary files to delete
File mergedTempFile = null;
PDDocument mergedDocument = null;
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
try {
MultipartFile[] files = request.getFileInput();
Arrays.sort(
files,
getSortComparator(
request.getSortType())); // Sort files based on the given sort type
PDFMergerUtility mergerUtility = new PDFMergerUtility();
long totalSize = 0;
for (MultipartFile multipartFile : files) {
totalSize += multipartFile.getSize();
File tempFile =
GeneralUtils.convertMultipartFileToFile(
multipartFile); // Convert MultipartFile to File
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
mergerUtility.addSource(tempFile); // Add source file to the merger utility
}
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
mergerUtility.mergeDocuments(
pdfDocumentFactory.getStreamCacheFunction(totalSize)); // Merge the documents
// Load the merged PDF document
mergedDocument = pdfDocumentFactory.load(mergedTempFile);
// Remove signatures if removeCertSign is true
if (removeCertSign) {
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
List<PDField> fieldsToRemove =
acroForm.getFields().stream()
.filter(field -> field instanceof PDSignatureField)
.toList();
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(
fieldsToRemove,
false); // Flatten the fields, effectively removing them
}
}
}
// Save the modified document to a new ByteArrayOutputStream
ByteArrayOutputStream baos = new ByteArrayOutputStream();
mergedDocument.save(baos);
String mergedFileName =
files[0].getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_merged_unsigned.pdf";
return WebResponseUtils.boasToWebResponse(
baos, mergedFileName); // Return the modified PDF
} catch (Exception ex) {
log.error("Error in merge pdf process", ex);
throw ex;
} finally {
if (mergedDocument != null) {
mergedDocument.close(); // Close the merged document
}
for (File file : filesToDelete) {
if (file != null) {
Files.deleteIfExists(file.toPath()); // Delete temporary files
}
}
if (mergedTempFile != null) {
Files.deleteIfExists(mergedTempFile.toPath());
}
}
}
}
@@ -0,0 +1,146 @@
package stirling.software.SPDF.controller.api;
import java.awt.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.multipdf.LayerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
import org.springframework.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.general.MergeMultiplePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class MultiPageLayoutController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/multi-page-layout", consumes = "multipart/form-data")
@Operation(
summary = "Merge multiple pages of a PDF document into a single page",
description =
"This operation takes an input PDF file and the number of pages to merge into a"
+ " single sheet in the output PDF file. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> mergeMultiplePagesIntoOne(
@ModelAttribute MergeMultiplePagesRequest request) throws IOException {
int pagesPerSheet = request.getPagesPerSheet();
MultipartFile file = request.getFileInput();
boolean addBorder = Boolean.TRUE.equals(request.getAddBorder());
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");
}
int cols =
pagesPerSheet == 2 || pagesPerSheet == 3
? pagesPerSheet
: (int) Math.sqrt(pagesPerSheet);
int rows = pagesPerSheet == 2 || pagesPerSheet == 3 ? 1 : (int) Math.sqrt(pagesPerSheet);
PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
PDPage newPage = new PDPage(PDRectangle.A4);
newDocument.addPage(newPage);
int totalPages = sourceDocument.getNumberOfPages();
float cellWidth = newPage.getMediaBox().getWidth() / cols;
float cellHeight = newPage.getMediaBox().getHeight() / rows;
PDPageContentStream contentStream =
new PDPageContentStream(
newDocument, newPage, PDPageContentStream.AppendMode.APPEND, true, true);
LayerUtility layerUtility = new LayerUtility(newDocument);
float borderThickness = 1.5f; // Specify border thickness as required
contentStream.setLineWidth(borderThickness);
contentStream.setStrokingColor(Color.BLACK);
for (int i = 0; i < totalPages; i++) {
if (i != 0 && i % pagesPerSheet == 0) {
// Close the current content stream and create a new page and content stream
contentStream.close();
newPage = new PDPage(PDRectangle.A4);
newDocument.addPage(newPage);
contentStream =
new PDPageContentStream(
newDocument,
newPage,
PDPageContentStream.AppendMode.APPEND,
true,
true);
}
PDPage sourcePage = sourceDocument.getPage(i);
PDRectangle rect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / rect.getWidth();
float scaleHeight = cellHeight / rect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
int adjustedPageIndex =
i % pagesPerSheet; // This will reset the index for every new page
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
float x = colIndex * cellWidth + (cellWidth - rect.getWidth() * scale) / 2;
float y =
newPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - rect.getHeight() * scale) / 2);
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
contentStream.transform(Matrix.getScaleInstance(scale, scale));
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, i);
contentStream.drawForm(formXObject);
contentStream.restoreGraphicsState();
if (addBorder) {
// Draw border around each page
float borderX = colIndex * cellWidth;
float borderY = newPage.getMediaBox().getHeight() - (rowIndex + 1) * cellHeight;
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
contentStream.stroke();
}
}
contentStream.close(); // Close the final content stream
sourceDocument.close();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
newDocument.close();
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
+ "_layoutChanged.pdf");
}
}
@@ -0,0 +1,77 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
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 stirling.software.SPDF.service.PdfImageRemovalService;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
/**
* Controller class for handling PDF image removal requests. Provides an endpoint to remove images
* from a PDF file to reduce its size.
*/
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class PdfImageRemovalController {
// Service for removing images from PDFs
private final PdfImageRemovalService pdfImageRemovalService;
private final CustomPDFDocumentFactory pdfDocumentFactory;
/**
* Endpoint to remove images from a PDF file.
*
* <p>This method processes the uploaded PDF file, removes all images, and returns the modified
* PDF file with a new name indicating that images were removed.
*
* @param file The PDF file with images to be removed.
* @return ResponseEntity containing the modified PDF file as byte array with appropriate
* content type and filename.
* @throws IOException If an error occurs while processing the PDF file.
*/
@PostMapping(consumes = "multipart/form-data", value = "/remove-image-pdf")
@Operation(
summary = "Remove images from file to reduce the file size.",
description =
"This endpoint remove images from file to reduce the file size.Input:PDF"
+ " Output:PDF Type:MISO")
public ResponseEntity<byte[]> removeImages(@ModelAttribute PDFFile file) throws IOException {
// Load the PDF document
PDDocument document = pdfDocumentFactory.load(file);
// Remove images from the PDF document using the service
PDDocument modifiedDocument = pdfImageRemovalService.removeImagesFromPdf(document);
// Create a ByteArrayOutputStream to hold the modified PDF data
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
// Save the modified PDF document to the output stream
modifiedDocument.save(outputStream);
modifiedDocument.close();
// Generate a new filename for the modified PDF
String mergedFileName =
file.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_removed_images.pdf";
// Convert the byte array to a web response and return it
return WebResponseUtils.bytesToWebResponse(outputStream.toByteArray(), mergedFileName);
}
}
@@ -0,0 +1,209 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.multipdf.Overlay;
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 stirling.software.SPDF.model.api.general.OverlayPdfsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class PdfOverlayController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data")
@Operation(
summary = "Overlay PDF files in various modes",
description =
"Overlay PDF files onto a base PDF with different modes: Sequential,"
+ " Interleaved, or Fixed Repeat. Input:PDF Output:PDF Type:MIMO")
public ResponseEntity<byte[]> overlayPdfs(@ModelAttribute OverlayPdfsRequest request)
throws IOException {
MultipartFile baseFile = request.getFileInput();
int overlayPos = request.getOverlayPosition();
MultipartFile[] overlayFiles = request.getOverlayFiles();
File[] overlayPdfFiles = new File[overlayFiles.length];
List<File> tempFiles = new ArrayList<>(); // List to keep track of temporary files
try {
for (int i = 0; i < overlayFiles.length; i++) {
overlayPdfFiles[i] = GeneralUtils.multipartToFile(overlayFiles[i]);
}
String mode = request.getOverlayMode(); // "SequentialOverlay", "InterleavedOverlay",
// "FixedRepeatOverlay"
int[] counts = request.getCounts(); // Used for FixedRepeatOverlay mode
try (PDDocument basePdf = pdfDocumentFactory.load(baseFile);
Overlay overlay = new Overlay()) {
Map<Integer, String> overlayGuide =
prepareOverlayGuide(
basePdf.getNumberOfPages(),
overlayPdfFiles,
mode,
counts,
tempFiles);
overlay.setInputPDF(basePdf);
if (overlayPos == 0) {
overlay.setOverlayPosition(Overlay.Position.FOREGROUND);
} else {
overlay.setOverlayPosition(Overlay.Position.BACKGROUND);
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
overlay.overlay(overlayGuide).save(outputStream);
byte[] data = outputStream.toByteArray();
String outputFilename =
Filenames.toSimpleFileName(baseFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_overlayed.pdf"; // Remove file extension and append .pdf
return WebResponseUtils.bytesToWebResponse(
data, outputFilename, MediaType.APPLICATION_PDF);
}
} finally {
for (File overlayPdfFile : overlayPdfFiles) {
if (overlayPdfFile != null) {
Files.deleteIfExists(overlayPdfFile.toPath());
}
}
for (File tempFile : tempFiles) { // Delete temporary files
if (tempFile != null) {
Files.deleteIfExists(tempFile.toPath());
}
}
}
}
private Map<Integer, String> prepareOverlayGuide(
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
throws IOException {
Map<Integer, String> overlayGuide = new HashMap<>();
switch (mode) {
case "SequentialOverlay":
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
break;
case "InterleavedOverlay":
interleavedOverlay(overlayGuide, overlayFiles, basePageCount);
break;
case "FixedRepeatOverlay":
fixedRepeatOverlay(overlayGuide, overlayFiles, counts, basePageCount);
break;
default:
throw new IllegalArgumentException("Invalid overlay mode");
}
return overlayGuide;
}
private void sequentialOverlay(
Map<Integer, String> overlayGuide,
File[] overlayFiles,
int basePageCount,
List<File> tempFiles)
throws IOException {
int overlayFileIndex = 0;
int pageCountInCurrentOverlay = 0;
for (int basePageIndex = 1; basePageIndex <= basePageCount; basePageIndex++) {
if (pageCountInCurrentOverlay == 0
|| pageCountInCurrentOverlay
>= getNumberOfPages(overlayFiles[overlayFileIndex])) {
pageCountInCurrentOverlay = 0;
overlayFileIndex = (overlayFileIndex + 1) % overlayFiles.length;
}
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
}
pageCountInCurrentOverlay++;
}
}
private int getNumberOfPages(File file) throws IOException {
try (PDDocument doc = Loader.loadPDF(file)) {
return doc.getNumberOfPages();
}
}
private void interleavedOverlay(
Map<Integer, String> overlayGuide, File[] overlayFiles, int basePageCount)
throws IOException {
for (int basePageIndex = 1; basePageIndex <= basePageCount; basePageIndex++) {
File overlayFile = overlayFiles[(basePageIndex - 1) % overlayFiles.length];
// Load the overlay document to check its page count
try (PDDocument overlayPdf = Loader.loadPDF(overlayFile)) {
int overlayPageCount = overlayPdf.getNumberOfPages();
if ((basePageIndex - 1) % overlayPageCount < overlayPageCount) {
overlayGuide.put(basePageIndex, overlayFile.getAbsolutePath());
}
}
}
}
private void fixedRepeatOverlay(
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");
}
int currentPage = 1;
for (int i = 0; i < overlayFiles.length; i++) {
File overlayFile = overlayFiles[i];
int repeatCount = counts[i];
// Load the overlay document to check its page count
try (PDDocument overlayPdf = Loader.loadPDF(overlayFile)) {
int overlayPageCount = overlayPdf.getNumberOfPages();
for (int j = 0; j < repeatCount; j++) {
for (int page = 0; page < overlayPageCount; page++) {
if (currentPage > basePageCount) break;
overlayGuide.put(currentPage++, overlayFile.getAbsolutePath());
}
}
}
}
}
}
// Additional classes like OverlayPdfsRequest, WebResponseUtils, etc. are assumed to be defined
// elsewhere.
@@ -0,0 +1,295 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
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.model.SortTypes;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.model.api.general.RearrangePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Slf4j
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class RearrangePagesPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-pages")
@Operation(
summary = "Remove pages from a PDF file",
description =
"This endpoint removes specified pages from a given PDF file. Users can provide"
+ " a comma-separated list of page numbers or ranges to delete. Input:PDF"
+ " Output:PDF Type:SISO")
public ResponseEntity<byte[]> deletePages(@ModelAttribute PDFWithPageNums request)
throws IOException {
MultipartFile pdfFile = request.getFileInput();
String pagesToDelete = request.getPageNumbers();
PDDocument document = pdfDocumentFactory.load(pdfFile);
// Split the page order string into an array of page numbers or range of numbers
String[] pageOrderArr = pagesToDelete.split(",");
List<Integer> pagesToRemove =
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages(), false);
Collections.sort(pagesToRemove);
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
int pageIndex = pagesToRemove.get(i);
document.removePage(pageIndex);
}
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_removed_pages.pdf");
}
private List<Integer> removeFirst(int totalPages) {
if (totalPages <= 1) return new ArrayList<>();
List<Integer> newPageOrder = new ArrayList<>();
for (int i = 2; i <= totalPages; i++) {
newPageOrder.add(i - 1);
}
return newPageOrder;
}
private List<Integer> removeLast(int totalPages) {
if (totalPages <= 1) return new ArrayList<>();
List<Integer> newPageOrder = new ArrayList<>();
for (int i = 1; i < totalPages; i++) {
newPageOrder.add(i - 1);
}
return newPageOrder;
}
private List<Integer> removeFirstAndLast(int totalPages) {
if (totalPages <= 2) return new ArrayList<>();
List<Integer> newPageOrder = new ArrayList<>();
for (int i = 2; i < totalPages; i++) {
newPageOrder.add(i - 1);
}
return newPageOrder;
}
private List<Integer> reverseOrder(int totalPages) {
List<Integer> newPageOrder = new ArrayList<>();
for (int i = totalPages; i >= 1; i--) {
newPageOrder.add(i - 1);
}
return newPageOrder;
}
private List<Integer> duplexSort(int totalPages) {
List<Integer> newPageOrder = new ArrayList<>();
int half = (totalPages + 1) / 2; // This ensures proper behavior with odd numbers of pages
for (int i = 1; i <= half; i++) {
newPageOrder.add(i - 1);
if (i <= totalPages - half) { // Avoid going out of bounds
newPageOrder.add(totalPages - i);
}
}
return newPageOrder;
}
private List<Integer> bookletSort(int totalPages) {
List<Integer> newPageOrder = new ArrayList<>();
for (int i = 0; i < totalPages / 2; i++) {
newPageOrder.add(i);
newPageOrder.add(totalPages - i - 1);
}
return newPageOrder;
}
private List<Integer> sideStitchBooklet(int totalPages) {
List<Integer> newPageOrder = new ArrayList<>();
for (int i = 0; i < (totalPages + 3) / 4; i++) {
int begin = i * 4;
newPageOrder.add(Math.min(begin + 3, totalPages - 1));
newPageOrder.add(Math.min(begin, totalPages - 1));
newPageOrder.add(Math.min(begin + 1, totalPages - 1));
newPageOrder.add(Math.min(begin + 2, totalPages - 1));
}
return newPageOrder;
}
private List<Integer> oddEvenSplit(int totalPages) {
List<Integer> newPageOrder = new ArrayList<>();
for (int i = 1; i <= totalPages; i += 2) {
newPageOrder.add(i - 1);
}
for (int i = 2; i <= totalPages; i += 2) {
newPageOrder.add(i - 1);
}
return newPageOrder;
}
/**
* Rearrange pages in a PDF file by merging odd and even pages. The first half of the pages will
* be the odd pages, and the second half will be the even pages as input. <br>
* This method is visible for testing purposes only.
*
* @param totalPages Total number of pages in the PDF file.
* @return List of page numbers in the new order. The first page is 0.
*/
List<Integer> oddEvenMerge(int totalPages) {
List<Integer> newPageOrderZeroBased = new ArrayList<>();
int numberOfOddPages = (totalPages + 1) / 2;
for (int oneBasedIndex = 1; oneBasedIndex < (numberOfOddPages + 1); oneBasedIndex++) {
newPageOrderZeroBased.add((oneBasedIndex - 1));
if (numberOfOddPages + oneBasedIndex <= totalPages) {
newPageOrderZeroBased.add((numberOfOddPages + oneBasedIndex - 1));
}
}
return newPageOrderZeroBased;
}
private List<Integer> duplicate(int totalPages, String pageOrder) {
List<Integer> newPageOrder = new ArrayList<>();
int duplicateCount;
try {
// Parse the duplicate count from pageOrder
duplicateCount =
pageOrder != null && !pageOrder.isEmpty()
? Integer.parseInt(pageOrder.trim())
: 2; // Default to 2 if not specified
} catch (NumberFormatException e) {
log.error("Invalid duplicate count specified", e);
duplicateCount = 2; // Default to 2 if invalid input
}
// Validate duplicate count
if (duplicateCount < 1) {
duplicateCount = 2; // Default to 2 if invalid input
}
// For each page in the document
for (int pageNum = 0; pageNum < totalPages; pageNum++) {
// Add the current page index duplicateCount times
for (int dupCount = 0; dupCount < duplicateCount; dupCount++) {
newPageOrder.add(pageNum);
}
}
return newPageOrder;
}
private List<Integer> processSortTypes(String sortTypes, int totalPages, String pageOrder) {
try {
SortTypes mode = SortTypes.valueOf(sortTypes.toUpperCase());
switch (mode) {
case REVERSE_ORDER:
return reverseOrder(totalPages);
case DUPLEX_SORT:
return duplexSort(totalPages);
case BOOKLET_SORT:
return bookletSort(totalPages);
case SIDE_STITCH_BOOKLET_SORT:
return sideStitchBooklet(totalPages);
case ODD_EVEN_SPLIT:
return oddEvenSplit(totalPages);
case ODD_EVEN_MERGE:
return oddEvenMerge(totalPages);
case REMOVE_FIRST:
return removeFirst(totalPages);
case REMOVE_LAST:
return removeLast(totalPages);
case REMOVE_FIRST_AND_LAST:
return removeFirstAndLast(totalPages);
case DUPLICATE:
return duplicate(totalPages, pageOrder);
default:
throw new IllegalArgumentException("Unsupported custom mode");
}
} catch (IllegalArgumentException e) {
log.error("Unsupported custom mode", e);
return null;
}
}
@PostMapping(consumes = "multipart/form-data", value = "/rearrange-pages")
@Operation(
summary = "Rearrange pages in a PDF file",
description =
"This endpoint rearranges pages in a given PDF file based on the specified page"
+ " order or custom mode. Users can provide a page order as a"
+ " comma-separated list of page numbers or page ranges, or a custom mode."
+ " Input:PDF Output:PDF")
public ResponseEntity<byte[]> rearrangePages(@ModelAttribute RearrangePagesRequest request)
throws IOException {
MultipartFile pdfFile = request.getFileInput();
String pageOrder = request.getPageNumbers();
String sortType = request.getCustomMode();
try {
// Load the input PDF
PDDocument document = pdfDocumentFactory.load(pdfFile);
// Split the page order string into an array of page numbers or range of numbers
String[] pageOrderArr = pageOrder != null ? pageOrder.split(",") : new String[0];
int totalPages = document.getNumberOfPages();
List<Integer> newPageOrder;
if (sortType != null
&& sortType.length() > 0
&& !"custom".equals(sortType.toLowerCase())) {
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
} else {
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
}
log.info("newPageOrder = " + newPageOrder);
log.info("totalPages = " + totalPages);
// Create a new list to hold the pages in the new order
List<PDPage> newPages = new ArrayList<>();
for (int i = 0; i < newPageOrder.size(); i++) {
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);
}
// Add the pages in the new order
for (PDPage page : newPages) {
document.addPage(page);
}
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_rearranged.pdf");
} catch (IOException e) {
log.error("Failed rearranging documents", e);
return null;
}
}
}
@@ -0,0 +1,65 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
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.general.RotatePDFRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class RotationController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/rotate-pdf")
@Operation(
summary = "Rotate a PDF file",
description =
"This endpoint rotates a given PDF file by a specified angle. The angle must be"
+ " a multiple of 90. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> rotatePDF(@ModelAttribute RotatePDFRequest request)
throws IOException {
MultipartFile pdfFile = request.getFileInput();
Integer angle = request.getAngle();
// Validate the angle is a multiple of 90
if (angle % 90 != 0) {
throw new IllegalArgumentException("Angle must be a multiple of 90");
}
// Load the PDF document
PDDocument document = pdfDocumentFactory.load(request);
// Get the list of pages in the document
PDPageTree pages = document.getPages();
for (PDPage page : pages) {
page.setRotation(page.getRotation() + angle);
}
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_rotated.pdf");
}
}
@@ -0,0 +1,145 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.apache.pdfbox.multipdf.LayerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
import org.springframework.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.general.ScalePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class ScalePagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/scale-pages", consumes = "multipart/form-data")
@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(),
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
+ "_scaled.pdf");
}
private PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
if ("KEEP".equals(targetPDRectangle)) {
if (sourceDocument.getNumberOfPages() == 0) {
return null;
}
// use the first page to determine the target page size
PDPage sourcePage = sourceDocument.getPage(0);
PDRectangle sourceSize = sourcePage.getMediaBox();
return sourceSize;
}
Map<String, PDRectangle> sizeMap = getSizeMap();
if (sizeMap.containsKey(targetPDRectangle)) {
return sizeMap.get(targetPDRectangle);
}
throw new IllegalArgumentException(
"Invalid PDRectangle. It must be one of the following: A0, A1, A2, A3, A4, A5, A6,"
+ " LETTER, LEGAL, KEEP");
}
private Map<String, PDRectangle> getSizeMap() {
Map<String, PDRectangle> sizeMap = new HashMap<>();
// Add A0 - A6
sizeMap.put("A0", PDRectangle.A0);
sizeMap.put("A1", PDRectangle.A1);
sizeMap.put("A2", PDRectangle.A2);
sizeMap.put("A3", PDRectangle.A3);
sizeMap.put("A4", PDRectangle.A4);
sizeMap.put("A5", PDRectangle.A5);
sizeMap.put("A6", PDRectangle.A6);
// Add other sizes
sizeMap.put("LETTER", PDRectangle.LETTER);
sizeMap.put("LEGAL", PDRectangle.LEGAL);
return sizeMap;
}
}
@@ -0,0 +1,53 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
@Controller
@Tag(name = "Settings", description = "Settings APIs")
@RequestMapping("/api/v1/settings")
@RequiredArgsConstructor
@Hidden
public class SettingsController {
private final ApplicationProperties applicationProperties;
private final EndpointConfiguration endpointConfiguration;
@PostMapping("/update-enable-analytics")
@Hidden
public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException {
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
return ResponseEntity.status(HttpStatus.ALREADY_REPORTED)
.body(
"Setting has already been set, To adjust please edit "
+ InstallationPathConfig.getSettingsPath());
}
GeneralUtils.saveKeyToSettings("system.enableAnalytics", enabled);
applicationProperties.getSystem().setEnableAnalytics(enabled);
return ResponseEntity.ok("Updated");
}
@GetMapping("/get-endpoints-status")
@Hidden
public ResponseEntity<Map<String, Boolean>> getDisabledEndpoints() {
return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses());
}
}
@@ -0,0 +1,163 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
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.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.SPDF.model.api.PDFWithPageNums;
@RestController
@RequestMapping("/api/v1/general")
@Slf4j
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class SplitPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/split-pages")
@Operation(
summary = "Split a PDF file into separate documents",
description =
"This endpoint splits a given PDF file into separate documents based on the"
+ " specified page numbers or ranges. Users can specify pages using"
+ " individual numbers, ranges, or 'all' for every page. Input:PDF"
+ " Output:PDF Type:SIMO")
public ResponseEntity<byte[]> splitPdf(@ModelAttribute PDFWithPageNums request)
throws IOException {
PDDocument document = null;
Path zipFile = null;
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
try {
MultipartFile file = request.getFileInput();
String pages = request.getPageNumbers();
// open the pdf document
document = pdfDocumentFactory.load(file);
// PdfMetadata metadata = PdfMetadataService.extractMetadataFromPdf(document);
int totalPages = document.getNumberOfPages();
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
if (!pageNumbers.contains(totalPages - 1)) {
// Create a mutable ArrayList so we can add to it
pageNumbers = new ArrayList<>(pageNumbers);
pageNumbers.add(totalPages - 1);
}
log.info(
"Splitting PDF into pages: {}",
pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(",")));
// split the document
splitDocumentsBoas = new ArrayList<>();
int previousPageNumber = 0;
for (int splitPoint : pageNumbers) {
try (PDDocument splitDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
for (int i = previousPageNumber; i <= splitPoint; i++) {
PDPage page = document.getPage(i);
splitDocument.addPage(page);
log.info("Adding page {} to split document", i);
}
previousPageNumber = splitPoint + 1;
// Transfer metadata to split pdf
// PdfMetadataService.setMetadataToPdf(splitDocument, metadata);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
} catch (Exception e) {
log.error("Failed splitting documents and saving them", e);
throw e;
}
}
// closing the original document
document.close();
zipFile = Files.createTempFile("split_documents", ".zip");
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
// loop through the split documents and write them to the zip file
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
String fileName = filename + "_" + (i + 1) + ".pdf";
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
byte[] pdf = baos.toByteArray();
// Add PDF file to the zip
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
log.info("Wrote split document {} to zip file", fileName);
}
} catch (Exception e) {
log.error("Failed writing to zip", e);
throw e;
}
log.info("Successfully created zip file with split documents: {}", zipFile.toString());
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
// return the Resource in the response
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", 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();
}
}
// Delete temporary zip file
if (zipFile != null) {
Files.deleteIfExists(zipFile);
}
} catch (Exception e) {
log.error("Error while cleaning up resources", e);
}
}
}
}
@@ -0,0 +1,310 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
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.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.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.PdfMetadata;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
@RestController
@RequestMapping("/api/v1/general")
@Slf4j
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class SplitPdfByChaptersController {
private final PdfMetadataService pdfMetadataService;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static List<Bookmark> extractOutlineItems(
PDDocument sourceDocument,
PDOutlineItem current,
List<Bookmark> bookmarks,
PDOutlineItem nextParent,
int level,
int maxLevel)
throws Exception {
while (current != null) {
String currentTitle = current.getTitle().replace("/", "");
int firstPage =
sourceDocument.getPages().indexOf(current.findDestinationPage(sourceDocument));
PDOutlineItem child = current.getFirstChild();
PDOutlineItem nextSibling = current.getNextSibling();
int endPage;
if (child != null && level < maxLevel) {
endPage =
sourceDocument
.getPages()
.indexOf(child.findDestinationPage(sourceDocument));
} else if (nextSibling != null) {
endPage =
sourceDocument
.getPages()
.indexOf(nextSibling.findDestinationPage(sourceDocument));
} else if (nextParent != null) {
endPage =
sourceDocument
.getPages()
.indexOf(nextParent.findDestinationPage(sourceDocument));
} else {
endPage = -2;
/*
happens when we have something like this:
Outline Item 2
Outline Item 2.1
Outline Item 2.1.1
Outline Item 2.2
Outline 2.2.1
Outline 2.2.2 <--- this item neither has an immediate next parent nor an immediate next sibling
Outline Item 3
*/
}
if (!bookmarks.isEmpty()
&& bookmarks.get(bookmarks.size() - 1).getEndPage() == -2
&& firstPage
>= bookmarks
.get(bookmarks.size() - 1)
.getStartPage()) { // for handling the above-mentioned case
Bookmark previousBookmark = bookmarks.get(bookmarks.size() - 1);
previousBookmark.setEndPage(firstPage);
}
bookmarks.add(new Bookmark(currentTitle, firstPage, endPage));
// Recursively process children
if (child != null && level < maxLevel) {
extractOutlineItems(
sourceDocument, child, bookmarks, nextSibling, level + 1, maxLevel);
}
current = nextSibling;
}
return bookmarks;
}
@PostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data")
@Operation(
summary = "Split PDFs by Chapters",
description = "Splits a PDF into chapters and returns a ZIP file.")
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfByChaptersRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
PDDocument sourceDocument = null;
Path zipFile = null;
try {
boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata());
Integer bookmarkLevel =
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
if (bookmarkLevel < 0) {
throw new IllegalArgumentException("Invalid bookmark level");
}
sourceDocument = pdfDocumentFactory.load(file);
PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline();
if (outline == null) {
log.warn("No outline found for {}", file.getOriginalFilename());
throw new IllegalArgumentException("No outline found");
}
List<Bookmark> bookmarks = new ArrayList<>();
try {
bookmarks =
extractOutlineItems(
sourceDocument,
outline.getFirstChild(),
bookmarks,
outline.getFirstChild().getNextSibling(),
0,
bookmarkLevel);
// to handle last page edge case
bookmarks.get(bookmarks.size() - 1).setEndPage(sourceDocument.getNumberOfPages());
Bookmark lastBookmark = bookmarks.get(bookmarks.size() - 1);
} catch (Exception e) {
log.error("Unable to extract outline items", e);
return ResponseEntity.internalServerError()
.body("Unable to extract outline items".getBytes());
}
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
if (!allowDuplicates) {
/*
duplicates are generated when multiple bookmarks correspond to the same page,
if the user doesn't want duplicates mergeBookmarksThatCorrespondToSamePage() method will merge the titles of all
the bookmarks that correspond to the same page, and treat them as a single bookmark
*/
bookmarks = mergeBookmarksThatCorrespondToSamePage(bookmarks);
}
for (Bookmark bookmark : bookmarks) {
log.info(
"{}::::{} to {}",
bookmark.getTitle(),
bookmark.getStartPage(),
bookmark.getEndPage());
}
List<ByteArrayOutputStream> splitDocumentsBoas =
getSplitDocumentsBoas(sourceDocument, bookmarks, includeMetadata);
zipFile = createZipFile(bookmarks, splitDocumentsBoas);
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
sourceDocument.close();
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} finally {
try {
if (sourceDocument != null) {
sourceDocument.close();
}
if (zipFile != null) {
Files.deleteIfExists(zipFile);
}
} catch (Exception e) {
log.error("Error while cleaning up resources", e);
}
}
}
private List<Bookmark> mergeBookmarksThatCorrespondToSamePage(List<Bookmark> bookmarks) {
String mergedTitle = "";
List<Bookmark> chaptersToBeRemoved = new ArrayList<>();
for (Bookmark bookmark : bookmarks) {
if (bookmark.getStartPage() == bookmark.getEndPage()) {
mergedTitle = mergedTitle.concat(bookmark.getTitle().concat(" "));
chaptersToBeRemoved.add(bookmark);
} else {
if (!mergedTitle.isEmpty()) {
if (mergedTitle.length() > 255) {
mergedTitle = mergedTitle.substring(0, 253) + "...";
}
bookmarks.set(
bookmarks.indexOf(bookmark),
new Bookmark(
mergedTitle, bookmark.getStartPage(), bookmark.getEndPage()));
}
mergedTitle = "";
}
}
bookmarks.removeAll(chaptersToBeRemoved);
return bookmarks;
}
private Path createZipFile(
List<Bookmark> bookmarks, List<ByteArrayOutputStream> splitDocumentsBoas)
throws Exception {
Path zipFile = Files.createTempFile("split_documents", ".zip");
String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d ";
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf"
String fileName =
String.format(fileNumberFormatter, i)
+ bookmarks.get(i).getTitle()
+ ".pdf";
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
byte[] pdf = baos.toByteArray();
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
log.info("Wrote split document {} to zip file", fileName);
}
} catch (Exception e) {
log.error("Failed writing to zip", e);
throw e;
}
log.info("Successfully created zip file with split documents: {}", zipFile);
return zipFile;
}
public List<ByteArrayOutputStream> getSplitDocumentsBoas(
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
PdfMetadata metadata = null;
if (includeMetadata) {
metadata = pdfMetadataService.extractMetadataFromPdf(sourceDocument);
}
for (Bookmark bookmark : bookmarks) {
try (PDDocument splitDocument = new PDDocument()) {
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
for (int i = bookmark.getStartPage();
i < bookmark.getEndPage() + (isSinglePage ? 1 : 0);
i++) {
PDPage page = sourceDocument.getPage(i);
splitDocument.addPage(page);
log.info("Adding page {} to split document", i);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (includeMetadata) {
pdfMetadataService.setMetadataToPdf(splitDocument, metadata);
}
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
} catch (Exception e) {
log.error("Failed splitting documents and saving them", e);
throw e;
}
}
return splitDocumentsBoas;
}
}
@Data
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
class Bookmark {
private String title;
private int startPage;
private int endPage;
}
@@ -0,0 +1,162 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.multipdf.LayerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.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.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.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class SplitPdfBySectionsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data")
@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."
+ " Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
MultipartFile file = request.getFileInput();
PDDocument sourceDocument = pdfDocumentFactory.load(file);
// 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);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
if (merge) {
MergeController mergeController = new MergeController(pdfDocumentFactory);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
mergeController.mergeDocuments(splitDocuments).save(baos);
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), filename + "_split.pdf");
}
for (PDDocument doc : splitDocuments) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
doc.close();
splitDocumentsBoas.add(baos);
}
sourceDocument.close();
Path zipFile = Files.createTempFile("split_documents", ".zip");
byte[] data;
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
int pageNum = 1;
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
int sectionNum = (i % (horiz * verti)) + 1;
String fileName = filename + "_" + pageNum + "_" + sectionNum + ".pdf";
byte[] pdf = baos.toByteArray();
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
if (sectionNum == horiz * verti) pageNum++;
}
zipOut.finish();
data = Files.readAllBytes(zipFile);
return WebResponseUtils.bytesToWebResponse(
data, filename + "_split.zip", MediaType.APPLICATION_OCTET_STREAM);
} finally {
Files.deleteIfExists(zipFile);
}
}
public List<PDDocument> splitPdfPages(
PDDocument document, int horizontalDivisions, int verticalDivisions)
throws IOException {
List<PDDocument> splitDocuments = new ArrayList<>();
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;
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);
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;
// 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));
// Draw the form
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
}
splitDocuments.add(subDoc);
}
}
}
return splitDocuments;
}
}
@@ -0,0 +1,471 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
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.model.api.general.SplitPdfBySizeOrCountRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Slf4j
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class SplitPdfBySizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data")
@Operation(
summary = "Auto split PDF pages into separate documents based on size or count",
description =
"split PDF into multiple paged documents based on size/count, ie if 20 pages"
+ " and split into 5, it does 5 documents each 4 pages\r\n"
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB"
+ " (rounded so that it accepts 1.9MB but not 2.1MB) Input:PDF"
+ " Output:ZIP-PDF Type:SISO")
public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute SplitPdfBySizeOrCountRequest request)
throws Exception {
log.debug("Starting PDF split process with request: {}", request);
MultipartFile file = request.getFileInput();
Path zipFile = Files.createTempFile("split_documents", ".zip");
log.debug("Created temporary zip file: {}", zipFile);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
log.debug("Base filename for output: {}", filename);
byte[] data = null;
try {
log.debug("Reading input file bytes");
byte[] pdfBytes = file.getBytes();
log.debug("Successfully read {} bytes from input file", pdfBytes.length);
log.debug("Creating ZIP output stream");
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
log.debug("Loading PDF document");
try (PDDocument sourceDocument = pdfDocumentFactory.load(pdfBytes)) {
log.debug(
"Successfully loaded PDF with {} pages",
sourceDocument.getNumberOfPages());
int type = request.getSplitType();
String value = request.getSplitValue();
log.debug("Split type: {}, Split value: {}", type, value);
if (type == 0) {
log.debug("Processing split by size");
long maxBytes = GeneralUtils.convertSizeToBytes(value);
log.debug("Max bytes per document: {}", maxBytes);
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
} else if (type == 1) {
log.debug("Processing split by page count");
int pageCount = Integer.parseInt(value);
log.debug("Pages per document: {}", pageCount);
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
} else if (type == 2) {
log.debug("Processing split by document count");
int documentCount = Integer.parseInt(value);
log.debug("Total number of documents: {}", documentCount);
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
} else {
log.error("Invalid split type: {}", type);
throw new IllegalArgumentException(
"Invalid argument for split type: " + type);
}
log.debug("PDF splitting completed successfully");
} catch (Exception e) {
log.error("Error loading or processing PDF document", e);
throw e;
}
} catch (IOException e) {
log.error("Error creating or writing to ZIP file", e);
throw e;
}
} catch (Exception e) {
log.error("Exception during PDF splitting process", e);
throw e; // Re-throw to ensure proper error response
} finally {
try {
log.debug("Reading ZIP file data");
data = Files.readAllBytes(zipFile);
log.debug("Successfully read {} bytes from ZIP file", data.length);
} catch (IOException e) {
log.error("Error reading ZIP file data", e);
}
try {
log.debug("Deleting temporary ZIP file");
boolean deleted = Files.deleteIfExists(zipFile);
log.debug("Temporary ZIP file deleted: {}", deleted);
} catch (IOException e) {
log.error("Error deleting temporary ZIP file", e);
}
}
log.debug("Returning response with {} bytes of data", data != null ? data.length : 0);
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
}
private void handleSplitBySize(
PDDocument sourceDocument, long maxBytes, ZipOutputStream zipOut, String baseFilename)
throws IOException {
log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
PDDocument currentDoc =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
int fileIndex = 1;
int totalPages = sourceDocument.getNumberOfPages();
int pageAdded = 0;
// Smart size check frequency - check more often with larger documents
int baseCheckFrequency = 5;
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage page = sourceDocument.getPage(pageIndex);
log.debug("Processing page {} of {}", pageIndex + 1, totalPages);
// Add the page to current document
PDPage newPage = new PDPage(page.getCOSObject());
currentDoc.addPage(newPage);
pageAdded++;
// Dynamic size checking based on document size and page count
boolean shouldCheckSize =
(pageAdded % baseCheckFrequency == 0)
|| (pageIndex == totalPages - 1)
|| (pageAdded >= 20); // Always check after 20 pages
if (shouldCheckSize) {
log.debug("Performing size check after {} pages", pageAdded);
ByteArrayOutputStream checkSizeStream = new ByteArrayOutputStream();
currentDoc.save(checkSizeStream);
long actualSize = checkSizeStream.size();
log.debug("Current document size: {} bytes (max: {} bytes)", actualSize, maxBytes);
if (actualSize > maxBytes) {
// We exceeded the limit - remove the last page and save
if (currentDoc.getNumberOfPages() > 1) {
currentDoc.removePage(currentDoc.getNumberOfPages() - 1);
pageIndex--; // Process this page again in the next document
log.debug("Size limit exceeded - removed last page");
}
log.debug(
"Saving document with {} pages as part {}",
currentDoc.getNumberOfPages(),
fileIndex);
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
currentDoc = new PDDocument();
pageAdded = 0;
} else if (pageIndex < totalPages - 1) {
// We're under the limit, calculate if we might fit more pages
// Try to predict how many more similar pages might fit
if (actualSize < maxBytes * 0.75 && pageAdded > 0) {
// Rather than using a ratio, look ahead to test actual upcoming pages
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1);
if (pagesToLookAhead > 0) {
log.debug(
"Testing {} upcoming pages for potential addition",
pagesToLookAhead);
// Create a temp document with current pages + look-ahead pages
PDDocument testDoc = new PDDocument();
// First copy existing pages
for (int i = 0; i < currentDoc.getNumberOfPages(); i++) {
testDoc.addPage(new PDPage(currentDoc.getPage(i).getCOSObject()));
}
// Try adding look-ahead pages one by one
int extraPagesAdded = 0;
for (int i = 0; i < pagesToLookAhead; i++) {
int testPageIndex = pageIndex + 1 + i;
PDPage testPage = sourceDocument.getPage(testPageIndex);
testDoc.addPage(new PDPage(testPage.getCOSObject()));
// Check if we're still under size
ByteArrayOutputStream testStream = new ByteArrayOutputStream();
testDoc.save(testStream);
long testSize = testStream.size();
if (testSize <= maxBytes) {
extraPagesAdded++;
log.debug(
"Test: Can add page {} (size would be {})",
testPageIndex + 1,
testSize);
} else {
log.debug(
"Test: Cannot add page {} (size would be {})",
testPageIndex + 1,
testSize);
break;
}
}
testDoc.close();
// Add the pages we verified would fit
if (extraPagesAdded > 0) {
log.debug("Adding {} verified pages ahead", extraPagesAdded);
for (int i = 0; i < extraPagesAdded; i++) {
int extraPageIndex = pageIndex + 1 + i;
PDPage extraPage = sourceDocument.getPage(extraPageIndex);
currentDoc.addPage(new PDPage(extraPage.getCOSObject()));
}
pageIndex += extraPagesAdded;
pageAdded += extraPagesAdded;
}
}
}
}
}
}
// Save final document if it has any pages
if (currentDoc.getNumberOfPages() > 0) {
log.debug(
"Saving final document with {} pages as part {}",
currentDoc.getNumberOfPages(),
fileIndex);
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
}
log.debug("Completed handleSplitBySize with {} document parts created", fileIndex - 1);
}
private void handleSplitByPageCount(
PDDocument sourceDocument, int pageCount, ZipOutputStream zipOut, String baseFilename)
throws IOException {
log.debug("Starting handleSplitByPageCount with pageCount={}", pageCount);
int currentPageCount = 0;
log.debug("Creating initial output document");
PDDocument currentDoc = null;
try {
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
log.debug("Successfully created initial output document");
} catch (Exception e) {
log.error("Error creating initial output document", e);
throw new IOException("Failed to create initial output document", e);
}
int fileIndex = 1;
int pageIndex = 0;
int totalPages = sourceDocument.getNumberOfPages();
log.debug("Processing {} pages", totalPages);
try {
for (PDPage page : sourceDocument.getPages()) {
pageIndex++;
log.debug("Processing page {} of {}", pageIndex, totalPages);
try {
log.debug("Adding page {} to current document", pageIndex);
currentDoc.addPage(page);
log.debug("Successfully added page {} to current document", pageIndex);
} catch (Exception e) {
log.error("Error adding page {} to current document", pageIndex, e);
throw new IOException("Failed to add page to document", e);
}
currentPageCount++;
log.debug("Current page count: {}/{}", currentPageCount, pageCount);
if (currentPageCount == pageCount) {
log.debug(
"Reached target page count ({}), saving current document as part {}",
pageCount,
fileIndex);
try {
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
log.debug("Successfully saved document part {}", fileIndex - 1);
} catch (Exception e) {
log.error("Error saving document part {}", fileIndex - 1, e);
throw e;
}
try {
log.debug("Creating new document for next part");
currentDoc = new PDDocument();
log.debug("Successfully created new document");
} catch (Exception e) {
log.error("Error creating new document for next part", e);
throw new IOException("Failed to create new document", e);
}
currentPageCount = 0;
log.debug("Reset current page count to 0");
}
}
} catch (Exception e) {
log.error("Error iterating through pages", e);
throw new IOException("Failed to iterate through pages", e);
}
// Add the last document if it contains any pages
try {
if (currentDoc.getPages().getCount() != 0) {
log.debug(
"Saving final document with {} pages as part {}",
currentDoc.getPages().getCount(),
fileIndex);
try {
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
log.debug("Successfully saved final document part {}", fileIndex - 1);
} catch (Exception e) {
log.error("Error saving final document part {}", fileIndex - 1, e);
throw e;
}
} else {
log.debug("Final document has no pages, skipping");
}
} catch (Exception e) {
log.error("Error checking or saving final document", e);
throw new IOException("Failed to process final document", e);
} finally {
try {
log.debug("Closing final document");
currentDoc.close();
log.debug("Successfully closed final document");
} catch (Exception e) {
log.error("Error closing final document", e);
}
}
log.debug("Completed handleSplitByPageCount with {} document parts created", fileIndex - 1);
}
private void handleSplitByDocCount(
PDDocument sourceDocument,
int documentCount,
ZipOutputStream zipOut,
String baseFilename)
throws IOException {
log.debug("Starting handleSplitByDocCount with documentCount={}", documentCount);
int totalPageCount = sourceDocument.getNumberOfPages();
log.debug("Total pages in source document: {}", totalPageCount);
int pagesPerDocument = totalPageCount / documentCount;
int extraPages = totalPageCount % documentCount;
log.debug("Pages per document: {}, Extra pages: {}", pagesPerDocument, extraPages);
int currentPageIndex = 0;
int fileIndex = 1;
for (int i = 0; i < documentCount; i++) {
log.debug("Creating document {} of {}", i + 1, documentCount);
PDDocument currentDoc = null;
try {
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
log.debug("Successfully created document {} of {}", i + 1, documentCount);
} catch (Exception e) {
log.error("Error creating document {} of {}", i + 1, documentCount, e);
throw new IOException("Failed to create document", e);
}
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
log.debug("Adding {} pages to document {}", pagesToAdd, i + 1);
for (int j = 0; j < pagesToAdd; j++) {
try {
log.debug(
"Adding page {} (index {}) to document {}",
j + 1,
currentPageIndex,
i + 1);
currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
log.debug("Successfully added page {} to document {}", j + 1, i + 1);
currentPageIndex++;
} catch (Exception e) {
log.error("Error adding page {} to document {}", j + 1, i + 1, e);
throw new IOException("Failed to add page to document", e);
}
}
try {
log.debug("Saving document {} with {} pages", i + 1, pagesToAdd);
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
log.debug("Successfully saved document {}", i + 1);
} catch (Exception e) {
log.error("Error saving document {}", i + 1, e);
throw e;
}
}
log.debug("Completed handleSplitByDocCount with {} documents created", documentCount);
}
private void saveDocumentToZip(
PDDocument document, ZipOutputStream zipOut, String baseFilename, int index)
throws IOException {
log.debug("Starting saveDocumentToZip for document part {}", index);
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
try {
log.debug("Saving document part {} to byte array", index);
document.save(outStream);
log.debug("Successfully saved document part {} ({} bytes)", index, outStream.size());
} catch (Exception e) {
log.error("Error saving document part {} to byte array", index, e);
throw new IOException("Failed to save document to byte array", e);
}
try {
log.debug("Closing document part {}", index);
document.close();
log.debug("Successfully closed document part {}", index);
} catch (Exception e) {
log.error("Error closing document part {}", index, e);
// Continue despite close error
}
try {
// Create a new zip entry
String entryName = baseFilename + "_" + index + ".pdf";
log.debug("Creating ZIP entry: {}", entryName);
ZipEntry zipEntry = new ZipEntry(entryName);
zipOut.putNextEntry(zipEntry);
byte[] bytes = outStream.toByteArray();
log.debug("Writing {} bytes to ZIP entry", bytes.length);
zipOut.write(bytes);
log.debug("Closing ZIP entry");
zipOut.closeEntry();
log.debug("Successfully added document part {} to ZIP", index);
} catch (Exception e) {
log.error("Error adding document part {} to ZIP", index, e);
throw new IOException("Failed to add document to ZIP file", e);
}
}
}
@@ -0,0 +1,97 @@
package stirling.software.SPDF.controller.api;
import java.awt.geom.AffineTransform;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.multipdf.LayerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.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 stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class ToSinglePageController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page")
@Operation(
summary = "Convert a multi-page PDF into a single long page PDF",
description =
"This endpoint converts a multi-page PDF document into a single paged PDF"
+ " document. The width of the single page will be same as the input's"
+ " width, but the height will be the sum of all the pages' heights."
+ " Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> pdfToSinglePage(@ModelAttribute PDFFile request)
throws IOException {
// Load the source document
PDDocument sourceDocument = pdfDocumentFactory.load(request);
// Calculate total height and max width
float totalHeight = 0;
float maxWidth = 0;
for (PDPage page : sourceDocument.getPages()) {
PDRectangle pageSize = page.getMediaBox();
totalHeight += pageSize.getHeight();
maxWidth = Math.max(maxWidth, pageSize.getWidth());
}
// Create new document and page with calculated dimensions
PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
PDPage newPage = new PDPage(new PDRectangle(maxWidth, totalHeight));
newDocument.addPage(newPage);
// Initialize the content stream of the new page
PDPageContentStream contentStream = new PDPageContentStream(newDocument, newPage);
contentStream.close();
LayerUtility layerUtility = new LayerUtility(newDocument);
float yOffset = totalHeight;
// For each page, copy its content to the new page at the correct offset
for (PDPage page : sourceDocument.getPages()) {
PDFormXObject form =
layerUtility.importPageAsForm(
sourceDocument, sourceDocument.getPages().indexOf(page));
AffineTransform af =
AffineTransform.getTranslateInstance(
0, yOffset - page.getMediaBox().getHeight());
layerUtility.wrapInSaveRestore(newPage);
String defaultLayerName = "Layer" + sourceDocument.getPages().indexOf(page);
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
yOffset -= page.getMediaBox().getHeight();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
newDocument.close();
sourceDocument.close();
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
request.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_singlePage.pdf");
}
}
@@ -0,0 +1,157 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.jetbrains.annotations.NotNull;
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 lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.EmlToPdf;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@Slf4j
@RequiredArgsConstructor
public class ConvertEmlToPDF {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
@PostMapping(consumes = "multipart/form-data", value = "/eml/pdf")
@Operation(
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")
public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
MultipartFile inputFile = request.getFileInput();
String originalFilename = inputFile.getOriginalFilename();
// Validate input
if (inputFile.isEmpty()) {
log.error("No file provided for EML to PDF conversion.");
return ResponseEntity.badRequest()
.body("No file provided".getBytes(StandardCharsets.UTF_8));
}
if (originalFilename == null || originalFilename.trim().isEmpty()) {
log.error("Filename is null or empty.");
return ResponseEntity.badRequest()
.body("Please provide a valid filename".getBytes(StandardCharsets.UTF_8));
}
// Validate file type - support EML
String lowerFilename = originalFilename.toLowerCase();
if (!lowerFilename.endsWith(".eml")) {
log.error("Invalid file type for EML to PDF: {}", originalFilename);
return ResponseEntity.badRequest()
.body("Please upload a valid EML file".getBytes(StandardCharsets.UTF_8));
}
String baseFilename = Filenames.toSimpleFileName(originalFilename); // Use Filenames utility
try {
byte[] fileBytes = inputFile.getBytes();
if (request.isDownloadHtml()) {
try {
String htmlContent = EmlToPdf.convertEmlToHtml(fileBytes, request);
log.info("Successfully converted EML to HTML: {}", originalFilename);
return WebResponseUtils.bytesToWebResponse(
htmlContent.getBytes(StandardCharsets.UTF_8),
baseFilename + ".html",
MediaType.TEXT_HTML);
} catch (IOException | IllegalArgumentException e) {
log.error("HTML conversion failed for {}", originalFilename, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
("HTML conversion failed: " + e.getMessage())
.getBytes(StandardCharsets.UTF_8));
}
}
// Convert EML to PDF with enhanced options
try {
byte[] pdfBytes =
EmlToPdf.convertEmlToPdf(
runtimePathConfig.getWeasyPrintPath(), // Use configured WeasyPrint path
request,
fileBytes,
originalFilename,
false,
pdfDocumentFactory);
if (pdfBytes == null || pdfBytes.length == 0) {
log.error("PDF conversion failed - empty output for {}", originalFilename);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
"PDF conversion failed - empty output"
.getBytes(StandardCharsets.UTF_8));
}
log.info("Successfully converted EML to PDF: {}", originalFilename);
return WebResponseUtils.bytesToWebResponse(
pdfBytes, baseFilename + ".pdf", MediaType.APPLICATION_PDF);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("EML to PDF conversion was interrupted for {}", originalFilename, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Conversion was interrupted".getBytes(StandardCharsets.UTF_8));
} catch (IllegalArgumentException e) {
String errorMessage = buildErrorMessage(e, originalFilename);
log.error("EML to PDF conversion failed for {}: {}", originalFilename, errorMessage, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorMessage.getBytes(StandardCharsets.UTF_8));
} catch (RuntimeException e) {
String errorMessage = buildErrorMessage(e, originalFilename);
log.error("EML to PDF conversion failed for {}: {}", originalFilename, errorMessage, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorMessage.getBytes(StandardCharsets.UTF_8));
}
} catch (IOException e) {
log.error("File processing error for EML to PDF: {}", originalFilename, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("File processing error".getBytes(StandardCharsets.UTF_8));
}
}
private static @NotNull String buildErrorMessage(Exception e, String originalFilename) {
String errorMessage;
if (e.getMessage() != null && e.getMessage().contains("Invalid EML")) {
errorMessage =
"Invalid EML file format. Please ensure you've uploaded a valid email"
+ " file ("
+ originalFilename
+ ").";
} else if (e.getMessage() != null && e.getMessage().contains("WeasyPrint")) {
errorMessage =
"PDF generation failed for "
+ originalFilename
+ ". This may be due to complex email formatting.";
} else {
errorMessage = "Conversion failed for " + originalFilename + ": " + e.getMessage();
}
return errorMessage;
}
}
@@ -0,0 +1,75 @@
package stirling.software.SPDF.controller.api.converters;
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.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.WebResponseUtils;
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@RequestMapping("/api/v1/convert")
@RequiredArgsConstructor
public class ConvertHtmlToPDF {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ApplicationProperties applicationProperties;
private final RuntimePathConfig runtimePathConfig;
@PostMapping(consumes = "multipart/form-data", value = "/html/pdf")
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
description =
"This endpoint takes an HTML or ZIP file input and converts it to a PDF format."
+ " Input:HTML Output:PDF Type:SISO")
public ResponseEntity<byte[]> HtmlToPdf(@ModelAttribute HTMLToPdfRequest request)
throws Exception {
MultipartFile fileInput = request.getFileInput();
if (fileInput == null) {
throw new IllegalArgumentException(
"Please provide an HTML or ZIP file for conversion.");
}
String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
if (originalFilename == null
|| (!originalFilename.endsWith(".html") && !originalFilename.endsWith(".zip"))) {
throw new IllegalArgumentException("File must be either .html or .zip format.");
}
boolean disableSanitize =
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
byte[] pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
request,
fileInput.getBytes(),
originalFilename,
disableSanitize);
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
String outputFilename =
originalFilename.replaceFirst("[.][^.]+$", "")
+ ".pdf"; // Remove file extension and append .pdf
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
}
}
@@ -0,0 +1,283 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
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.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.model.api.converters.ConvertToImageRequest;
import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Slf4j
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
public class ConvertImgPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf/img")
@Operation(
summary = "Convert PDF to image(s)",
description =
"This endpoint converts a PDF file to image(s) with the specified image format,"
+ " color type, and DPI. Users can choose to get a single image or multiple"
+ " images. Input:PDF Output:Image Type:SI-Conditional")
public ResponseEntity<byte[]> convertToImage(@ModelAttribute ConvertToImageRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
String imageFormat = request.getImageFormat();
String singleOrMultiple = request.getSingleOrMultiple();
String colorType = request.getColorType();
int dpi = request.getDpi();
String pageNumbers = request.getPageNumbers();
Path tempFile = null;
Path tempOutputDir = null;
Path tempPdfPath = null;
byte[] result = null;
String[] pageOrderArr =
(pageNumbers != null && !pageNumbers.trim().isEmpty())
? pageNumbers.split(",")
: new String[] {"all"};
;
try {
// Load the input PDF
byte[] newPdfBytes = rearrangePdfPages(file, pageOrderArr);
ImageType colorTypeResult = ImageType.RGB;
if ("greyscale".equals(colorType)) {
colorTypeResult = ImageType.GRAY;
} else if ("blackwhite".equals(colorType)) {
colorTypeResult = ImageType.BINARY;
}
// returns bytes for image
boolean singleImage = "single".equals(singleOrMultiple);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
result =
PdfUtils.convertFromPdf(
pdfDocumentFactory,
newPdfBytes,
"webp".equalsIgnoreCase(imageFormat)
? "png"
: imageFormat.toUpperCase(),
colorTypeResult,
singleImage,
dpi,
filename);
if (result == null || result.length == 0) {
log.error("resultant bytes for {} is null, error converting ", filename);
}
if ("webp".equalsIgnoreCase(imageFormat) && !CheckProgramInstall.isPythonAvailable()) {
throw new IOException("Python is not installed. Required for WebP conversion.");
} else if ("webp".equalsIgnoreCase(imageFormat)
&& CheckProgramInstall.isPythonAvailable()) {
// Write the output stream to a temp file
tempFile = Files.createTempFile("temp_png", ".png");
try (FileOutputStream fos = new FileOutputStream(tempFile.toFile())) {
fos.write(result);
fos.flush();
}
String pythonVersion = CheckProgramInstall.getAvailablePythonCommand();
List<String> command = new ArrayList<>();
command.add(pythonVersion);
command.add("./scripts/png_to_webp.py"); // Python script to handle the conversion
// Create a temporary directory for the output WebP files
tempOutputDir = Files.createTempDirectory("webp_output");
if (singleImage) {
// Run the Python script to convert PNG to WebP
command.add(tempFile.toString());
command.add(tempOutputDir.toString());
command.add("--single");
} else {
// Save the uploaded PDF to a temporary file
tempPdfPath = Files.createTempFile("temp_pdf", ".pdf");
file.transferTo(tempPdfPath.toFile());
// Run the Python script to convert PDF to WebP
command.add(tempPdfPath.toString());
command.add(tempOutputDir.toString());
}
command.add("--dpi");
command.add(String.valueOf(dpi));
ProcessExecutorResult resultProcess =
ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV)
.runCommandWithOutputHandling(command);
// Find all WebP files in the output directory
List<Path> webpFiles =
Files.walk(tempOutputDir)
.filter(path -> path.toString().endsWith(".webp"))
.toList();
if (webpFiles.isEmpty()) {
log.error("No WebP files were created in: {}", tempOutputDir.toString());
throw new IOException(
"No WebP files were created. " + resultProcess.getMessages());
}
byte[] bodyBytes = new byte[0];
if (webpFiles.size() == 1) {
// Return the single WebP file directly
Path webpFilePath = webpFiles.get(0);
bodyBytes = Files.readAllBytes(webpFilePath);
} else {
// Create a ZIP file containing all WebP images
ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
for (Path webpFile : webpFiles) {
zos.putNextEntry(new ZipEntry(webpFile.getFileName().toString()));
Files.copy(webpFile, zos);
zos.closeEntry();
}
}
bodyBytes = zipOutputStream.toByteArray();
}
// Clean up the temporary files
Files.deleteIfExists(tempFile);
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
result = bodyBytes;
}
if (singleImage) {
String docName = filename + "." + imageFormat;
MediaType mediaType = MediaType.parseMediaType(getMediaType(imageFormat));
return WebResponseUtils.bytesToWebResponse(result, docName, mediaType);
} else {
String zipFilename = filename + "_convertedToImages.zip";
return WebResponseUtils.bytesToWebResponse(
result, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
}
} finally {
try {
// Clean up temporary files
if (tempFile != null) {
Files.deleteIfExists(tempFile);
}
if (tempPdfPath != null) {
Files.deleteIfExists(tempPdfPath);
}
if (tempOutputDir != null) {
FileUtils.deleteDirectory(tempOutputDir.toFile());
}
} catch (Exception e) {
log.error("Error cleaning up temporary files", e);
}
}
}
@PostMapping(consumes = "multipart/form-data", value = "/img/pdf")
@Operation(
summary = "Convert images to a PDF file",
description =
"This endpoint converts one or more images to a PDF file. Users can specify"
+ " whether to stretch the images to fit the PDF page, and whether to"
+ " automatically rotate the images. Input:Image Output:PDF Type:MISO")
public ResponseEntity<byte[]> convertToPdf(@ModelAttribute ConvertToPdfRequest request)
throws IOException {
MultipartFile[] file = request.getFileInput();
String fitOption = request.getFitOption();
String colorType = request.getColorType();
boolean autoRotate = Boolean.TRUE.equals(request.getAutoRotate());
// Handle Null entries for formdata
if (colorType == null || colorType.isBlank()) {
colorType = "color";
}
if (fitOption == null || fitOption.isEmpty()) {
fitOption = "fillPage";
}
// Convert the file to PDF and get the resulting bytes
byte[] bytes =
PdfUtils.imageToPdf(file, fitOption, autoRotate, colorType, pdfDocumentFactory);
return WebResponseUtils.bytesToWebResponse(
bytes,
file[0].getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_converted.pdf");
}
private String getMediaType(String imageFormat) {
String mimeType = URLConnection.guessContentTypeFromName("." + imageFormat);
return "null".equals(mimeType) ? "application/octet-stream" : mimeType;
}
/**
* Rearranges the pages of the given PDF document based on the specified page order.
*
* @param pdfBytes The byte array of the original PDF file.
* @param pageOrderArr An array of page numbers indicating the new order.
* @return A byte array of the rearranged PDF.
* @throws IOException If an error occurs while processing the PDF.
*/
private byte[] rearrangePdfPages(MultipartFile pdfFile, String[] pageOrderArr)
throws IOException {
// Load the input PDF
PDDocument document = pdfDocumentFactory.load(pdfFile);
int totalPages = document.getNumberOfPages();
List<Integer> newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
// Create a new list to hold the pages in the new order
List<PDPage> newPages = new ArrayList<>();
for (int pageIndex : newPageOrder) {
newPages.add(document.getPage(pageIndex));
}
// Remove all the pages from the original document
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
document.removePage(i);
}
// Add the pages in the new order
for (PDPage page : newPages) {
document.addPage(page);
}
// Convert PDDocument to byte array
byte[] newPdfBytes;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
document.save(baos);
newPdfBytes = baos.toByteArray();
} finally {
document.close();
}
return newPdfBytes;
}
}
@@ -0,0 +1,101 @@
package stirling.software.SPDF.controller.api.converters;
import java.util.List;
import java.util.Map;
import org.commonmark.Extension;
import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.AttributeProvider;
import org.commonmark.renderer.html.HtmlRenderer;
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.common.model.api.GeneralFile;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.WebResponseUtils;
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@RequestMapping("/api/v1/convert")
@RequiredArgsConstructor
public class ConvertMarkdownToPdf {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ApplicationProperties applicationProperties;
private final RuntimePathConfig runtimePathConfig;
@PostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
@Operation(
summary = "Convert a Markdown file to PDF",
description =
"This endpoint takes a Markdown file input, converts it to HTML, and then to"
+ " PDF format. Input:MARKDOWN Output:PDF Type:SISO")
public ResponseEntity<byte[]> markdownToPdf(@ModelAttribute GeneralFile generalFile)
throws Exception {
MultipartFile fileInput = generalFile.getFileInput();
if (fileInput == null) {
throw new IllegalArgumentException("Please provide a Markdown file for conversion.");
}
String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
if (originalFilename == null || !originalFilename.endsWith(".md")) {
throw new IllegalArgumentException("File must be in .md format.");
}
// Convert Markdown to HTML using CommonMark
List<Extension> extensions = List.of(TablesExtension.create());
Parser parser = Parser.builder().extensions(extensions).build();
Node document = parser.parse(new String(fileInput.getBytes()));
HtmlRenderer renderer =
HtmlRenderer.builder()
.attributeProviderFactory(context -> new TableAttributeProvider())
.extensions(extensions)
.build();
String htmlContent = renderer.render(document);
boolean disableSanitize =
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
byte[] pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlContent.getBytes(),
"converted.html",
disableSanitize);
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
String outputFilename =
originalFilename.replaceFirst("[.][^.]+$", "")
+ ".pdf"; // Remove file extension and append .pdf
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
}
}
class TableAttributeProvider implements AttributeProvider {
@Override
public void setAttributes(Node node, String tagName, Map<String, String> attributes) {
if (node instanceof TableBlock) {
attributes.put("class", "table table-striped");
}
}
}
@@ -0,0 +1,112 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
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.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.WebResponseUtils;
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@RequestMapping("/api/v1/convert")
@RequiredArgsConstructor
public class ConvertOfficeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
public File convertToPdf(MultipartFile inputFile) throws IOException, InterruptedException {
// Check for valid file extension
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFilename == null
|| !isValidFileExtension(FilenameUtils.getExtension(originalFilename))) {
throw new IllegalArgumentException("Invalid file extension");
}
// Save the uploaded file to a temporary location
Path tempInputFile =
Files.createTempFile("input_", "." + FilenameUtils.getExtension(originalFilename));
inputFile.transferTo(tempInputFile);
// Prepare the output file path
Path tempOutputFile = Files.createTempFile("output_", ".pdf");
try {
// Run the LibreOffice command
List<String> command =
new ArrayList<>(
Arrays.asList(
runtimePathConfig.getUnoConvertPath(),
"--port",
"2003",
"--convert-to",
"pdf",
tempInputFile.toString(),
tempOutputFile.toString()));
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
// Read the converted PDF file
return tempOutputFile.toFile();
} finally {
// Clean up the temporary files
if (tempInputFile != null) Files.deleteIfExists(tempInputFile);
}
}
private boolean isValidFileExtension(String fileExtension) {
String extensionPattern = "^(?i)[a-z0-9]{2,4}$";
return fileExtension.matches(extensionPattern);
}
@PostMapping(consumes = "multipart/form-data", value = "/file/pdf")
@Operation(
summary = "Convert a file to a PDF using LibreOffice",
description =
"This endpoint converts a given file to a PDF using LibreOffice API Input:ANY"
+ " Output:PDF Type:SISO")
public ResponseEntity<byte[]> processFileToPDF(@ModelAttribute GeneralFile generalFile)
throws Exception {
MultipartFile inputFile = generalFile.getFileInput();
// unused but can start server instance if startup time is to long
// LibreOfficeListener.getInstance().start();
File file = null;
try {
file = convertToPdf(inputFile);
PDDocument doc = pdfDocumentFactory.load(file);
return WebResponseUtils.pdfDocToWebResponse(
doc,
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_convertedToPDF.pdf");
} finally {
if (file != null) file.delete();
}
}
}
@@ -0,0 +1,31 @@
package stirling.software.SPDF.controller.api.converters;
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 stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.PDFToFile;
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@RequestMapping("/api/v1/convert")
public class ConvertPDFToHtml {
@PostMapping(consumes = "multipart/form-data", 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")
public ResponseEntity<byte[]> processPdfToHTML(@ModelAttribute PDFFile file) throws Exception {
MultipartFile inputFile = file.getFileInput();
PDFToFile pdfToFile = new PDFToFile();
return pdfToFile.processPdfToHtml(inputFile);
}
}
@@ -0,0 +1,106 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
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 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.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.PDFToFile;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
public class ConvertPDFToOffice {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/pdf/presentation")
@Operation(
summary = "Convert PDF to Presentation format",
description =
"This endpoint converts a given PDF file to a Presentation format. Input:PDF"
+ " Output:PPT Type:SISO")
public ResponseEntity<byte[]> processPdfToPresentation(
@ModelAttribute PdfToPresentationRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String outputFormat = request.getOutputFormat();
PDFToFile pdfToFile = new PDFToFile();
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
}
@PostMapping(consumes = "multipart/form-data", value = "/pdf/text")
@Operation(
summary = "Convert PDF to Text or RTF format",
description =
"This endpoint converts a given PDF file to Text or RTF format. Input:PDF"
+ " Output:TXT Type:SISO")
public ResponseEntity<byte[]> processPdfToRTForTXT(
@ModelAttribute PdfToTextOrRTFRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String outputFormat = request.getOutputFormat();
if ("txt".equals(request.getOutputFormat())) {
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
PDFTextStripper stripper = new PDFTextStripper();
String text = stripper.getText(document);
return WebResponseUtils.bytesToWebResponse(
text.getBytes(),
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ ".txt",
MediaType.TEXT_PLAIN);
}
} else {
PDFToFile pdfToFile = new PDFToFile();
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
}
}
@PostMapping(consumes = "multipart/form-data", value = "/pdf/word")
@Operation(
summary = "Convert PDF to Word document",
description =
"This endpoint converts a given PDF file to a Word document format. Input:PDF"
+ " Output:WORD Type:SISO")
public ResponseEntity<byte[]> processPdfToWord(@ModelAttribute PdfToWordRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String outputFormat = request.getOutputFormat();
PDFToFile pdfToFile = new PDFToFile();
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
}
@PostMapping(consumes = "multipart/form-data", value = "/pdf/xml")
@Operation(
summary = "Convert PDF to XML",
description =
"This endpoint converts a PDF file to an XML file. Input:PDF Output:XML"
+ " Type:SISO")
public ResponseEntity<byte[]> processPdfToXML(@ModelAttribute PDFFile file) throws Exception {
MultipartFile inputFile = file.getFileInput();
PDFToFile pdfToFile = new PDFToFile();
return pdfToFile.processPdfToOfficeFormat(inputFile, "xml", "writer_pdf_import");
}
}
@@ -0,0 +1,126 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.apache.commons.io.FileUtils;
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.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Slf4j
@Tag(name = "Convert", description = "Convert APIs")
public class ConvertPDFToPDFA {
@PostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa")
@Operation(
summary = "Convert a PDF to a PDF/A",
description =
"This endpoint converts a PDF file to a PDF/A file using LibreOffice. PDF/A is a format designed for long-term archiving of digital documents. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> pdfToPdfA(@ModelAttribute PdfToPdfARequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
String outputFormat = request.getOutputFormat();
// Validate input file type
if (!"application/pdf".equals(inputFile.getContentType())) {
log.error("Invalid input file type: {}", inputFile.getContentType());
throw new IllegalArgumentException("Input file must be a PDF");
}
// Get the original filename without extension
String originalFileName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFileName == null || originalFileName.trim().isEmpty()) {
originalFileName = "output.pdf";
}
String baseFileName =
originalFileName.contains(".")
? originalFileName.substring(0, originalFileName.lastIndexOf('.'))
: originalFileName;
Path tempInputFile = null;
Path tempOutputDir = null;
byte[] fileBytes;
try {
// Save uploaded file to temp location
tempInputFile = Files.createTempFile("input_", ".pdf");
inputFile.transferTo(tempInputFile);
// Create temp output directory
tempOutputDir = Files.createTempDirectory("output_");
// Determine PDF/A filter based on requested format
String pdfFilter =
"pdfa".equals(outputFormat)
? "pdf:writer_pdf_Export:{\"SelectPdfVersion\":{\"type\":\"long\",\"value\":\"2\"}}"
: "pdf:writer_pdf_Export:{\"SelectPdfVersion\":{\"type\":\"long\",\"value\":\"1\"}}";
// Prepare LibreOffice command
List<String> command =
new ArrayList<>(
Arrays.asList(
"soffice",
"--headless",
"--nologo",
"--convert-to",
pdfFilter,
"--outdir",
tempOutputDir.toString(),
tempInputFile.toString()));
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
if (returnCode.getRc() != 0) {
log.error("PDF/A conversion failed with return code: {}", returnCode.getRc());
throw new RuntimeException("PDF/A conversion failed");
}
// Get the output file
File[] outputFiles = tempOutputDir.toFile().listFiles();
if (outputFiles == null || outputFiles.length != 1) {
throw new RuntimeException(
"Expected exactly one output file but found "
+ (outputFiles == null ? "none" : outputFiles.length));
}
fileBytes = FileUtils.readFileToByteArray(outputFiles[0]);
String outputFilename = baseFileName + "_PDFA.pdf";
return WebResponseUtils.bytesToWebResponse(
fileBytes, outputFilename, MediaType.APPLICATION_PDF);
} finally {
// Clean up temporary files
if (tempInputFile != null) {
Files.deleteIfExists(tempInputFile);
}
if (tempOutputDir != null) {
FileUtils.deleteDirectory(tempOutputDir.toFile());
}
}
}
}
@@ -0,0 +1,108 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
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.model.api.converters.UrlToPdfRequest;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
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.WebResponseUtils;
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@Slf4j
@RequestMapping("/api/v1/convert")
@RequiredArgsConstructor
public class ConvertWebsiteToPDF {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties;
@PostMapping(consumes = "multipart/form-data", value = "/url/pdf")
@Operation(
summary = "Convert a URL to a PDF",
description =
"This endpoint fetches content from a URL and converts it to a PDF format."
+ " Input:N/A Output:PDF Type:SISO")
public ResponseEntity<byte[]> urlToPdf(@ModelAttribute UrlToPdfRequest request)
throws IOException, InterruptedException {
String URL = request.getUrlInput();
if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
throw new IllegalArgumentException("This endpoint has been disabled by the admin.");
}
// Validate the URL format
if (!URL.matches("^https?://.*") || !GeneralUtils.isValidURL(URL)) {
throw new IllegalArgumentException("Invalid URL format provided.");
}
// validate the URL is reachable
if (!GeneralUtils.isURLReachable(URL)) {
throw new IllegalArgumentException("URL is not reachable, please provide a valid URL.");
}
Path tempOutputFile = null;
PDDocument doc = null;
try {
// 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(URL);
command.add("--pdf-forms");
command.add(tempOutputFile.toString());
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)
.runCommandWithOutputHandling(command);
// Load the PDF using pdfDocumentFactory
doc = pdfDocumentFactory.load(tempOutputFile.toFile());
// Convert URL to a safe filename
String outputFilename = convertURLToFileName(URL);
return WebResponseUtils.pdfDocToWebResponse(doc, outputFilename);
} finally {
if (tempOutputFile != null) {
try {
Files.deleteIfExists(tempOutputFile);
} catch (IOException e) {
log.error("Error deleting temporary output file", e);
}
}
}
}
private String convertURLToFileName(String url) {
String safeName = url.replaceAll("[^a-zA-Z0-9]", "_");
if (safeName.length() > 50) {
safeName = safeName.substring(0, 50); // restrict to 50 characters
}
return safeName + ".pdf";
}
}
@@ -0,0 +1,134 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.QuoteMode;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
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.model.api.PDFWithPageNums;
import stirling.software.SPDF.pdf.FlexibleCSVWriter;
import stirling.software.common.service.CustomPDFDocumentFactory;
import technology.tabula.ObjectExtractor;
import technology.tabula.Page;
import technology.tabula.Table;
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@Slf4j
@RequiredArgsConstructor
public class ExtractCSVController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/pdf/csv", consumes = "multipart/form-data")
@Operation(
summary = "Extracts a CSV document from a PDF",
description =
"This operation takes an input PDF file and returns CSV file of whole page."
+ " Input:PDF Output:CSV Type:SISO")
public ResponseEntity<?> pdfToCsv(@ModelAttribute PDFWithPageNums request) throws Exception {
String baseName = getBaseName(request.getFileInput().getOriginalFilename());
List<CsvEntry> csvEntries = new ArrayList<>();
try (PDDocument document = pdfDocumentFactory.load(request)) {
List<Integer> pages = request.getPageNumbersList(document, true);
SpreadsheetExtractionAlgorithm sea = new SpreadsheetExtractionAlgorithm();
CSVFormat format =
CSVFormat.EXCEL.builder().setEscape('"').setQuoteMode(QuoteMode.ALL).build();
for (int pageNum : pages) {
try (ObjectExtractor extractor = new ObjectExtractor(document)) {
log.info("{}", pageNum);
Page page = extractor.extract(pageNum);
List<Table> tables = sea.extract(page);
for (int i = 0; i < tables.size(); i++) {
StringWriter sw = new StringWriter();
FlexibleCSVWriter csvWriter = new FlexibleCSVWriter(format);
csvWriter.write(sw, Collections.singletonList(tables.get(i)));
String entryName = generateEntryName(baseName, pageNum, i + 1);
csvEntries.add(new CsvEntry(entryName, sw.toString()));
}
}
}
if (csvEntries.isEmpty()) {
return ResponseEntity.noContent().build();
} else if (csvEntries.size() == 1) {
return createCsvResponse(csvEntries.get(0), baseName);
} else {
return createZipResponse(csvEntries, baseName);
}
}
}
private ResponseEntity<byte[]> createZipResponse(List<CsvEntry> entries, String baseName)
throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zipOut = new ZipOutputStream(baos)) {
for (CsvEntry entry : entries) {
ZipEntry zipEntry = new ZipEntry(entry.filename());
zipOut.putNextEntry(zipEntry);
zipOut.write(entry.content().getBytes(StandardCharsets.UTF_8));
zipOut.closeEntry();
}
}
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(
ContentDisposition.builder("attachment")
.filename(baseName + "_extracted.zip")
.build());
headers.setContentType(MediaType.parseMediaType("application/zip"));
return ResponseEntity.ok().headers(headers).body(baos.toByteArray());
}
private ResponseEntity<String> createCsvResponse(CsvEntry entry, String baseName) {
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(
ContentDisposition.builder("attachment")
.filename(baseName + "_extracted.csv")
.build());
headers.setContentType(MediaType.parseMediaType("text/csv"));
return ResponseEntity.ok().headers(headers).body(entry.content());
}
private String generateEntryName(String baseName, int pageNum, int tableIndex) {
return String.format("%s_p%d_t%d.csv", baseName, pageNum, tableIndex);
}
private String getBaseName(String filename) {
return filename.replaceFirst("[.][^.]+$", "");
}
private record CsvEntry(String filename, String content) {}
}
@@ -0,0 +1,217 @@
package stirling.software.SPDF.controller.api.filters;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
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.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.service.CustomPDFDocumentFactory;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/filter")
@Tag(name = "Filter", description = "Filter APIs")
@RequiredArgsConstructor
public class FilterController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/filter-contains-text")
@Operation(
summary = "Checks if a PDF contains set text, returns true if does",
description = "Input:PDF Output:Boolean Type:SISO")
public ResponseEntity<byte[]> containsText(@ModelAttribute ContainsTextRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String text = request.getText();
String pageNumber = request.getPageNumbers();
PDDocument pdfDocument = pdfDocumentFactory.load(inputFile);
if (PdfUtils.hasText(pdfDocument, pageNumber, text))
return WebResponseUtils.pdfDocToWebResponse(
pdfDocument, Filenames.toSimpleFileName(inputFile.getOriginalFilename()));
return null;
}
// TODO
@PostMapping(consumes = "multipart/form-data", value = "/filter-contains-image")
@Operation(
summary = "Checks if a PDF contains an image",
description = "Input:PDF Output:Boolean Type:SISO")
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;
}
@PostMapping(consumes = "multipart/form-data", 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")
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();
boolean valid = false;
// Perform the comparison
switch (comparator) {
case "Greater":
valid = actualPageCount > pageCount;
break;
case "Equal":
valid = actualPageCount == pageCount;
break;
case "Less":
valid = actualPageCount < pageCount;
break;
default:
throw new IllegalArgumentException("Invalid comparator: " + comparator);
}
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
return null;
}
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-size")
@Operation(
summary = "Checks if a PDF is of a certain size",
description = "Input:PDF Output:Boolean Type:SISO")
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);
PDPage firstPage = document.getPage(0);
PDRectangle actualPageSize = firstPage.getMediaBox();
// Calculate the area of the actual page size
float actualArea = actualPageSize.getWidth() * actualPageSize.getHeight();
// Get the standard size and calculate its area
PDRectangle standardSize = PdfUtils.textToPageSize(standardPageSize);
float standardArea = standardSize.getWidth() * standardSize.getHeight();
boolean valid = false;
// Perform the comparison
switch (comparator) {
case "Greater":
valid = actualArea > standardArea;
break;
case "Equal":
valid = actualArea == standardArea;
break;
case "Less":
valid = actualArea < standardArea;
break;
default:
throw new IllegalArgumentException("Invalid comparator: " + comparator);
}
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
return null;
}
@PostMapping(consumes = "multipart/form-data", value = "/filter-file-size")
@Operation(
summary = "Checks if a PDF is a set file size",
description = "Input:PDF Output:Boolean Type:SISO")
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 = false;
// Perform the comparison
switch (comparator) {
case "Greater":
valid = actualFileSize > fileSize;
break;
case "Equal":
valid = actualFileSize == fileSize;
break;
case "Less":
valid = actualFileSize < fileSize;
break;
default:
throw new IllegalArgumentException("Invalid comparator: " + comparator);
}
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
return null;
}
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation")
@Operation(
summary = "Checks if a PDF is of a certain rotation",
description = "Input:PDF Output:Boolean Type:SISO")
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);
// Get the rotation of the first page
PDPage firstPage = document.getPage(0);
int actualRotation = firstPage.getRotation();
boolean valid = false;
// Perform the comparison
switch (comparator) {
case "Greater":
valid = actualRotation > rotation;
break;
case "Equal":
valid = actualRotation == rotation;
break;
case "Less":
valid = actualRotation < rotation;
break;
default:
throw new IllegalArgumentException("Invalid comparator: " + comparator);
}
if (valid) return WebResponseUtils.multiPartFileToWebResponse(inputFile);
return null;
}
}
@@ -0,0 +1,145 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
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.model.api.misc.ExtractHeaderRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class AutoRenameController {
// private static final float TITLE_FONT_SIZE_THRESHOLD = 20.0f;
private static final int LINE_LIMIT = 200;
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/auto-rename")
@Operation(
summary = "Extract header from PDF file",
description =
"This endpoint accepts a PDF file and attempts to extract its title or header"
+ " based on heuristics. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> extractHeader(@ModelAttribute ExtractHeaderRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
boolean useFirstTextAsFallback = Boolean.TRUE.equals(request.getUseFirstTextAsFallback());
PDDocument document = pdfDocumentFactory.load(file);
PDFTextStripper reader =
new PDFTextStripper() {
List<LineInfo> lineInfos = new ArrayList<>();
StringBuilder lineBuilder = new StringBuilder();
float lastY = -1;
float maxFontSizeInLine = 0.0f;
int lineCount = 0;
@Override
protected void processTextPosition(TextPosition text) {
if (lastY != text.getY() && lineCount < LINE_LIMIT) {
processLine();
lineBuilder = new StringBuilder(text.getUnicode());
maxFontSizeInLine = text.getFontSizeInPt();
lastY = text.getY();
lineCount++;
} else if (lineCount < LINE_LIMIT) {
lineBuilder.append(text.getUnicode());
if (text.getFontSizeInPt() > maxFontSizeInLine) {
maxFontSizeInLine = text.getFontSizeInPt();
}
}
}
private void processLine() {
if (lineBuilder.length() > 0 && lineCount < LINE_LIMIT) {
lineInfos.add(new LineInfo(lineBuilder.toString(), maxFontSizeInLine));
}
}
@Override
public String getText(PDDocument doc) throws IOException {
this.lineInfos.clear();
this.lineBuilder = new StringBuilder();
this.lastY = -1;
this.maxFontSizeInLine = 0.0f;
this.lineCount = 0;
super.getText(doc);
processLine(); // Process the last line
// Merge lines with same font size
List<LineInfo> mergedLineInfos = new ArrayList<>();
for (int i = 0; i < lineInfos.size(); i++) {
String mergedText = lineInfos.get(i).text;
float fontSize = lineInfos.get(i).fontSize;
while (i + 1 < lineInfos.size()
&& lineInfos.get(i + 1).fontSize == fontSize) {
mergedText += " " + lineInfos.get(i + 1).text;
i++;
}
mergedLineInfos.add(new LineInfo(mergedText, fontSize));
}
// Sort lines by font size in descending order and get the first one
mergedLineInfos.sort(
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
String title =
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
return title != null
? title
: (useFirstTextAsFallback
? (mergedLineInfos.isEmpty()
? null
: mergedLineInfos.get(mergedLineInfos.size() - 1)
.text)
: null);
}
class LineInfo {
String text;
float fontSize;
LineInfo(String text, float fontSize) {
this.text = text;
this.fontSize = fontSize;
}
}
};
String header = reader.getText(document);
// Sanitize the header string by removing characters not allowed in a filename.
if (header != null && header.length() < 255) {
header = header.replaceAll("[/\\\\?%*:|\"<>]", "").trim();
return WebResponseUtils.pdfDocToWebResponse(document, header + ".pdf");
} else {
log.info("File has no good title to be found");
return WebResponseUtils.pdfDocToWebResponse(
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
}
}
}
@@ -0,0 +1,210 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.awt.image.DataBufferInt;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
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 org.springframework.web.multipart.MultipartFile;
import com.google.zxing.*;
import com.google.zxing.common.HybridBinarizer;
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.model.api.misc.AutoSplitPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class AutoSplitPdfController {
private static final Set<String> VALID_QR_CONTENTS =
new HashSet<>(
Set.of(
"https://github.com/Stirling-Tools/Stirling-PDF",
"https://github.com/Frooodle/Stirling-PDF",
"https://stirlingpdf.com"));
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static String decodeQRCode(BufferedImage bufferedImage) {
LuminanceSource source;
if (bufferedImage.getRaster().getDataBuffer() instanceof DataBufferByte dataBufferByte) {
byte[] pixels = dataBufferByte.getData();
source =
new PlanarYUVLuminanceSource(
pixels,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
0,
0,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
false);
} else if (bufferedImage.getRaster().getDataBuffer()
instanceof DataBufferInt dataBufferInt) {
int[] pixels = dataBufferInt.getData();
byte[] newPixels = new byte[pixels.length];
for (int i = 0; i < pixels.length; i++) {
newPixels[i] = (byte) (pixels[i] & 0xff);
}
source =
new PlanarYUVLuminanceSource(
newPixels,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
0,
0,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
false);
} else {
throw new IllegalArgumentException(
"BufferedImage must have 8-bit gray scale, 24-bit RGB, 32-bit ARGB (packed"
+ " int), byte gray, or 3-byte/4-byte RGB image data");
}
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
try {
Result result = new MultiFormatReader().decode(bitmap);
return result.getText();
} catch (NotFoundException e) {
return null; // there is no QR code in the image
}
}
@PostMapping(value = "/auto-split-pdf", consumes = "multipart/form-data")
@Operation(
summary = "Auto split PDF pages into separate documents",
description =
"This endpoint accepts a PDF file, scans each page for a specific QR code, and"
+ " splits the document at the QR code boundaries. The output is a zip file"
+ " containing each separate PDF document. Input:PDF Output:ZIP-PDF"
+ " Type:SISO")
public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute AutoSplitPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
boolean duplexMode = Boolean.TRUE.equals(request.getDuplexMode());
PDDocument document = null;
List<PDDocument> splitDocuments = new ArrayList<>();
Path zipFile = null;
byte[] data = null;
try {
document = pdfDocumentFactory.load(file.getInputStream());
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
for (int page = 0; page < document.getNumberOfPages(); ++page) {
BufferedImage bim = pdfRenderer.renderImageWithDPI(page, 150);
String result = decodeQRCode(bim);
boolean isValidQrCode = VALID_QR_CONTENTS.contains(result);
log.debug("detected qr code {}, code is vale={}", result, isValidQrCode);
if (isValidQrCode && page != 0) {
splitDocuments.add(new PDDocument());
}
if (!splitDocuments.isEmpty() && !isValidQrCode) {
splitDocuments.get(splitDocuments.size() - 1).addPage(document.getPage(page));
} else if (page == 0) {
PDDocument firstDocument = new PDDocument();
firstDocument.addPage(document.getPage(page));
splitDocuments.add(firstDocument);
}
// If duplexMode is true and current page is a divider, then skip next page
if (duplexMode && isValidQrCode) {
page++;
}
}
// Remove split documents that have no pages
splitDocuments.removeIf(pdDocument -> pdDocument.getNumberOfPages() == 0);
zipFile = Files.createTempFile("split_documents", ".zip");
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
for (int i = 0; i < splitDocuments.size(); i++) {
String fileName = filename + "_" + (i + 1) + ".pdf";
PDDocument splitDocument = splitDocuments.get(i);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
splitDocument.save(baos);
byte[] pdf = baos.toByteArray();
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
}
}
data = Files.readAllBytes(zipFile);
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (Exception e) {
log.error("Error in auto split", e);
throw e;
} finally {
// Clean up resources
if (document != null) {
try {
document.close();
} catch (IOException e) {
log.error("Error closing main PDDocument", e);
}
}
for (PDDocument splitDoc : splitDocuments) {
try {
splitDoc.close();
} catch (IOException e) {
log.error("Error closing split PDDocument", e);
}
}
if (zipFile != null) {
try {
Files.deleteIfExists(zipFile);
} catch (IOException e) {
log.error("Error deleting temporary zip file", e);
}
}
}
}
}
@@ -0,0 +1,170 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
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 lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.RemoveBlankPagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class BlankPageController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
public static boolean isBlankImage(
BufferedImage image, int threshold, double whitePercent, int blurSize) {
if (image == null) {
log.info("Error: Image is null");
return false;
}
// Convert to binary image based on the threshold
int whitePixels = 0;
int totalPixels = image.getWidth() * image.getHeight();
for (int i = 0; i < image.getHeight(); i++) {
for (int j = 0; j < image.getWidth(); j++) {
int color = image.getRGB(j, i) & 0xFF;
if (color >= 255 - threshold) {
whitePixels++;
}
}
}
double whitePixelPercentage = (whitePixels / (double) totalPixels) * 100;
log.info(String.format("Page has white pixel percent of %.2f%%", whitePixelPercentage));
return whitePixelPercentage >= whitePercent;
}
@PostMapping(consumes = "multipart/form-data", value = "/remove-blanks")
@Operation(
summary = "Remove blank pages from a PDF file",
description =
"This endpoint removes blank pages from a given PDF file. Users can specify the"
+ " threshold and white percentage to tune the detection of blank pages."
+ " Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> removeBlankPages(@ModelAttribute RemoveBlankPagesRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
int threshold = request.getThreshold();
float whitePercent = request.getWhitePercent();
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
PDPageTree pages = document.getDocumentCatalog().getPages();
PDFTextStripper textStripper = new PDFTextStripper();
List<PDPage> nonBlankPages = new ArrayList<>();
List<PDPage> blankPages = new ArrayList<>();
int pageIndex = 0;
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
for (PDPage page : pages) {
log.info("checking page {}", pageIndex);
textStripper.setStartPage(pageIndex + 1);
textStripper.setEndPage(pageIndex + 1);
String pageText = textStripper.getText(document);
boolean hasText = !pageText.trim().isEmpty();
boolean blank = true;
if (hasText) {
log.info("page {} has text, not blank", pageIndex);
blank = false;
} else {
boolean hasImages = PdfUtils.hasImagesOnPage(page);
if (hasImages) {
log.info("page {} has image, running blank detection", pageIndex);
// Render image and save as temp file
BufferedImage image = pdfRenderer.renderImageWithDPI(pageIndex, 30);
blank = isBlankImage(image, threshold, whitePercent, threshold);
}
}
if (blank) {
log.info("Skipping, Image was blank for page #{}", pageIndex);
blankPages.add(page);
} else {
log.info("page {} has image which is not blank", pageIndex);
nonBlankPages.add(page);
}
pageIndex++;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ZipOutputStream zos = new ZipOutputStream(baos);
String filename =
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
if (!nonBlankPages.isEmpty()) {
createZipEntry(zos, nonBlankPages, filename + "_nonBlankPages.pdf");
} else {
createZipEntry(zos, blankPages, filename + "_allBlankPages.pdf");
}
if (!nonBlankPages.isEmpty() && !blankPages.isEmpty()) {
createZipEntry(zos, blankPages, filename + "_blankPages.pdf");
}
zos.close();
log.info("Returning ZIP file: {}", filename + "_processed.zip");
return WebResponseUtils.boasToWebResponse(
baos, filename + "_processed.zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (IOException e) {
log.error("exception", e);
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
public void createZipEntry(ZipOutputStream zos, List<PDPage> pages, String entryName)
throws IOException {
try (PDDocument document = pdfDocumentFactory.createNewDocument()) {
for (PDPage page : pages) {
document.addPage(page);
}
ZipEntry zipEntry = new ZipEntry(entryName);
zos.putNextEntry(zipEntry);
document.save(zos);
zos.closeEntry();
}
}
}
@@ -0,0 +1,873 @@
package stirling.software.SPDF.controller.api.misc;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
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.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.plugins.jpeg.JPEGImageWriteParam;
import javax.imageio.stream.ImageOutputStream;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
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 stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
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.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
public class CompressController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final boolean qpdfEnabled;
public CompressController(
CustomPDFDocumentFactory pdfDocumentFactory,
EndpointConfiguration endpointConfiguration) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.qpdfEnabled = endpointConfiguration.isGroupEnabled("qpdf");
}
@Data
@AllArgsConstructor
@NoArgsConstructor
private static class ImageReference {
int pageNum; // Page number where the image appears
COSName name; // The name used to reference this image
}
@Data
@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
@NoArgsConstructor
private static class NestedImageReference extends ImageReference {
COSName formName; // Name of the form XObject containing the image
COSName imageName; // Name of the image within the form
}
// Tracks compression stats for reporting
private static class CompressionStats {
int totalImages = 0;
int nestedImages = 0;
int uniqueImagesCount = 0;
int compressedImages = 0;
int skippedImages = 0;
long totalOriginalBytes = 0;
long totalCompressedBytes = 0;
}
public Path compressImagesInPDF(
Path pdfFile, double scaleFactor, float jpegQuality, boolean convertToGrayscale)
throws Exception {
Path newCompressedPDF = Files.createTempFile("compressedPDF", ".pdf");
long originalFileSize = Files.size(pdfFile);
log.info(
"Starting image compression with scale factor: {}, JPEG quality: {}, grayscale: {} on file size: {}",
scaleFactor,
jpegQuality,
convertToGrayscale,
GeneralUtils.formatBytes(originalFileSize));
try (PDDocument doc = pdfDocumentFactory.load(pdfFile)) {
// Find all unique images in the document
Map<String, List<ImageReference>> uniqueImages = findImages(doc);
// Get statistics
CompressionStats stats = new CompressionStats();
stats.uniqueImagesCount = uniqueImages.size();
calculateImageStats(uniqueImages, stats);
// Create compressed versions of unique images
Map<String, PDImageXObject> compressedVersions =
createCompressedImages(
doc, uniqueImages, scaleFactor, jpegQuality, convertToGrayscale, stats);
// Replace all instances with compressed versions
replaceImages(doc, uniqueImages, compressedVersions, stats);
// Log compression statistics
logCompressionStats(stats, originalFileSize);
// Free memory before saving
compressedVersions.clear();
uniqueImages.clear();
log.info("Saving compressed PDF to {}", newCompressedPDF.toString());
doc.save(newCompressedPDF.toString());
// Log overall file size reduction
long compressedFileSize = Files.size(newCompressedPDF);
double overallReduction = 100.0 - ((compressedFileSize * 100.0) / originalFileSize);
log.info(
"Overall PDF compression: {} → {} (reduced by {}%)",
GeneralUtils.formatBytes(originalFileSize),
GeneralUtils.formatBytes(compressedFileSize),
String.format("%.1f", overallReduction));
return newCompressedPDF;
}
}
// Find all images in the document, both direct and nested within forms
private Map<String, List<ImageReference>> findImages(PDDocument doc) throws IOException {
Map<String, List<ImageReference>> uniqueImages = new HashMap<>();
// Scan through all pages in the document
for (int pageNum = 0; pageNum < doc.getNumberOfPages(); pageNum++) {
PDPage page = doc.getPage(pageNum);
PDResources res = page.getResources();
if (res == null || res.getXObjectNames() == null) continue;
// Process all XObjects on the page
for (COSName name : res.getXObjectNames()) {
PDXObject xobj = res.getXObject(name);
// Direct image
if (isImage(xobj)) {
addDirectImage(pageNum, name, (PDImageXObject) xobj, uniqueImages);
log.info(
"Found direct image '{}' on page {} - {}x{}",
name.getName(),
pageNum + 1,
((PDImageXObject) xobj).getWidth(),
((PDImageXObject) xobj).getHeight());
}
// Form XObject that may contain nested images
else if (isForm(xobj)) {
checkFormForImages(pageNum, name, (PDFormXObject) xobj, uniqueImages);
}
}
}
return uniqueImages;
}
private boolean isImage(PDXObject xobj) {
return xobj instanceof PDImageXObject;
}
private boolean isForm(PDXObject xobj) {
return xobj instanceof PDFormXObject;
}
private ImageReference addDirectImage(
int pageNum,
COSName name,
PDImageXObject image,
Map<String, List<ImageReference>> uniqueImages)
throws IOException {
ImageReference ref = new ImageReference();
ref.pageNum = pageNum;
ref.name = name;
String imageHash = generateImageHash(image);
uniqueImages.computeIfAbsent(imageHash, k -> new ArrayList<>()).add(ref);
return ref;
}
// Look for images inside form XObjects
private void checkFormForImages(
int pageNum,
COSName formName,
PDFormXObject formXObj,
Map<String, List<ImageReference>> uniqueImages)
throws IOException {
PDResources formResources = formXObj.getResources();
if (formResources == null || formResources.getXObjectNames() == null) {
return;
}
log.info(
"Checking form XObject '{}' on page {} for nested images",
formName.getName(),
pageNum + 1);
// Process all XObjects within the form
for (COSName nestedName : formResources.getXObjectNames()) {
PDXObject nestedXobj = formResources.getXObject(nestedName);
if (isImage(nestedXobj)) {
PDImageXObject nestedImage = (PDImageXObject) nestedXobj;
log.info(
"Found nested image '{}' in form '{}' on page {} - {}x{}",
nestedName.getName(),
formName.getName(),
pageNum + 1,
nestedImage.getWidth(),
nestedImage.getHeight());
// Create specialized reference for the nested image
NestedImageReference nestedRef = new NestedImageReference();
nestedRef.pageNum = pageNum;
nestedRef.formName = formName;
nestedRef.imageName = nestedName;
String imageHash = generateImageHash(nestedImage);
uniqueImages.computeIfAbsent(imageHash, k -> new ArrayList<>()).add(nestedRef);
}
}
}
// Count total images and nested images
private void calculateImageStats(
Map<String, List<ImageReference>> uniqueImages, CompressionStats stats) {
for (List<ImageReference> references : uniqueImages.values()) {
for (ImageReference ref : references) {
stats.totalImages++;
if (ref instanceof NestedImageReference) {
stats.nestedImages++;
}
}
}
}
// Create compressed versions of all unique images
private Map<String, PDImageXObject> createCompressedImages(
PDDocument doc,
Map<String, List<ImageReference>> uniqueImages,
double scaleFactor,
float jpegQuality,
boolean convertToGrayscale,
CompressionStats stats)
throws IOException {
Map<String, PDImageXObject> compressedVersions = new HashMap<>();
// Process each unique image exactly once
for (Entry<String, List<ImageReference>> entry : uniqueImages.entrySet()) {
String imageHash = entry.getKey();
List<ImageReference> references = entry.getValue();
if (references.isEmpty()) continue;
// Get the first instance of this image
PDImageXObject originalImage = getOriginalImage(doc, references.get(0));
// Track original size
int originalSize = (int) originalImage.getCOSObject().getLength();
stats.totalOriginalBytes += originalSize;
// Process this unique image
PDImageXObject compressedImage =
compressImage(
doc,
originalImage,
originalSize,
scaleFactor,
jpegQuality,
convertToGrayscale);
if (compressedImage != null) {
// Store the compressed version in our map
compressedVersions.put(imageHash, compressedImage);
stats.compressedImages++;
// Update compression stats
int compressedSize = (int) compressedImage.getCOSObject().getLength();
stats.totalCompressedBytes += compressedSize * references.size();
double reductionPercentage = 100.0 - ((compressedSize * 100.0) / originalSize);
log.info(
"Image hash {}: Compressed from {} to {} (reduced by {}%)",
imageHash,
GeneralUtils.formatBytes(originalSize),
GeneralUtils.formatBytes(compressedSize),
String.format("%.1f", reductionPercentage));
} else {
log.info("Image hash {}: Not suitable for compression, skipping", imageHash);
stats.totalCompressedBytes += originalSize * references.size();
stats.skippedImages++;
}
}
return compressedVersions;
}
// Get original image from a reference
private PDImageXObject getOriginalImage(PDDocument doc, ImageReference ref) throws IOException {
if (ref instanceof NestedImageReference) {
// Get the nested image from within a form XObject
NestedImageReference nestedRef = (NestedImageReference) ref;
PDPage page = doc.getPage(nestedRef.pageNum);
PDResources pageResources = page.getResources();
// Get the form XObject
PDFormXObject formXObj = (PDFormXObject) pageResources.getXObject(nestedRef.formName);
// Get the nested image from the form's resources
PDResources formResources = formXObj.getResources();
return (PDImageXObject) formResources.getXObject(nestedRef.imageName);
} else {
// Get direct image from page resources
PDPage page = doc.getPage(ref.pageNum);
PDResources resources = page.getResources();
return (PDImageXObject) resources.getXObject(ref.name);
}
}
// Try to compress an image if it makes sense
private PDImageXObject compressImage(
PDDocument doc,
PDImageXObject originalImage,
int originalSize,
double scaleFactor,
float jpegQuality,
boolean convertToGrayscale)
throws IOException {
// Process and compress the image
BufferedImage processedImage =
processAndCompressImage(
originalImage, scaleFactor, jpegQuality, convertToGrayscale);
if (processedImage == null) {
return null;
}
// Convert to bytes for storage
byte[] compressedData = convertToBytes(processedImage, jpegQuality);
// Check if compression is beneficial
if (compressedData.length < originalSize || convertToGrayscale) {
// Create a compressed version
return PDImageXObject.createFromByteArray(
doc, compressedData, originalImage.getCOSObject().toString());
}
return null;
}
// Replace all instances of original images with their compressed versions
private void replaceImages(
PDDocument doc,
Map<String, List<ImageReference>> uniqueImages,
Map<String, PDImageXObject> compressedVersions,
CompressionStats stats)
throws IOException {
for (Entry<String, List<ImageReference>> entry : uniqueImages.entrySet()) {
String imageHash = entry.getKey();
List<ImageReference> references = entry.getValue();
// Skip if no compressed version exists
PDImageXObject compressedImage = compressedVersions.get(imageHash);
if (compressedImage == null) continue;
// Replace ALL instances with the compressed version
for (ImageReference ref : references) {
replaceImageReference(doc, ref, compressedImage);
}
}
}
// Replace a specific image reference with a compressed version
private void replaceImageReference(
PDDocument doc, ImageReference ref, PDImageXObject compressedImage) throws IOException {
if (ref instanceof NestedImageReference) {
// Replace nested image within form XObject
NestedImageReference nestedRef = (NestedImageReference) ref;
PDPage page = doc.getPage(nestedRef.pageNum);
PDResources pageResources = page.getResources();
// Get the form XObject
PDFormXObject formXObj = (PDFormXObject) pageResources.getXObject(nestedRef.formName);
// Replace the nested image in the form's resources
PDResources formResources = formXObj.getResources();
formResources.put(nestedRef.imageName, compressedImage);
log.info(
"Replaced nested image '{}' in form '{}' on page {} with compressed version",
nestedRef.imageName.getName(),
nestedRef.formName.getName(),
nestedRef.pageNum + 1);
} else {
// Replace direct image in page resources
PDPage page = doc.getPage(ref.pageNum);
PDResources resources = page.getResources();
resources.put(ref.name, compressedImage);
log.info("Replaced direct image on page {} with compressed version", ref.pageNum + 1);
}
}
// Log final stats about the compression
private void logCompressionStats(CompressionStats stats, long originalFileSize) {
// Calculate image reduction percentage
double overallImageReduction =
stats.totalOriginalBytes > 0
? 100.0 - ((stats.totalCompressedBytes * 100.0) / stats.totalOriginalBytes)
: 0;
int duplicatedImages = stats.totalImages - stats.uniqueImagesCount;
log.info(
"Image compression summary - Total unique: {}, Compressed: {}, Skipped: {}, Duplicates: {}, Nested: {}",
stats.uniqueImagesCount,
stats.compressedImages,
stats.skippedImages,
duplicatedImages,
stats.nestedImages);
log.info(
"Total original image size: {}, compressed: {} (reduced by {}%)",
GeneralUtils.formatBytes(stats.totalOriginalBytes),
GeneralUtils.formatBytes(stats.totalCompressedBytes),
String.format("%.1f", overallImageReduction));
}
private BufferedImage convertToGrayscale(BufferedImage image) {
BufferedImage grayImage =
new BufferedImage(
image.getWidth(), image.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g = grayImage.createGraphics();
g.drawImage(image, 0, 0, null);
g.dispose();
return grayImage;
}
// Resize and optionally convert to grayscale
private BufferedImage processAndCompressImage(
PDImageXObject image, double scaleFactor, float jpegQuality, boolean convertToGrayscale)
throws IOException {
BufferedImage bufferedImage = image.getImage();
int originalWidth = bufferedImage.getWidth();
int originalHeight = bufferedImage.getHeight();
// Minimum dimensions to preserve reasonable quality
int MIN_WIDTH = 400;
int MIN_HEIGHT = 400;
log.info("Original dimensions: {}x{}", originalWidth, originalHeight);
// Skip if already small enough
if ((originalWidth <= MIN_WIDTH || originalHeight <= MIN_HEIGHT) && !convertToGrayscale) {
log.info("Skipping - below minimum dimensions threshold");
return null;
}
// Convert to grayscale first if requested (before resizing for better quality)
if (convertToGrayscale) {
bufferedImage = convertToGrayscale(bufferedImage);
log.info("Converted image to grayscale");
}
// Adjust scale factor for very large or very small images
double adjustedScaleFactor = scaleFactor;
if (originalWidth > 3000 || originalHeight > 3000) {
// More aggressive for very large images
adjustedScaleFactor = Math.min(scaleFactor, 0.75);
log.info("Very large image, using more aggressive scale: {}", adjustedScaleFactor);
} else if (originalWidth < 1000 || originalHeight < 1000) {
// More conservative for smaller images
adjustedScaleFactor = Math.max(scaleFactor, 0.9);
log.info("Smaller image, using conservative scale: {}", adjustedScaleFactor);
}
int newWidth = (int) (originalWidth * adjustedScaleFactor);
int newHeight = (int) (originalHeight * adjustedScaleFactor);
// Ensure minimum dimensions
newWidth = Math.max(newWidth, MIN_WIDTH);
newHeight = Math.max(newHeight, MIN_HEIGHT);
// Skip if change is negligible
if ((double) newWidth / originalWidth > 0.95
&& (double) newHeight / originalHeight > 0.95
&& !convertToGrayscale) {
log.info("Change too small, skipping compression");
return null;
}
log.info(
"Resizing to {}x{} ({}% of original)",
newWidth, newHeight, Math.round((newWidth * 100.0) / originalWidth));
BufferedImage scaledImage;
if (convertToGrayscale) {
// If already grayscale, maintain the grayscale format
scaledImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_BYTE_GRAY);
} else {
// Otherwise use original color model
scaledImage =
new BufferedImage(
newWidth,
newHeight,
bufferedImage.getColorModel().hasAlpha()
? BufferedImage.TYPE_INT_ARGB
: BufferedImage.TYPE_INT_RGB);
}
Graphics2D g2d = scaledImage.createGraphics();
g2d.setRenderingHint(
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.drawImage(bufferedImage, 0, 0, newWidth, newHeight, null);
g2d.dispose();
return scaledImage;
}
// Convert image to byte array with quality settings
private byte[] convertToBytes(BufferedImage scaledImage, float jpegQuality) throws IOException {
String format = scaledImage.getColorModel().hasAlpha() ? "png" : "jpeg";
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
if ("jpeg".equals(format)) {
// Get the best available JPEG writer
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
ImageWriter writer = writers.next();
JPEGImageWriteParam param = (JPEGImageWriteParam) writer.getDefaultWriteParam();
// Set compression parameters
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(jpegQuality);
param.setOptimizeHuffmanTables(true); // Better compression
param.setProgressiveMode(ImageWriteParam.MODE_DEFAULT); // Progressive scanning
// Write compressed image
try (ImageOutputStream ios = ImageIO.createImageOutputStream(outputStream)) {
writer.setOutput(ios);
writer.write(null, new IIOImage(scaledImage, null, null), param);
}
writer.dispose();
} else {
ImageIO.write(scaledImage, format, outputStream);
}
return outputStream.toByteArray();
}
// Hash function to identify identical images
private String generateImageHash(PDImageXObject image) {
try {
// Create a stream for the raw stream data
try (InputStream stream = image.getCOSObject().createRawInputStream()) {
// Read up to first 8KB of data for the hash
byte[] buffer = new byte[8192];
int bytesRead = stream.read(buffer);
if (bytesRead > 0) {
byte[] dataToHash =
bytesRead == buffer.length ? buffer : Arrays.copyOf(buffer, bytesRead);
return bytesToHexString(generatMD5(dataToHash));
}
return "empty-stream";
}
} catch (Exception e) {
log.error("Error generating image hash", e);
return "fallback-" + System.identityHashCode(image);
}
}
private String bytesToHexString(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private byte[] generatMD5(byte[] data) throws IOException {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
return md.digest(data); // Get the MD5 hash of the image bytes
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 algorithm not available", e);
}
}
// Scale factors for different optimization levels
private double getScaleFactorForLevel(int optimizeLevel) {
return switch (optimizeLevel) {
case 3 -> 0.85;
case 4 -> 0.75;
case 5 -> 0.65;
case 6 -> 0.55;
case 7 -> 0.45;
case 8 -> 0.35;
case 9 -> 0.25;
case 10 -> 0.15;
default -> 1.0;
};
}
// JPEG quality for different optimization levels
private float getJpegQualityForLevel(int optimizeLevel) {
return switch (optimizeLevel) {
case 3 -> 0.85f;
case 4 -> 0.80f;
case 5 -> 0.75f;
case 6 -> 0.70f;
case 7 -> 0.60f;
case 8 -> 0.50f;
case 9 -> 0.35f;
case 10 -> 0.2f;
default -> 0.7f;
};
}
@PostMapping(consumes = "multipart/form-data", value = "/compress-pdf")
@Operation(
summary = "Optimize PDF file",
description =
"This endpoint accepts a PDF file and optimizes it based on the provided"
+ " parameters. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> optimizePdf(@ModelAttribute OptimizePdfRequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
Integer optimizeLevel = request.getOptimizeLevel();
String expectedOutputSizeString = request.getExpectedOutputSize();
Boolean convertToGrayscale = request.getGrayscale();
if (expectedOutputSizeString == null && optimizeLevel == null) {
throw new Exception("Both expected output size and optimize level are not specified");
}
Long expectedOutputSize = 0L;
boolean autoMode = false;
if (expectedOutputSizeString != null && expectedOutputSizeString.length() > 1) {
expectedOutputSize = GeneralUtils.convertSizeToBytes(expectedOutputSizeString);
autoMode = true;
}
// Create initial input file
Path originalFile = Files.createTempFile("original_", ".pdf");
inputFile.transferTo(originalFile.toFile());
long inputFileSize = Files.size(originalFile);
Path currentFile = Files.createTempFile("working_", ".pdf");
Files.copy(originalFile, currentFile, StandardCopyOption.REPLACE_EXISTING);
// Keep track of all temporary files for cleanup
List<Path> tempFiles = new ArrayList<>();
tempFiles.add(originalFile);
tempFiles.add(currentFile);
try {
if (autoMode) {
double sizeReductionRatio = expectedOutputSize / (double) inputFileSize;
optimizeLevel = determineOptimizeLevel(sizeReductionRatio);
}
boolean sizeMet = false;
boolean imageCompressionApplied = false;
boolean qpdfCompressionApplied = false;
if (qpdfEnabled && optimizeLevel <= 3) {
optimizeLevel = 4;
}
while (!sizeMet && optimizeLevel <= 9) {
// Apply image compression for levels 4-9
if ((optimizeLevel >= 3 || Boolean.TRUE.equals(convertToGrayscale))
&& !imageCompressionApplied) {
double scaleFactor = getScaleFactorForLevel(optimizeLevel);
float jpegQuality = getJpegQualityForLevel(optimizeLevel);
// Compress images
Path compressedImageFile =
compressImagesInPDF(
currentFile,
scaleFactor,
jpegQuality,
Boolean.TRUE.equals(convertToGrayscale));
tempFiles.add(compressedImageFile);
currentFile = compressedImageFile;
imageCompressionApplied = true;
}
// Apply QPDF compression for all levels
if (!qpdfCompressionApplied && qpdfEnabled) {
applyQpdfCompression(request, optimizeLevel, currentFile, tempFiles);
qpdfCompressionApplied = true;
} else if (!qpdfCompressionApplied) {
// If QPDF is disabled, mark as applied and log
if (!qpdfEnabled) {
log.info("Skipping QPDF compression as QPDF group is disabled");
}
qpdfCompressionApplied = true;
}
// Check if target size reached or not in auto mode
long outputFileSize = Files.size(currentFile);
if (outputFileSize <= expectedOutputSize || !autoMode) {
sizeMet = true;
} else {
int newOptimizeLevel =
incrementOptimizeLevel(
optimizeLevel, outputFileSize, expectedOutputSize);
// Check if we can't increase the level further
if (newOptimizeLevel == optimizeLevel) {
if (autoMode) {
log.info(
"Maximum optimization level reached without meeting target size.");
sizeMet = true;
}
} else {
// Reset flags for next iteration with higher optimization level
imageCompressionApplied = false;
qpdfCompressionApplied = false;
optimizeLevel = newOptimizeLevel;
}
}
}
// Use original if optimized file is somehow larger
long finalFileSize = Files.size(currentFile);
if (finalFileSize >= inputFileSize) {
log.warn(
"Optimized file is larger than the original. Using the original file instead.");
currentFile = originalFile;
}
String outputFilename =
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_Optimized.pdf";
return WebResponseUtils.pdfDocToWebResponse(
pdfDocumentFactory.load(currentFile.toFile()), outputFilename);
} finally {
// Clean up all temporary files
for (Path tempFile : tempFiles) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException e) {
log.warn("Failed to delete temporary file: " + tempFile, e);
}
}
}
}
// Run QPDF compression
private void applyQpdfCompression(
OptimizePdfRequest request, int optimizeLevel, Path currentFile, List<Path> tempFiles)
throws IOException {
long preQpdfSize = Files.size(currentFile);
log.info("Pre-QPDF file size: {}", GeneralUtils.formatBytes(preQpdfSize));
// Map optimization levels to QPDF compression levels
int qpdfCompressionLevel;
if (optimizeLevel == 1) {
qpdfCompressionLevel = 5;
} else if (optimizeLevel == 2) {
qpdfCompressionLevel = 9;
} else {
qpdfCompressionLevel = 9;
}
// Create output file for QPDF
Path qpdfOutputFile = Files.createTempFile("qpdf_output_", ".pdf");
tempFiles.add(qpdfOutputFile);
// Build QPDF command
List<String> command = new ArrayList<>();
command.add("qpdf");
if (request.getNormalize()) {
command.add("--normalize-content=y");
}
if (request.getLinearize()) {
command.add("--linearize");
}
command.add("--recompress-flate");
command.add("--compression-level=" + qpdfCompressionLevel);
command.add("--compress-streams=y");
command.add("--object-streams=generate");
command.add(currentFile.toString());
command.add(qpdfOutputFile.toString());
ProcessExecutorResult returnCode = null;
try {
returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)
.runCommandWithOutputHandling(command);
// Update current file to the QPDF output
Files.copy(qpdfOutputFile, currentFile, StandardCopyOption.REPLACE_EXISTING);
long postQpdfSize = Files.size(currentFile);
double qpdfReduction = 100.0 - ((postQpdfSize * 100.0) / preQpdfSize);
log.info(
"Post-QPDF file size: {} (reduced by {}%)",
GeneralUtils.formatBytes(postQpdfSize), String.format("%.1f", qpdfReduction));
} catch (Exception e) {
if (returnCode != null && returnCode.getRc() != 3) {
throw new IOException("QPDF command failed", e);
}
// If QPDF fails, keep using the current file
log.warn("QPDF compression failed, continuing with current file", e);
}
}
// Pick optimization level based on target size
private int determineOptimizeLevel(double sizeReductionRatio) {
if (sizeReductionRatio > 0.9) return 1;
if (sizeReductionRatio > 0.8) return 2;
if (sizeReductionRatio > 0.7) return 3;
if (sizeReductionRatio > 0.6) return 4;
if (sizeReductionRatio > 0.3) return 5;
if (sizeReductionRatio > 0.2) return 6;
if (sizeReductionRatio > 0.15) return 7;
if (sizeReductionRatio > 0.1) return 8;
return 9;
}
// Increment optimization level if we need more compression
private int incrementOptimizeLevel(int currentLevel, long currentSize, long targetSize) {
double currentRatio = currentSize / (double) targetSize;
log.info("Current compression ratio: {}", String.format("%.2f", currentRatio));
if (currentRatio > 2.0) {
return Math.min(9, currentLevel + 3);
} else if (currentRatio > 1.5) {
return Math.min(9, currentLevel + 2);
}
return Math.min(9, currentLevel + 1);
}
}
@@ -0,0 +1,141 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.HashSet;
import java.util.Set;
import org.apache.pdfbox.cos.*;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.pdfwriter.compress.CompressParameters;
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.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class DecompressPdfController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/decompress-pdf", consumes = "multipart/form-data")
@Operation(
summary = "Decompress PDF streams",
description = "Fully decompresses all PDF streams including text content")
public ResponseEntity<byte[]> decompressPdf(@ModelAttribute PDFFile request)
throws IOException {
MultipartFile file = request.getFileInput();
try (PDDocument document = pdfDocumentFactory.load(file)) {
// Process all objects in document
processAllObjects(document);
// Save with explicit no compression
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos, CompressParameters.NO_COMPRESSION);
String outputFilename =
file.getOriginalFilename().replaceFirst("\\.(?=[^.]+$)", "_decompressed.");
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(), outputFilename, MediaType.APPLICATION_PDF);
}
}
private void processAllObjects(PDDocument document) {
Set<COSBase> processed = new HashSet<>();
COSDocument cosDoc = document.getDocument();
// Process all objects in the document
for (COSObjectKey key : cosDoc.getXrefTable().keySet()) {
COSObject obj = cosDoc.getObjectFromPool(key);
processObject(obj, processed);
}
}
private void processObject(COSBase obj, Set<COSBase> processed) {
// Skip null objects or already processed objects to avoid infinite recursion
if (obj == null || processed.contains(obj)) return;
processed.add(obj);
if (obj instanceof COSObject cosObj) {
processObject(cosObj.getObject(), processed);
} else if (obj instanceof COSDictionary dict) {
processDictionary(dict, processed);
} else if (obj instanceof COSArray array) {
processArray(array, processed);
}
}
private void processDictionary(COSDictionary dict, Set<COSBase> processed) {
// Process all dictionary entries
for (COSName key : dict.keySet()) {
processObject(dict.getDictionaryObject(key), processed);
}
// If this is a stream, decompress it
if (dict instanceof COSStream stream) {
decompressStream(stream);
}
}
private void processArray(COSArray array, Set<COSBase> processed) {
// Process all array elements
for (int i = 0; i < array.size(); i++) {
processObject(array.get(i), processed);
}
}
private void decompressStream(COSStream stream) {
try {
log.debug("Processing stream: {}", stream);
// Only remove filter information if it exists
if (stream.containsKey(COSName.FILTER)
|| stream.containsKey(COSName.DECODE_PARMS)
|| stream.containsKey(COSName.D)) {
// Read the decompressed content first
byte[] decompressedBytes;
try (COSInputStream is = stream.createInputStream()) {
decompressedBytes = IOUtils.toByteArray(is);
}
// Now remove filter information
stream.removeItem(COSName.FILTER);
stream.removeItem(COSName.DECODE_PARMS);
stream.removeItem(COSName.D);
// Write the raw content back
try (OutputStream out = stream.createRawOutputStream()) {
out.write(decompressedBytes);
}
// Set the Length to reflect the new stream size
stream.setInt(COSName.LENGTH, decompressedBytes.length);
}
} catch (IOException e) {
log.error("Error decompressing stream", e);
// Continue processing other streams even if this one fails
}
}
}
@@ -0,0 +1,220 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.image.BufferedImage;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
import org.apache.commons.io.FileUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
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 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.model.api.misc.ExtractImageScansRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class ExtractImageScansController {
private static final String REPLACEFIRST = "[.][^.]+$";
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/extract-image-scans")
@Operation(
summary = "Extract image scans from an input file",
description =
"This endpoint extracts image scans from a given file based on certain"
+ " parameters. Users can specify angle threshold, tolerance, minimum area,"
+ " minimum contour area, and border size. Input:PDF Output:IMAGE/ZIP"
+ " Type:SIMO")
public ResponseEntity<byte[]> extractImageScans(
@ModelAttribute ExtractImageScansRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String fileName = inputFile.getOriginalFilename();
String extension = fileName.substring(fileName.lastIndexOf(".") + 1);
List<String> images = new ArrayList<>();
List<Path> tempImageFiles = new ArrayList<>();
Path tempInputFile = null;
Path tempZipFile = null;
List<Path> tempDirs = new ArrayList<>();
if (!CheckProgramInstall.isPythonAvailable()) {
throw new IOException("Python is not installed.");
}
String pythonVersion = CheckProgramInstall.getAvailablePythonCommand();
try {
// Check if input file is a PDF
if ("pdf".equalsIgnoreCase(extension)) {
// Load PDF document
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
int pageCount = document.getNumberOfPages();
images = new ArrayList<>();
// Create images of all pages
for (int i = 0; i < pageCount; i++) {
// Create temp file to save the image
Path tempFile = Files.createTempFile("image_", ".png");
// Render image and save as temp file
BufferedImage image = pdfRenderer.renderImageWithDPI(i, 300);
ImageIO.write(image, "png", tempFile.toFile());
// Add temp file path to images list
images.add(tempFile.toString());
tempImageFiles.add(tempFile);
}
}
} else {
tempInputFile = Files.createTempFile("input_", "." + extension);
inputFile.transferTo(tempInputFile);
// Add input file path to images list
images.add(tempInputFile.toString());
}
List<byte[]> processedImageBytes = new ArrayList<>();
// Process each image
for (int i = 0; i < images.size(); i++) {
Path tempDir = Files.createTempDirectory("openCV_output");
tempDirs.add(tempDir);
List<String> command =
new ArrayList<>(
Arrays.asList(
pythonVersion,
"./scripts/split_photos.py",
images.get(i),
tempDir.toString(),
"--angle_threshold",
String.valueOf(request.getAngleThreshold()),
"--tolerance",
String.valueOf(request.getTolerance()),
"--min_area",
String.valueOf(request.getMinArea()),
"--min_contour_area",
String.valueOf(request.getMinContourArea()),
"--border_size",
String.valueOf(request.getBorderSize())));
// Run CLI command
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV)
.runCommandWithOutputHandling(command);
// Read the output photos in temp directory
List<Path> tempOutputFiles = Files.list(tempDir).sorted().toList();
for (Path tempOutputFile : tempOutputFiles) {
byte[] imageBytes = Files.readAllBytes(tempOutputFile);
processedImageBytes.add(imageBytes);
}
// Clean up the temporary directory
FileUtils.deleteDirectory(tempDir.toFile());
}
// Create zip file if multiple images
if (processedImageBytes.size() > 1) {
String outputZipFilename =
fileName.replaceFirst(REPLACEFIRST, "") + "_processed.zip";
tempZipFile = Files.createTempFile("output_", ".zip");
try (ZipOutputStream zipOut =
new ZipOutputStream(new FileOutputStream(tempZipFile.toFile()))) {
// Add processed images to the zip
for (int i = 0; i < processedImageBytes.size(); i++) {
ZipEntry entry =
new ZipEntry(
fileName.replaceFirst(REPLACEFIRST, "")
+ "_"
+ (i + 1)
+ ".png");
zipOut.putNextEntry(entry);
zipOut.write(processedImageBytes.get(i));
zipOut.closeEntry();
}
}
byte[] zipBytes = Files.readAllBytes(tempZipFile);
// Clean up the temporary zip file
Files.deleteIfExists(tempZipFile);
return WebResponseUtils.bytesToWebResponse(
zipBytes, outputZipFilename, MediaType.APPLICATION_OCTET_STREAM);
}
if (processedImageBytes.size() == 0) {
throw new IllegalArgumentException("No images detected");
} else {
// Return the processed image as a response
byte[] imageBytes = processedImageBytes.get(0);
return WebResponseUtils.bytesToWebResponse(
imageBytes,
fileName.replaceFirst(REPLACEFIRST, "") + ".png",
MediaType.IMAGE_PNG);
}
} finally {
// Cleanup logic for all temporary files and directories
tempImageFiles.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.error("Failed to delete temporary image file: " + path, e);
}
});
if (tempZipFile != null && Files.exists(tempZipFile)) {
try {
Files.deleteIfExists(tempZipFile);
} catch (IOException e) {
log.error("Failed to delete temporary zip file: " + tempZipFile, e);
}
}
tempDirs.forEach(
dir -> {
try {
FileUtils.deleteDirectory(dir.toFile());
} catch (IOException e) {
log.error("Failed to delete temporary directory: " + dir, e);
}
});
}
}
}
@@ -0,0 +1,235 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
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.model.api.PDFExtractImagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ImageProcessingUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class ExtractImagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/extract-images")
@Operation(
summary = "Extract images from a PDF file",
description =
"This endpoint extracts images from a given PDF file and returns them in a zip"
+ " file. Users can specify the output image format. Input:PDF"
+ " Output:IMAGE/ZIP Type:SIMO")
public ResponseEntity<byte[]> extractImages(@ModelAttribute PDFExtractImagesRequest request)
throws IOException, InterruptedException, ExecutionException {
MultipartFile file = request.getFileInput();
String format = request.getFormat();
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
PDDocument document = pdfDocumentFactory.load(file);
// Determine if multithreading should be used based on PDF size or number of pages
boolean useMultithreading = shouldUseMultithreading(file, document);
// Create ByteArrayOutputStream to write zip file to byte array
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// Create ZipOutputStream to create zip file
ZipOutputStream zos = new ZipOutputStream(baos);
// Set compression level
zos.setLevel(Deflater.BEST_COMPRESSION);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
Set<byte[]> processedImages = new HashSet<>();
if (useMultithreading) {
// Executor service to handle multithreading
ExecutorService executor =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
Set<Future<Void>> futures = new HashSet<>();
// Iterate over each page
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) {
PDPage page = document.getPage(pgNum);
Future<Void> future =
executor.submit(
() -> {
// Use the page number directly from the iterator, so no need to
// calculate manually
int pageNum = document.getPages().indexOf(page) + 1;
try {
// Call the image extraction method for each page
extractImagesFromPage(
page,
format,
filename,
pageNum,
processedImages,
zos,
allowDuplicates);
} catch (IOException e) {
// Log the error and continue processing other pages
log.error(
"Error extracting images from page {}: {}",
pageNum,
e.getMessage());
}
return null; // Callable requires a return type
});
// Add the Future object to the list to track completion
futures.add(future);
}
// Wait for all tasks to complete
for (Future<Void> future : futures) {
future.get();
}
// Close executor service
executor.shutdown();
} else {
// Single-threaded extraction
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) {
PDPage page = document.getPage(pgNum);
extractImagesFromPage(
page, format, filename, pgNum + 1, processedImages, zos, allowDuplicates);
}
}
// Close PDDocument and ZipOutputStream
document.close();
zos.close();
// Create ByteArrayResource from byte array
byte[] zipContents = baos.toByteArray();
return WebResponseUtils.boasToWebResponse(
baos, filename + "_extracted-images.zip", MediaType.APPLICATION_OCTET_STREAM);
}
private boolean shouldUseMultithreading(MultipartFile file, PDDocument document) {
// Criteria: Use multithreading if file size > 10MB or number of pages > 20
long fileSizeInMB = file.getSize() / (1024 * 1024);
int numberOfPages = document.getPages().getCount();
return fileSizeInMB > 10 || numberOfPages > 20;
}
private void extractImagesFromPage(
PDPage page,
String format,
String filename,
int pageNum,
Set<byte[]> processedImages,
ZipOutputStream zos,
boolean allowDuplicates)
throws IOException {
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
log.error("MD5 algorithm not available for extractImages hash.", e);
return;
}
if (page.getResources() == null || page.getResources().getXObjectNames() == null) {
return;
}
int count = 1;
for (COSName name : page.getResources().getXObjectNames()) {
if (page.getResources().isImageXObject(name)) {
PDImageXObject image = (PDImageXObject) page.getResources().getXObject(name);
if (!allowDuplicates) {
byte[] data = ImageProcessingUtils.getImageData(image.getImage());
byte[] imageHash = md.digest(data);
synchronized (processedImages) {
if (processedImages.stream()
.anyMatch(hash -> Arrays.equals(hash, imageHash))) {
continue; // Skip already processed images
}
processedImages.add(imageHash);
}
}
RenderedImage renderedImage = image.getImage();
// Convert to standard RGB colorspace if needed
BufferedImage bufferedImage = convertToRGB(renderedImage, format);
// Write image to zip file
String imageName = filename + "_page_" + pageNum + "_" + count++ + "." + format;
synchronized (zos) {
zos.putNextEntry(new ZipEntry(imageName));
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, format, imageBaos);
zos.write(imageBaos.toByteArray());
zos.closeEntry();
}
}
}
}
private BufferedImage convertToRGB(RenderedImage renderedImage, String format) {
int width = renderedImage.getWidth();
int height = renderedImage.getHeight();
BufferedImage rgbImage;
if ("png".equalsIgnoreCase(format)) {
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
} else if ("jpeg".equalsIgnoreCase(format) || "jpg".equalsIgnoreCase(format)) {
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
} else if ("gif".equalsIgnoreCase(format)) {
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_INDEXED);
} else {
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
}
Graphics2D g = rgbImage.createGraphics();
g.drawImage((Image) renderedImage, 0, 0, null);
g.dispose();
return rgbImage;
}
}
@@ -0,0 +1,440 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Random;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
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 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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.FakeScanRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous PDF APIs")
@RequiredArgsConstructor
@Slf4j
public class FakeScanController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static final Random RANDOM = new Random();
@PostMapping(value = "/fake-scan", consumes = "multipart/form-data")
@Operation(
summary = "Convert PDF to look like a scanned document",
description =
"Applies various effects to make a PDF look like it was scanned, including rotation, noise, and edge softening. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> fakeScan(@Valid @ModelAttribute FakeScanRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
// Apply preset first if needed
if (!request.isAdvancedEnabled()) {
switch (request.getQuality()) {
case high -> request.applyHighQualityPreset();
case medium -> request.applyMediumQualityPreset();
case low -> request.applyLowQualityPreset();
}
}
// Extract values after preset application
int baseRotation = request.getRotationValue() + request.getRotate();
int rotateVariance = request.getRotateVariance();
int borderPx = request.getBorder();
float brightness = request.getBrightness();
float contrast = request.getContrast();
float blur = request.getBlur();
float noise = request.getNoise();
boolean yellowish = request.isYellowish();
int resolution = request.getResolution();
FakeScanRequest.Colorspace colorspace = request.getColorspace();
try (PDDocument document = pdfDocumentFactory.load(file)) {
PDDocument outputDocument = new PDDocument();
PDFRenderer pdfRenderer = new PDFRenderer(document);
for (int i = 0; i < document.getNumberOfPages(); i++) {
// Render page to image with specified resolution
BufferedImage image = pdfRenderer.renderImageWithDPI(i, resolution);
// 1. Convert to grayscale or keep color
BufferedImage processed;
if (colorspace == FakeScanRequest.Colorspace.grayscale) {
processed =
new BufferedImage(
image.getWidth(),
image.getHeight(),
BufferedImage.TYPE_INT_RGB);
Graphics2D gGray = processed.createGraphics();
gGray.setColor(Color.BLACK);
gGray.fillRect(0, 0, image.getWidth(), image.getHeight());
gGray.drawImage(image, 0, 0, null);
gGray.dispose();
// Convert to grayscale manually
for (int y = 0; y < processed.getHeight(); y++) {
for (int x = 0; x < processed.getWidth(); x++) {
int rgb = processed.getRGB(x, y);
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
int gray = (r + g + b) / 3;
int grayRGB = (gray << 16) | (gray << 8) | gray;
processed.setRGB(x, y, grayRGB);
}
}
} else {
processed =
new BufferedImage(
image.getWidth(),
image.getHeight(),
BufferedImage.TYPE_INT_RGB);
Graphics2D gCol = processed.createGraphics();
gCol.drawImage(image, 0, 0, null);
gCol.dispose();
}
// 2. Add border with randomized grey gradient
int baseW = processed.getWidth() + 2 * borderPx;
int baseH = processed.getHeight() + 2 * borderPx;
boolean vertical = RANDOM.nextBoolean();
float startGrey = 0.6f + 0.3f * RANDOM.nextFloat();
float endGrey = 0.6f + 0.3f * RANDOM.nextFloat();
Color startColor =
new Color(
Math.round(startGrey * 255),
Math.round(startGrey * 255),
Math.round(startGrey * 255));
Color endColor =
new Color(
Math.round(endGrey * 255),
Math.round(endGrey * 255),
Math.round(endGrey * 255));
BufferedImage composed = new BufferedImage(baseW, baseH, processed.getType());
Graphics2D gBg = composed.createGraphics();
for (int y = 0; y < baseH; y++) {
for (int x = 0; x < baseW; x++) {
float frac = vertical ? (float) y / (baseH - 1) : (float) x / (baseW - 1);
int r =
Math.round(
startColor.getRed()
+ (endColor.getRed() - startColor.getRed()) * frac);
int g =
Math.round(
startColor.getGreen()
+ (endColor.getGreen() - startColor.getGreen())
* frac);
int b =
Math.round(
startColor.getBlue()
+ (endColor.getBlue() - startColor.getBlue())
* frac);
composed.setRGB(x, y, new Color(r, g, b).getRGB());
}
}
gBg.drawImage(processed, borderPx, borderPx, null);
gBg.dispose();
// 3. Rotate the entire composed image
double pageRotation = baseRotation;
if (baseRotation != 0 || rotateVariance != 0) {
pageRotation += (RANDOM.nextDouble() * 2 - 1) * rotateVariance;
}
BufferedImage rotated;
int w = composed.getWidth();
int h = composed.getHeight();
int rotW = w;
int rotH = h;
// Skip rotation entirely if no rotation is needed
if (pageRotation == 0) {
rotated = composed;
} else {
double radians = Math.toRadians(pageRotation);
double sin = Math.abs(Math.sin(radians));
double cos = Math.abs(Math.cos(radians));
rotW = (int) Math.floor(w * cos + h * sin);
rotH = (int) Math.floor(h * cos + w * sin);
BufferedImage rotatedBg = new BufferedImage(rotW, rotH, composed.getType());
Graphics2D gBgRot = rotatedBg.createGraphics();
for (int y = 0; y < rotH; y++) {
for (int x = 0; x < rotW; x++) {
float frac = vertical ? (float) y / (rotH - 1) : (float) x / (rotW - 1);
int r =
Math.round(
startColor.getRed()
+ (endColor.getRed() - startColor.getRed())
* frac);
int g =
Math.round(
startColor.getGreen()
+ (endColor.getGreen() - startColor.getGreen())
* frac);
int b =
Math.round(
startColor.getBlue()
+ (endColor.getBlue() - startColor.getBlue())
* frac);
rotatedBg.setRGB(x, y, new Color(r, g, b).getRGB());
}
}
gBgRot.dispose();
rotated = new BufferedImage(rotW, rotH, composed.getType());
Graphics2D g2d = rotated.createGraphics();
g2d.drawImage(rotatedBg, 0, 0, null);
AffineTransform at = new AffineTransform();
at.translate((rotW - w) / 2.0, (rotH - h) / 2.0);
at.rotate(radians, w / 2.0, h / 2.0);
g2d.setRenderingHint(
RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(
RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(
RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.drawImage(composed, at, null);
g2d.dispose();
}
// 4. Scale and center the rotated image to cover the original page size
PDRectangle origPageSize = document.getPage(i).getMediaBox();
float origW = origPageSize.getWidth();
float origH = origPageSize.getHeight();
float scale = Math.max(origW / rotW, origH / rotH);
float drawW = rotW * scale;
float drawH = rotH * scale;
float offsetX = (origW - drawW) / 2f;
float offsetY = (origH - drawH) / 2f;
// 5. Apply adaptive blur and edge softening
BufferedImage softened =
softenEdges(
rotated,
Math.max(10, Math.round(Math.min(rotW, rotH) * 0.02f)),
startColor,
endColor,
vertical);
BufferedImage blurred = applyGaussianBlur(softened, blur);
// 6. Adjust brightness and contrast
BufferedImage adjusted = adjustBrightnessContrast(blurred, brightness, contrast);
// 7. Add noise and yellowish effect to the content
if (yellowish) {
applyYellowishEffect(adjusted);
}
addGaussianNoise(adjusted, noise);
// 8. Write to PDF
PDPage newPage = new PDPage(new PDRectangle(origW, origH));
outputDocument.addPage(newPage);
try (PDPageContentStream contentStream =
new PDPageContentStream(outputDocument, newPage)) {
PDImageXObject pdImage =
LosslessFactory.createFromImage(outputDocument, adjusted);
contentStream.drawImage(pdImage, offsetX, offsetY, drawW, drawH);
}
}
// Save to byte array
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
outputDocument.save(outputStream);
outputDocument.close();
String outputFilename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_scanned.pdf";
return WebResponseUtils.bytesToWebResponse(
outputStream.toByteArray(), outputFilename, MediaType.APPLICATION_PDF);
}
}
private BufferedImage softenEdges(
BufferedImage image,
int featherRadius,
Color startColor,
Color endColor,
boolean vertical) {
int width = image.getWidth();
int height = image.getHeight();
BufferedImage output = new BufferedImage(width, height, image.getType());
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int dx = Math.min(x, width - 1 - x);
int dy = Math.min(y, height - 1 - y);
int d = Math.min(dx, dy);
float frac = vertical ? (float) y / (height - 1) : (float) x / (width - 1);
int rBg =
Math.round(
startColor.getRed()
+ (endColor.getRed() - startColor.getRed()) * frac);
int gBg =
Math.round(
startColor.getGreen()
+ (endColor.getGreen() - startColor.getGreen()) * frac);
int bBg =
Math.round(
startColor.getBlue()
+ (endColor.getBlue() - startColor.getBlue()) * frac);
int bgVal = new Color(rBg, gBg, bBg).getRGB();
int fgVal = image.getRGB(x, y);
float alpha = d < featherRadius ? (float) d / featherRadius : 1.0f;
int blended = blendColors(fgVal, bgVal, alpha);
output.setRGB(x, y, blended);
}
}
return output;
}
private int blendColors(int fg, int bg, float alpha) {
int r = Math.round(((fg >> 16) & 0xFF) * alpha + ((bg >> 16) & 0xFF) * (1 - alpha));
int g = Math.round(((fg >> 8) & 0xFF) * alpha + ((bg >> 8) & 0xFF) * (1 - alpha));
int b = Math.round((fg & 0xFF) * alpha + (bg & 0xFF) * (1 - alpha));
return (r << 16) | (g << 8) | b;
}
private BufferedImage applyGaussianBlur(BufferedImage image, double sigma) {
if (sigma <= 0) {
return image;
}
// Scale sigma based on image size to maintain consistent blur effect
double scaledSigma = sigma * Math.min(image.getWidth(), image.getHeight()) / 1000.0;
int radius = Math.max(1, (int) Math.ceil(scaledSigma * 3));
int size = 2 * radius + 1;
float[] data = new float[size * size];
double sum = 0.0;
// Generate Gaussian kernel
for (int i = -radius; i <= radius; i++) {
for (int j = -radius; j <= radius; j++) {
double xDistance = (double) i * i;
double yDistance = (double) j * j;
double g = Math.exp(-(xDistance + yDistance) / (2 * scaledSigma * scaledSigma));
data[(i + radius) * size + j + radius] = (float) g;
sum += g;
}
}
// Normalize kernel
for (int i = 0; i < data.length; i++) {
data[i] /= (float) sum;
}
// Create and apply convolution
java.awt.image.Kernel kernel = new java.awt.image.Kernel(size, size, data);
java.awt.image.ConvolveOp op =
new java.awt.image.ConvolveOp(kernel, java.awt.image.ConvolveOp.EDGE_NO_OP, null);
// Apply blur with high-quality rendering hints
BufferedImage result =
new BufferedImage(image.getWidth(), image.getHeight(), image.getType());
Graphics2D g2d = result.createGraphics();
g2d.setRenderingHint(
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.drawImage(op.filter(image, null), 0, 0, null);
g2d.dispose();
return result;
}
private void applyYellowishEffect(BufferedImage image) {
for (int x = 0; x < image.getWidth(); x++) {
for (int y = 0; y < image.getHeight(); y++) {
int rgb = image.getRGB(x, y);
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
// Stronger yellow tint while preserving brightness
float brightness = (r + g + b) / 765.0f; // Normalize to 0-1
r = Math.min(255, (int) (r + (255 - r) * 0.18f * brightness));
g = Math.min(255, (int) (g + (255 - g) * 0.12f * brightness));
b = Math.max(0, (int) (b * (1 - 0.25f * brightness)));
image.setRGB(x, y, (r << 16) | (g << 8) | b);
}
}
}
private void addGaussianNoise(BufferedImage image, double strength) {
if (strength <= 0) return;
// Scale noise based on image size
double scaledStrength = strength * Math.min(image.getWidth(), image.getHeight()) / 1000.0;
for (int x = 0; x < image.getWidth(); x++) {
for (int y = 0; y < image.getHeight(); y++) {
int rgb = image.getRGB(x, y);
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
// Generate noise with better distribution
double noiseR = RANDOM.nextGaussian() * scaledStrength;
double noiseG = RANDOM.nextGaussian() * scaledStrength;
double noiseB = RANDOM.nextGaussian() * scaledStrength;
// Apply noise with better color preservation
r = Math.min(255, Math.max(0, r + (int) noiseR));
g = Math.min(255, Math.max(0, g + (int) noiseG));
b = Math.min(255, Math.max(0, b + (int) noiseB));
image.setRGB(x, y, (r << 16) | (g << 8) | b);
}
}
}
private BufferedImage adjustBrightnessContrast(
BufferedImage image, float brightness, float contrast) {
BufferedImage output =
new BufferedImage(image.getWidth(), image.getHeight(), image.getType());
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
int rgb = image.getRGB(x, y);
int r = (int) (((((rgb >> 16) & 0xFF) - 128) * contrast + 128) * brightness);
int g = (int) (((((rgb >> 8) & 0xFF) - 128) * contrast + 128) * brightness);
int b = (int) ((((rgb & 0xFF) - 128) * contrast + 128) * brightness);
r = Math.min(255, Math.max(0, r));
g = Math.min(255, Math.max(0, g));
b = Math.min(255, Math.max(0, b));
output.setRGB(x, y, (r << 16) | (g << 8) | b);
}
}
return output;
}
}
@@ -0,0 +1,89 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.image.BufferedImage;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
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.model.api.misc.FlattenRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class FlattenController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/flatten")
@Operation(
summary = "Flatten PDF form fields or full page",
description =
"Flattening just PDF form fields or converting each page to images to make text"
+ " unselectable. Input:PDF, Output:PDF. Type:SISO")
public ResponseEntity<byte[]> flatten(@ModelAttribute FlattenRequest request) throws Exception {
MultipartFile file = request.getFileInput();
PDDocument document = pdfDocumentFactory.load(file);
Boolean flattenOnlyForms = request.getFlattenOnlyForms();
if (Boolean.TRUE.equals(flattenOnlyForms)) {
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
if (acroForm != null) {
acroForm.flatten();
}
return WebResponseUtils.pdfDocToWebResponse(
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
} else {
// flatten whole page aka convert each page to image and readd it (making text
// unselectable)
PDFRenderer pdfRenderer = new PDFRenderer(document);
PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
int numPages = document.getNumberOfPages();
for (int i = 0; i < numPages; i++) {
try {
BufferedImage image = pdfRenderer.renderImageWithDPI(i, 300, ImageType.RGB);
PDPage page = new PDPage();
page.setMediaBox(document.getPage(i).getMediaBox());
newDocument.addPage(page);
try (PDPageContentStream contentStream =
new PDPageContentStream(newDocument, page)) {
PDImageXObject pdImage = JPEGFactory.createFromImage(newDocument, image);
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
}
} catch (IOException e) {
log.error("exception", e);
}
}
return WebResponseUtils.pdfDocToWebResponse(
newDocument, Filenames.toSimpleFileName(file.getOriginalFilename()));
}
}
}
@@ -0,0 +1,185 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Map;
import java.util.Map.Entry;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
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.model.api.misc.MetadataRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.common.util.propertyeditor.StringToMapPropertyEditor;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class MetadataController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private String checkUndefined(String entry) {
// Check if the string is "undefined"
if ("undefined".equals(entry)) {
// Return null if it is
return null;
}
// Return the original string if it's not "undefined"
return entry;
}
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(Map.class, "allRequestParams", new StringToMapPropertyEditor());
}
@PostMapping(consumes = "multipart/form-data", value = "/update-metadata")
@Operation(
summary = "Update metadata of a PDF file",
description =
"This endpoint allows you to update the metadata of a given PDF file. You can"
+ " add, modify, or delete standard and custom metadata fields. Input:PDF"
+ " Output:PDF Type:SISO")
public ResponseEntity<byte[]> metadata(@ModelAttribute MetadataRequest request)
throws IOException {
// Extract PDF file from the request object
MultipartFile pdfFile = request.getFileInput();
// Extract metadata information
boolean deleteAll = Boolean.TRUE.equals(request.getDeleteAll());
String author = request.getAuthor();
String creationDate = request.getCreationDate();
String creator = request.getCreator();
String keywords = request.getKeywords();
String modificationDate = request.getModificationDate();
String producer = request.getProducer();
String subject = request.getSubject();
String title = request.getTitle();
String trapped = request.getTrapped();
// Extract additional custom parameters
Map<String, String> allRequestParams = request.getAllRequestParams();
if (allRequestParams == null) {
allRequestParams = new java.util.HashMap<String, String>();
}
// Load the PDF file into a PDDocument
PDDocument document = pdfDocumentFactory.load(pdfFile, true);
// Get the document information from the PDF
PDDocumentInformation info = document.getDocumentInformation();
// Check if each metadata value is "undefined" and set it to null if it is
author = checkUndefined(author);
creationDate = checkUndefined(creationDate);
creator = checkUndefined(creator);
keywords = checkUndefined(keywords);
modificationDate = checkUndefined(modificationDate);
producer = checkUndefined(producer);
subject = checkUndefined(subject);
title = checkUndefined(title);
trapped = checkUndefined(trapped);
// If the "deleteAll" flag is set, remove all metadata from the document
// information
if (deleteAll) {
for (String key : info.getMetadataKeys()) {
info.setCustomMetadataValue(key, null);
}
// Remove metadata from the PDF history
document.getDocumentCatalog().getCOSObject().removeItem(COSName.getPDFName("Metadata"));
document.getDocumentCatalog()
.getCOSObject()
.removeItem(COSName.getPDFName("PieceInfo"));
author = null;
creationDate = null;
creator = null;
keywords = null;
modificationDate = null;
producer = null;
subject = null;
title = null;
trapped = null;
} else {
// Iterate through the request parameters and set the metadata values
for (Entry<String, String> entry : allRequestParams.entrySet()) {
String key = entry.getKey();
// Check if the key is a standard metadata key
if (!"Author".equalsIgnoreCase(key)
&& !"CreationDate".equalsIgnoreCase(key)
&& !"Creator".equalsIgnoreCase(key)
&& !"Keywords".equalsIgnoreCase(key)
&& !"modificationDate".equalsIgnoreCase(key)
&& !"Producer".equalsIgnoreCase(key)
&& !"Subject".equalsIgnoreCase(key)
&& !"Title".equalsIgnoreCase(key)
&& !"Trapped".equalsIgnoreCase(key)
&& !key.contains("customKey")
&& !key.contains("customValue")) {
info.setCustomMetadataValue(key, entry.getValue());
} else if (key.contains("customKey")) {
int number = Integer.parseInt(key.replaceAll("\\D", ""));
String customKey = entry.getValue();
String customValue = allRequestParams.get("customValue" + number);
info.setCustomMetadataValue(customKey, customValue);
}
}
}
if (creationDate != null && creationDate.length() > 0) {
Calendar creationDateCal = Calendar.getInstance();
try {
creationDateCal.setTime(
new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").parse(creationDate));
} catch (ParseException e) {
log.error("exception", e);
}
info.setCreationDate(creationDateCal);
} else {
info.setCreationDate(null);
}
if (modificationDate != null && modificationDate.length() > 0) {
Calendar modificationDateCal = Calendar.getInstance();
try {
modificationDateCal.setTime(
new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").parse(modificationDate));
} catch (ParseException e) {
log.error("exception", e);
}
info.setModificationDate(modificationDateCal);
} else {
info.setModificationDate(null);
}
info.setCreator(creator);
info.setKeywords(keywords);
info.setAuthor(author);
info.setProducer(producer);
info.setSubject(subject);
info.setTitle(title);
info.setTrapped(trapped);
document.setDocumentInformation(info);
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_metadata.pdf");
}
}
@@ -0,0 +1,212 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.rendering.PDFRenderer;
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.BoundedLineReader;
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.model.api.misc.ProcessPdfWithOcrRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@Slf4j
@RequiredArgsConstructor
public class OCRController {
private final ApplicationProperties applicationProperties;
private final CustomPDFDocumentFactory pdfDocumentFactory;
/** Gets the list of available Tesseract languages from the tessdata directory */
public List<String> getAvailableTesseractLanguages() {
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
File[] files = new File(tessdataDir).listFiles();
if (files == null) {
return Collections.emptyList();
}
return Arrays.stream(files)
.filter(file -> file.getName().endsWith(".traineddata"))
.map(file -> file.getName().replace(".traineddata", ""))
.filter(lang -> !"osd".equalsIgnoreCase(lang))
.toList();
}
@PostMapping(consumes = "multipart/form-data", value = "/ocr-pdf")
@Operation(
summary = "Process PDF files with OCR using Tesseract",
description =
"Takes a PDF file as input, performs OCR using specified languages and OCR type"
+ " (skip-text/force-ocr), and returns the processed PDF. Input:PDF"
+ " Output:PDF Type:SISO")
public ResponseEntity<byte[]> processPdfWithOCR(
@ModelAttribute ProcessPdfWithOcrRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
List<String> languages = request.getLanguages();
String ocrType = request.getOcrType();
Path tempDir = Files.createTempDirectory("ocr_process");
Path tempInputFile = tempDir.resolve("input.pdf");
Path tempOutputDir = tempDir.resolve("output");
Path tempImagesDir = tempDir.resolve("images");
Path finalOutputFile = tempDir.resolve("final_output.pdf");
Files.createDirectories(tempOutputDir);
Files.createDirectories(tempImagesDir);
Process process = null;
try {
// Save input file
inputFile.transferTo(tempInputFile.toFile());
PDFMergerUtility merger = new PDFMergerUtility();
merger.setDestinationFileName(finalOutputFile.toString());
try (PDDocument document = pdfDocumentFactory.load(tempInputFile.toFile())) {
PDFRenderer pdfRenderer = new PDFRenderer(document);
int pageCount = document.getNumberOfPages();
for (int pageNum = 0; pageNum < pageCount; pageNum++) {
PDPage page = document.getPage(pageNum);
boolean hasText = false;
// Check for existing text
try (PDDocument tempDoc = new PDDocument()) {
tempDoc.addPage(page);
PDFTextStripper stripper = new PDFTextStripper();
hasText = !stripper.getText(tempDoc).trim().isEmpty();
}
boolean shouldOcr =
switch (ocrType) {
case "skip-text" -> !hasText;
case "force-ocr" -> true;
default -> true;
};
Path pageOutputPath =
tempOutputDir.resolve(String.format("page_%d.pdf", pageNum));
if (shouldOcr) {
// Convert page to image
BufferedImage image = pdfRenderer.renderImageWithDPI(pageNum, 300);
Path imagePath =
tempImagesDir.resolve(String.format("page_%d.png", pageNum));
ImageIO.write(image, "png", imagePath.toFile());
// Build OCR command
List<String> command = new ArrayList<>();
command.add("tesseract");
command.add(imagePath.toString());
command.add(
tempOutputDir
.resolve(String.format("page_%d", pageNum))
.toString());
command.add("-l");
command.add(String.join("+", languages));
// Always output PDF
command.add("pdf");
ProcessBuilder pb = new ProcessBuilder(command);
process = pb.start();
// Capture any error output
try (BufferedReader reader =
new BufferedReader(
new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) {
log.debug("Tesseract: {}", line);
}
}
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException(
"Tesseract failed with exit code: " + exitCode);
}
// Add OCR'd PDF to merger
merger.addSource(pageOutputPath.toFile());
} else {
// Save original page without OCR
try (PDDocument pageDoc = new PDDocument()) {
pageDoc.addPage(page);
pageDoc.save(pageOutputPath.toFile());
merger.addSource(pageOutputPath.toFile());
}
}
}
}
// Merge all pages into final PDF
merger.mergeDocuments(null);
// Read the final PDF file
byte[] pdfContent = Files.readAllBytes(finalOutputFile);
String outputFilename =
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_OCR.pdf";
return ResponseEntity.ok()
.header(
"Content-Disposition",
"attachment; filename=\"" + outputFilename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdfContent);
} finally {
if (process != null) {
process.destroy();
}
// Clean up temporary files
deleteDirectory(tempDir);
}
}
private void addFileToZip(File file, String filename, ZipOutputStream zipOut)
throws IOException {
if (!file.exists()) {
log.warn("File {} does not exist, skipping", file);
return;
}
try (FileInputStream fis = new FileInputStream(file)) {
ZipEntry zipEntry = new ZipEntry(filename);
zipOut.putNextEntry(zipEntry);
byte[] buffer = new byte[1024];
int length;
while ((length = fis.read(buffer)) >= 0) {
zipOut.write(buffer, 0, length);
}
zipOut.closeEntry();
}
}
private void deleteDirectory(Path directory) {
try {
Files.walk(directory)
.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.delete(path);
} catch (IOException e) {
log.error("Error deleting {}: {}", path, e.getMessage());
}
});
} catch (IOException e) {
log.error("Error walking directory {}: {}", directory, e.getMessage());
}
}
}
@@ -0,0 +1,64 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import org.springframework.http.HttpStatus;
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.model.api.misc.OverlayImageRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class OverlayImageController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/add-image")
@Operation(
summary = "Overlay image onto a PDF file",
description =
"This endpoint overlays an image onto a PDF file at the specified coordinates."
+ " The image can be overlaid on every page of the PDF if specified. "
+ " Input:PDF/IMAGE Output:PDF Type:SISO")
public ResponseEntity<byte[]> overlayImage(@ModelAttribute OverlayImageRequest request) {
MultipartFile pdfFile = request.getFileInput();
MultipartFile imageFile = request.getImageFile();
float x = request.getX();
float y = request.getY();
boolean everyPage = Boolean.TRUE.equals(request.getEveryPage());
try {
byte[] pdfBytes = pdfFile.getBytes();
byte[] imageBytes = imageFile.getBytes();
byte[] result =
PdfUtils.overlayImage(
pdfDocumentFactory, pdfBytes, imageBytes, x, y, everyPage);
return WebResponseUtils.bytesToWebResponse(
result,
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_overlayed.pdf");
} catch (IOException e) {
log.error("Failed to add image to PDF", e);
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
}
@@ -0,0 +1,171 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.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.misc.AddPageNumbersRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class PageNumbersController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/add-page-numbers", consumes = "multipart/form-data")
@Operation(
summary = "Add page numbers to a PDF document",
description =
"This operation takes an input PDF file and adds page numbers to it. Input:PDF"
+ " Output:PDF Type:SISO")
public ResponseEntity<byte[]> addPageNumbers(@ModelAttribute AddPageNumbersRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
String customMargin = request.getCustomMargin();
int position = request.getPosition();
int pageNumber = request.getStartingNumber();
String pagesToNumber = request.getPagesToNumber();
String customText = request.getCustomText();
float fontSize = request.getFontSize();
String fontType = request.getFontType();
PDDocument document = pdfDocumentFactory.load(file);
float marginFactor;
switch (customMargin.toLowerCase()) {
case "small":
marginFactor = 0.02f;
break;
case "large":
marginFactor = 0.05f;
break;
case "x-large":
marginFactor = 0.075f;
break;
case "medium":
default:
marginFactor = 0.035f;
break;
}
if (pagesToNumber == null || pagesToNumber.isEmpty()) {
pagesToNumber = "all";
}
if (customText == null || customText.isEmpty()) {
customText = "{n}";
}
List<Integer> pagesToNumberList =
GeneralUtils.parsePageList(pagesToNumber.split(","), document.getNumberOfPages());
for (int i : pagesToNumberList) {
PDPage page = document.getPage(i);
PDRectangle pageSize = page.getMediaBox();
String text =
customText
.replace("{n}", String.valueOf(pageNumber))
.replace("{total}", String.valueOf(document.getNumberOfPages()))
.replace(
"{filename}",
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", ""));
PDType1Font currentFont =
switch (fontType.toLowerCase()) {
case "courier" -> new PDType1Font(Standard14Fonts.FontName.COURIER);
case "times" -> new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
default -> new PDType1Font(Standard14Fonts.FontName.HELVETICA);
};
float x, y;
if (position == 5) {
// Calculate text width and font metrics
float textWidth = currentFont.getStringWidth(text) / 1000 * fontSize;
float ascent = currentFont.getFontDescriptor().getAscent() / 1000 * fontSize;
float descent = currentFont.getFontDescriptor().getDescent() / 1000 * fontSize;
float centerX = pageSize.getLowerLeftX() + (pageSize.getWidth() / 2);
float centerY = pageSize.getLowerLeftY() + (pageSize.getHeight() / 2);
x = centerX - (textWidth / 2);
y = centerY - (ascent + descent) / 2;
} else {
int xGroup = (position - 1) % 3;
int yGroup = 2 - (position - 1) / 3;
x =
switch (xGroup) {
case 0 ->
pageSize.getLowerLeftX()
+ marginFactor * pageSize.getWidth(); // left
case 1 ->
pageSize.getLowerLeftX() + (pageSize.getWidth() / 2); // center
default ->
pageSize.getUpperRightX()
- marginFactor * pageSize.getWidth(); // right
};
y =
switch (yGroup) {
case 0 ->
pageSize.getLowerLeftY()
+ marginFactor * pageSize.getHeight(); // bottom
case 1 ->
pageSize.getLowerLeftY() + (pageSize.getHeight() / 2); // middle
default ->
pageSize.getUpperRightY()
- marginFactor * pageSize.getHeight(); // top
};
}
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
contentStream.beginText();
contentStream.setFont(currentFont, fontSize);
contentStream.newLineAtOffset(x, y);
contentStream.showText(text);
contentStream.endText();
contentStream.close();
pageNumber++;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
document.close();
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(),
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
+ "_numbersAdded.pdf",
MediaType.APPLICATION_PDF);
}
}
@@ -0,0 +1,107 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.print.PageFormat;
import java.awt.print.Printable;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import java.io.IOException;
import java.util.Arrays;
import javax.imageio.ImageIO;
import javax.print.PrintService;
import javax.print.PrintServiceLookup;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.printing.PDFPageable;
import org.springframework.http.HttpStatus;
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;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@Slf4j
public class PrintFileController {
// TODO
// @PostMapping(value = "/print-file", consumes = "multipart/form-data")
// @Operation(
// summary = "Prints PDF/Image file to a set printer",
// description =
// "Input of PDF or Image along with a printer name/URL/IP to match against to
// send it to (Fire and forget) Input:Any Output:N/A Type:SISO")
public ResponseEntity<String> printFile(@ModelAttribute PrintFileRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
String printerName = request.getPrinterName();
String contentType = file.getContentType();
try {
// Find matching printer
PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
PrintService selectedService =
Arrays.stream(services)
.filter(
service ->
service.getName().toLowerCase().contains(printerName))
.findFirst()
.orElseThrow(
() ->
new IllegalArgumentException(
"No matching printer found"));
log.info("Selected Printer: " + selectedService.getName());
if ("application/pdf".equals(contentType)) {
PDDocument document = Loader.loadPDF(file.getBytes());
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(selectedService);
job.setPageable(new PDFPageable(document));
job.print();
document.close();
} else if (contentType.startsWith("image/")) {
BufferedImage image = ImageIO.read(file.getInputStream());
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(selectedService);
job.setPrintable(
new Printable() {
public int print(
Graphics graphics, PageFormat pageFormat, int pageIndex)
throws PrinterException {
if (pageIndex != 0) {
return NO_SUCH_PAGE;
}
Graphics2D g2d = (Graphics2D) graphics;
g2d.translate(
pageFormat.getImageableX(), pageFormat.getImageableY());
g2d.drawImage(
image,
0,
0,
(int) pageFormat.getImageableWidth(),
(int) pageFormat.getImageableHeight(),
null);
return PAGE_EXISTS;
}
});
job.print();
}
return new ResponseEntity<>(
"File printed successfully to " + selectedService.getName(), HttpStatus.OK);
} catch (Exception e) {
System.err.println("Failed to print: " + e.getMessage());
return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
}
@@ -0,0 +1,77 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
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.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class RepairController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/repair")
@Operation(
summary = "Repair a PDF file",
description =
"This endpoint repairs a given PDF file by running qpdf command. The PDF is"
+ " first saved to a temporary location, repaired, read back, and then"
+ " returned as a response. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> repairPdf(@ModelAttribute PDFFile file)
throws IOException, InterruptedException {
MultipartFile inputFile = file.getFileInput();
// Save the uploaded file to a temporary location
Path tempInputFile = Files.createTempFile("input_", ".pdf");
byte[] pdfBytes = null;
inputFile.transferTo(tempInputFile.toFile());
try {
List<String> command = new ArrayList<>();
command.add("qpdf");
command.add("--replace-input"); // Automatically fixes problems it can
command.add("--qdf"); // Linearizes and normalizes PDF structure
command.add("--object-streams=disable"); // Can help with some corruptions
command.add(tempInputFile.toString());
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)
.runCommandWithOutputHandling(command);
// Read the optimized PDF file
pdfBytes = pdfDocumentFactory.loadToBytes(tempInputFile.toFile());
// Return the optimized PDF as a response
String outputFilename =
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_repaired.pdf";
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
} finally {
// Clean up the temporary files
Files.deleteIfExists(tempInputFile);
}
}
}
@@ -0,0 +1,53 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
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 stirling.software.SPDF.model.api.misc.ReplaceAndInvertColorRequest;
import stirling.software.SPDF.service.misc.ReplaceAndInvertColorService;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class ReplaceAndInvertColorController {
private final ReplaceAndInvertColorService replaceAndInvertColorService;
@PostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf")
@Operation(
summary = "Replace-Invert Color PDF",
description =
"This endpoint accepts a PDF file and option of invert all colors or replace"
+ " text and background colors. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<InputStreamResource> replaceAndInvertColor(
@ModelAttribute ReplaceAndInvertColorRequest request) throws IOException {
InputStreamResource resource =
replaceAndInvertColorService.replaceAndInvertColor(
request.getFileInput(),
request.getReplaceAndInvertOption(),
request.getHighContrastColorCombination(),
request.getBackGroundColor(),
request.getTextColor());
// Return the modified PDF as a downloadable file
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=inverted.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(resource);
}
}
@@ -0,0 +1,84 @@
package stirling.software.SPDF.controller.api.misc;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDNameTreeNode;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
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.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class ShowJavascript {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/show-javascript")
@Operation(
summary = "Grabs all JS from a PDF and returns a single JS file with all code",
description = "desc. Input:PDF Output:JS Type:SISO")
public ResponseEntity<byte[]> extractHeader(@ModelAttribute PDFFile file) throws Exception {
MultipartFile inputFile = file.getFileInput();
String script = "";
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
if (document.getDocumentCatalog() != null
&& document.getDocumentCatalog().getNames() != null) {
PDNameTreeNode<PDActionJavaScript> jsTree =
document.getDocumentCatalog().getNames().getJavaScript();
if (jsTree != null) {
Map<String, PDActionJavaScript> jsEntries = jsTree.getNames();
for (Map.Entry<String, PDActionJavaScript> entry : jsEntries.entrySet()) {
String name = entry.getKey();
PDActionJavaScript jsAction = entry.getValue();
String jsCodeStr = jsAction.getAction();
script +=
"// File: "
+ Filenames.toSimpleFileName(
inputFile.getOriginalFilename())
+ ", Script: "
+ name
+ "\n"
+ jsCodeStr
+ "\n";
}
}
}
if (script.isEmpty()) {
script =
"PDF '"
+ Filenames.toSimpleFileName(inputFile.getOriginalFilename())
+ "' does not contain Javascript";
}
return WebResponseUtils.bytesToWebResponse(
script.getBytes(StandardCharsets.UTF_8),
Filenames.toSimpleFileName(inputFile.getOriginalFilename()) + ".js",
MediaType.TEXT_PLAIN);
}
}
}
@@ -0,0 +1,341 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.List;
import javax.imageio.ImageIO;
import org.apache.commons.io.IOUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;
import org.apache.pdfbox.util.Matrix;
import org.springframework.core.io.ClassPathResource;
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.misc.AddStampRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class StampController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/add-stamp")
@Operation(
summary = "Add stamp to a PDF file",
description =
"This endpoint adds a stamp to a given PDF file. Users can specify the stamp"
+ " type (text or image), rotation, opacity, width spacer, and height"
+ " spacer. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> addStamp(@ModelAttribute AddStampRequest request)
throws IOException, Exception {
MultipartFile pdfFile = request.getFileInput();
String stampType = request.getStampType();
String stampText = request.getStampText();
MultipartFile stampImage = request.getStampImage();
String alphabet = request.getAlphabet();
float fontSize = request.getFontSize();
float rotation = request.getRotation();
float opacity = request.getOpacity();
int position = request.getPosition(); // Updated to use 1-9 positioning logic
float overrideX = request.getOverrideX(); // New field for X override
float overrideY = request.getOverrideY(); // New field for Y override
String customColor = request.getCustomColor();
float marginFactor;
switch (request.getCustomMargin().toLowerCase()) {
case "small":
marginFactor = 0.02f;
break;
case "medium":
marginFactor = 0.035f;
break;
case "large":
marginFactor = 0.05f;
break;
case "x-large":
marginFactor = 0.075f;
break;
default:
marginFactor = 0.035f;
break;
}
// Load the input PDF
PDDocument document = pdfDocumentFactory.load(pdfFile);
List<Integer> pageNumbers = request.getPageNumbersList(document, true);
for (int pageIndex : pageNumbers) {
int zeroBasedIndex = pageIndex - 1;
if (zeroBasedIndex >= 0 && zeroBasedIndex < document.getNumberOfPages()) {
PDPage page = document.getPage(zeroBasedIndex);
PDRectangle pageSize = page.getMediaBox();
float margin = marginFactor * (pageSize.getWidth() + pageSize.getHeight()) / 2;
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
graphicsState.setNonStrokingAlphaConstant(opacity);
contentStream.setGraphicsStateParameters(graphicsState);
if ("text".equalsIgnoreCase(stampType)) {
addTextStamp(
contentStream,
stampText,
document,
page,
rotation,
position,
fontSize,
alphabet,
overrideX,
overrideY,
margin,
customColor);
} else if ("image".equalsIgnoreCase(stampType)) {
addImageStamp(
contentStream,
stampImage,
document,
page,
rotation,
position,
fontSize,
overrideX,
overrideY,
margin);
}
contentStream.close();
}
}
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_stamped.pdf");
}
private void addTextStamp(
PDPageContentStream contentStream,
String stampText,
PDDocument document,
PDPage page,
float rotation,
int position, // 1-9 positioning logic
float fontSize,
String alphabet,
float overrideX, // X override
float overrideY,
float margin,
String colorString) // Y override
throws IOException {
String resourceDir = "";
PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
switch (alphabet) {
case "arabic":
resourceDir = "static/fonts/NotoSansArabic-Regular.ttf";
break;
case "japanese":
resourceDir = "static/fonts/Meiryo.ttf";
break;
case "korean":
resourceDir = "static/fonts/malgun.ttf";
break;
case "chinese":
resourceDir = "static/fonts/SimSun.ttf";
break;
case "roman":
default:
resourceDir = "static/fonts/NotoSans-Regular.ttf";
break;
}
if (!"".equals(resourceDir)) {
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf("."));
File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile();
try (InputStream is = classPathResource.getInputStream();
FileOutputStream os = new FileOutputStream(tempFile)) {
IOUtils.copy(is, os);
font = PDType0Font.load(document, tempFile);
} finally {
if (tempFile != null) {
Files.deleteIfExists(tempFile.toPath());
}
}
}
contentStream.setFont(font, fontSize);
Color redactColor;
try {
if (!colorString.startsWith("#")) {
colorString = "#" + colorString;
}
redactColor = Color.decode(colorString);
} catch (NumberFormatException e) {
redactColor = Color.LIGHT_GRAY;
}
contentStream.setNonStrokingColor(redactColor);
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);
}
// Split the stampText into multiple lines
String[] lines = stampText.split("\\\\n");
// Calculate dynamic line height based on font ascent and descent
float ascent = font.getFontDescriptor().getAscent();
float descent = font.getFontDescriptor().getDescent();
float lineHeight = ((ascent - descent) / 1000) * fontSize;
contentStream.beginText();
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
// 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();
}
private void addImageStamp(
PDPageContentStream contentStream,
MultipartFile stampImage,
PDDocument document,
PDPage page,
float rotation,
int position, // 1-9 positioning logic
float fontSize,
float overrideX,
float overrideY,
float margin)
throws IOException {
// Load the stamp image
BufferedImage image = ImageIO.read(stampImage.getInputStream());
// Compute width based on original aspect ratio
float aspectRatio = (float) image.getWidth() / (float) image.getHeight();
// Desired physical height (in PDF points)
float desiredPhysicalHeight = fontSize;
// Desired physical width based on the aspect ratio
float desiredPhysicalWidth = desiredPhysicalHeight * aspectRatio;
// Convert the BufferedImage to PDImageXObject
PDImageXObject xobject = LosslessFactory.createFromImage(document, image);
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, desiredPhysicalWidth, null, 0, null, margin);
y = calculatePositionY(pageSize, position, fontSize, margin);
}
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
contentStream.restoreGraphicsState();
}
private float calculatePositionX(
PDRectangle pageSize,
int position,
float contentWidth,
PDFont font,
float fontSize,
String text,
float margin)
throws IOException {
float actualWidth =
(text != null) ? calculateTextWidth(text, font, fontSize) : contentWidth;
switch (position % 3) {
case 1: // Left
return pageSize.getLowerLeftX() + margin;
case 2: // Center
return (pageSize.getWidth() - actualWidth) / 2;
case 0: // Right
return pageSize.getUpperRightX() - actualWidth - margin;
default:
return 0;
}
}
private float calculatePositionY(
PDRectangle pageSize, int position, float height, float margin) {
switch ((position - 1) / 3) {
case 0: // Top
return pageSize.getUpperRightY() - height - margin;
case 1: // Middle
return (pageSize.getHeight() - height) / 2;
case 2: // Bottom
return pageSize.getLowerLeftY() + margin;
default:
return 0;
}
}
private float calculateTextWidth(String text, PDFont font, float fontSize) throws IOException {
return font.getStringWidth(text) / 1000 * fontSize;
}
private float calculateTextCapHeight(PDFont font, float fontSize) {
return font.getFontDescriptor().getCapHeight() / 1000 * fontSize;
}
}
@@ -0,0 +1,124 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.apache.pdfbox.cos.*;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.springframework.beans.factory.annotation.Autowired;
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.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
public class UnlockPDFFormsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@Autowired
public UnlockPDFFormsController(CustomPDFDocumentFactory pdfDocumentFactory) {
this.pdfDocumentFactory = pdfDocumentFactory;
}
@PostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms")
@Operation(
summary = "Remove read-only property from form fields",
description =
"Removing read-only property from form fields making them fillable"
+ "Input:PDF, Output:PDF. Type:SISO")
public ResponseEntity<byte[]> unlockPDFForms(@ModelAttribute PDFFile file) {
try (PDDocument document = pdfDocumentFactory.load(file)) {
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
if (acroForm != null) {
acroForm.setNeedAppearances(true);
for (PDField field : acroForm.getFieldTree()) {
COSDictionary dict = field.getCOSObject();
if (dict.containsKey(COSName.getPDFName("Lock"))) {
dict.removeItem(COSName.getPDFName("Lock"));
}
int currentFlags = field.getFieldFlags();
if ((currentFlags & 1) == 1) {
int newFlags = currentFlags & ~1;
field.setFieldFlags(newFlags);
}
}
COSBase xfaBase = acroForm.getCOSObject().getDictionaryObject(COSName.XFA);
if (xfaBase != null) {
try {
if (xfaBase instanceof COSStream xfaStream) {
InputStream is = xfaStream.createInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
is.transferTo(baos);
String xml = baos.toString(StandardCharsets.UTF_8);
xml = xml.replaceAll("access\\s*=\\s*\"readOnly\"", "access=\"open\"");
PDStream newStream =
new PDStream(
document,
new ByteArrayInputStream(
xml.getBytes(StandardCharsets.UTF_8)));
acroForm.getCOSObject().setItem(COSName.XFA, newStream.getCOSObject());
} else if (xfaBase instanceof COSArray xfaArray) {
for (int i = 0; i < xfaArray.size(); i += 2) {
COSBase namePart = xfaArray.getObject(i);
COSBase streamPart = xfaArray.getObject(i + 1);
if (namePart instanceof COSString
&& streamPart instanceof COSStream stream) {
InputStream is = stream.createInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
is.transferTo(baos);
String xml = baos.toString(StandardCharsets.UTF_8);
xml =
xml.replaceAll(
"access\\s*=\\s*\"readOnly\"",
"access=\"open\"");
PDStream newStream =
new PDStream(
document,
new ByteArrayInputStream(
xml.getBytes(StandardCharsets.UTF_8)));
xfaArray.set(i + 1, newStream.getCOSObject());
}
}
}
} catch (Exception e) {
log.error("exception", e);
}
}
}
String mergedFileName =
file.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_unlocked_forms.pdf";
return WebResponseUtils.pdfDocToWebResponse(
document, Filenames.toSimpleFileName(mergedFileName));
} catch (Exception e) {
log.error(e.getMessage(), e);
}
return null;
}
}
@@ -0,0 +1,128 @@
package stirling.software.SPDF.controller.api.pipeline;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.model.api.HandleDataRequest;
import stirling.software.common.service.PostHogService;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/pipeline")
@Slf4j
@Tag(name = "Pipeline", description = "Pipeline APIs")
@RequiredArgsConstructor
public class PipelineController {
private final PipelineProcessor processor;
private final ObjectMapper objectMapper;
private final PostHogService postHogService;
@PostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request)
throws JsonMappingException, JsonProcessingException {
MultipartFile[] files = request.getFileInput();
String jsonString = request.getJson();
if (files == null) {
return null;
}
PipelineConfig config = objectMapper.readValue(jsonString, PipelineConfig.class);
log.info("Received POST request to /handleData with {} files", files.length);
List<String> operationNames =
config.getOperations().stream().map(PipelineOperation::getOperation).toList();
Map<String, Object> properties = new HashMap<>();
properties.put("operations", operationNames);
properties.put("fileCount", files.length);
postHogService.captureEvent("pipeline_api_event", properties);
try {
List<Resource> inputFiles = processor.generateInputFiles(files);
if (inputFiles == null || inputFiles.size() == 0) {
return null;
}
PipelineResult result = processor.runPipelineAgainstFiles(inputFiles, config);
List<Resource> outputFiles = result.getOutputFiles();
if (outputFiles != null && outputFiles.size() == 1) {
// If there is only one file, return it directly
Resource singleFile = outputFiles.get(0);
InputStream is = singleFile.getInputStream();
byte[] bytes = new byte[(int) singleFile.contentLength()];
is.read(bytes);
is.close();
log.info("Returning single file response...");
return WebResponseUtils.bytesToWebResponse(
bytes, singleFile.getFilename(), MediaType.APPLICATION_OCTET_STREAM);
} else if (outputFiles == null) {
return null;
}
// Create a ByteArrayOutputStream to hold the zip
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ZipOutputStream zipOut = new ZipOutputStream(baos);
// A map to keep track of filenames and their counts
Map<String, Integer> filenameCount = new HashMap<>();
// Loop through each file and add it to the zip
for (Resource file : outputFiles) {
String originalFilename = file.getFilename();
String filename = originalFilename;
// Check if the filename already exists, and modify it if necessary
if (filenameCount.containsKey(originalFilename)) {
int count = filenameCount.get(originalFilename);
String baseName = originalFilename.replaceAll("\\.[^.]*$", "");
String extension = originalFilename.replaceAll("^.*\\.", "");
filename = baseName + "(" + count + ")." + extension;
filenameCount.put(originalFilename, count + 1);
} else {
filenameCount.put(originalFilename, 1);
}
ZipEntry zipEntry = new ZipEntry(filename);
zipOut.putNextEntry(zipEntry);
// Read the file into a byte array
InputStream is = file.getInputStream();
byte[] bytes = new byte[(int) file.contentLength()];
is.read(bytes);
// Write the bytes of the file to the zip
zipOut.write(bytes, 0, bytes.length);
zipOut.closeEntry();
is.close();
}
zipOut.close();
log.info("Returning zipped file response...");
return WebResponseUtils.boasToWebResponse(
baos, "output.zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (Exception e) {
log.error("Error handling data: ", e);
return null;
}
}
}
@@ -0,0 +1,408 @@
package stirling.software.SPDF.controller.api.pipeline;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystemException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.service.PostHogService;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.FileMonitor;
@Service
@Slf4j
public class PipelineDirectoryProcessor {
private final ObjectMapper objectMapper;
private final ApiDocService apiDocService;
private final PipelineProcessor processor;
private final FileMonitor fileMonitor;
private final PostHogService postHogService;
private final String watchedFoldersDir;
private final String finishedFoldersDir;
public PipelineDirectoryProcessor(
ObjectMapper objectMapper,
ApiDocService apiDocService,
PipelineProcessor processor,
FileMonitor fileMonitor,
PostHogService postHogService,
RuntimePathConfig runtimePathConfig) {
this.objectMapper = objectMapper;
this.apiDocService = apiDocService;
this.processor = processor;
this.fileMonitor = fileMonitor;
this.postHogService = postHogService;
this.watchedFoldersDir = runtimePathConfig.getPipelineWatchedFoldersPath();
this.finishedFoldersDir = runtimePathConfig.getPipelineFinishedFoldersPath();
}
@Scheduled(fixedRate = 60000)
public void scanFolders() {
Path watchedFolderPath = Paths.get(watchedFoldersDir).toAbsolutePath();
if (!Files.exists(watchedFolderPath)) {
try {
Files.createDirectories(watchedFolderPath);
log.info("Created directory: {}", watchedFolderPath);
} catch (IOException e) {
log.error("Error creating directory: {}", watchedFolderPath, e);
return;
}
}
try {
Files.walkFileTree(
watchedFolderPath,
new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(
Path dir, BasicFileAttributes attrs) {
try {
// Skip root directory and "processing" subdirectories
if (!dir.equals(watchedFolderPath) && !dir.endsWith("processing")) {
handleDirectory(dir);
}
} catch (Exception e) {
log.error("Error handling directory: {}", dir, e);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path path, IOException exc) {
// Handle broken symlinks or inaccessible directories
log.error("Error accessing path: {}", path, exc);
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
log.error("Error walking through directory: {}", watchedFolderPath, e);
}
}
public void handleDirectory(Path dir) throws IOException {
log.info("Handling directory: {}", dir);
Path processingDir = createProcessingDirectory(dir);
Optional<Path> jsonFileOptional = findJsonFile(dir);
if (!jsonFileOptional.isPresent()) {
log.warn("No .JSON settings file found. No processing will happen for dir {}.", dir);
return;
}
Path jsonFile = jsonFileOptional.get();
PipelineConfig config = readAndParseJson(jsonFile);
processPipelineOperations(dir, processingDir, jsonFile, config);
}
private Path createProcessingDirectory(Path dir) throws IOException {
Path processingDir = dir.resolve("processing");
if (!Files.exists(processingDir)) {
Files.createDirectory(processingDir);
log.info("Created processing directory: {}", processingDir);
}
return processingDir;
}
private Optional<Path> findJsonFile(Path dir) throws IOException {
try (Stream<Path> paths = Files.list(dir)) {
return paths.filter(file -> file.toString().endsWith(".json")).findFirst();
}
}
private PipelineConfig readAndParseJson(Path jsonFile) throws IOException {
String jsonString = new String(Files.readAllBytes(jsonFile), StandardCharsets.UTF_8);
log.debug("Reading JSON file: {}", jsonFile);
return objectMapper.readValue(jsonString, PipelineConfig.class);
}
private void processPipelineOperations(
Path dir, Path processingDir, Path jsonFile, PipelineConfig config) throws IOException {
for (PipelineOperation operation : config.getOperations()) {
validateOperation(operation);
File[] files = collectFilesForProcessing(dir, jsonFile, operation);
if (files == null || files.length == 0) {
log.debug("No files detected for {} ", dir);
return;
}
List<String> operationNames =
config.getOperations().stream().map(PipelineOperation::getOperation).toList();
Map<String, Object> properties = new HashMap<>();
properties.put("operations", operationNames);
properties.put("fileCount", files.length);
postHogService.captureEvent("pipeline_directory_event", properties);
List<File> filesToProcess = prepareFilesForProcessing(files, processingDir);
runPipelineAgainstFiles(filesToProcess, config, dir, processingDir);
}
}
private void validateOperation(PipelineOperation operation) throws IOException {
if (!apiDocService.isValidOperation(operation.getOperation(), operation.getParameters())) {
throw new IOException("Invalid operation: " + operation.getOperation());
}
}
private File[] collectFilesForProcessing(Path dir, Path jsonFile, PipelineOperation operation)
throws IOException {
List<String> inputExtensions =
apiDocService.getExtensionTypes(false, operation.getOperation());
log.info(
"Allowed extensions for operation {}: {}",
operation.getOperation(),
inputExtensions);
boolean allowAllFiles = inputExtensions.contains("ALL");
try (Stream<Path> paths = Files.list(dir)) {
File[] files =
paths.filter(
path -> {
if (Files.isDirectory(path)) {
return false;
}
if (path.equals(jsonFile)) {
return false;
}
// Get file extension
String filename = path.getFileName().toString();
String extension =
filename.contains(".")
? filename.substring(
filename.lastIndexOf(".")
+ 1)
.toLowerCase()
: "";
// Check against allowed extensions
boolean isAllowed =
allowAllFiles
|| inputExtensions.contains(
extension.toLowerCase());
if (!isAllowed) {
log.info(
"Skipping file with unsupported extension: {} ({})",
filename,
extension);
}
return isAllowed;
})
.map(Path::toAbsolutePath)
.filter(
path -> {
boolean isReady =
fileMonitor.isFileReadyForProcessing(path);
if (!isReady) {
log.info(
"File not ready for processing (locked/created last 5s): {}",
path);
}
return isReady;
})
.map(Path::toFile)
.toArray(File[]::new);
log.info(
"Collected {} files for processing for {}",
files.length,
dir.toAbsolutePath().toString());
return files;
}
}
private List<File> prepareFilesForProcessing(File[] files, Path processingDir)
throws IOException {
List<File> filesToProcess = new ArrayList<>();
for (File file : files) {
Path targetPath = resolveUniqueFilePath(processingDir, file.getName());
// Retry with exponential backoff
int maxRetries = 3;
int retryDelayMs = 500;
boolean moved = false;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
Files.move(file.toPath(), targetPath, StandardCopyOption.REPLACE_EXISTING);
moved = true;
break;
} catch (FileSystemException e) {
if (attempt < maxRetries) {
log.info("File move failed (attempt {}), retrying...", attempt);
try {
Thread.sleep(retryDelayMs * (int) Math.pow(2, attempt - 1));
} catch (InterruptedException e1) {
log.error("prepareFilesForProcessing failure", e);
}
}
}
}
if (moved) {
filesToProcess.add(targetPath.toFile());
} else {
log.error("Failed to move file after {} attempts: {}", maxRetries, file.getName());
}
}
return filesToProcess;
}
private Path resolveUniqueFilePath(Path directory, String originalFileName) {
Path filePath = directory.resolve(originalFileName);
int counter = 1;
while (Files.exists(filePath)) {
String newName = appendSuffixToFileName(originalFileName, "(" + counter + ")");
filePath = directory.resolve(newName);
counter++;
}
return filePath;
}
private String appendSuffixToFileName(String originalFileName, String suffix) {
int dotIndex = originalFileName.lastIndexOf('.');
if (dotIndex == -1) {
return originalFileName + suffix;
} else {
return originalFileName.substring(0, dotIndex)
+ suffix
+ originalFileName.substring(dotIndex);
}
}
private void runPipelineAgainstFiles(
List<File> filesToProcess, PipelineConfig config, Path dir, Path processingDir)
throws IOException {
try {
List<Resource> inputFiles =
processor.generateInputFiles(filesToProcess.toArray(new File[0]));
if (inputFiles == null || inputFiles.isEmpty()) {
return;
}
PipelineResult result = processor.runPipelineAgainstFiles(inputFiles, config);
if (result.isHasErrors()) {
log.error("Errors occurred during processing, retaining original files");
moveToErrorDirectory(filesToProcess, dir);
} else {
moveAndRenameFiles(result.getOutputFiles(), config, dir);
deleteOriginalFiles(filesToProcess, processingDir);
}
} catch (Exception e) {
log.error("Error during processing", e);
moveFilesBack(filesToProcess, processingDir);
}
}
private void moveToErrorDirectory(List<File> files, Path originalDir) throws IOException {
Path errorDir = originalDir.resolve("error");
if (!Files.exists(errorDir)) {
Files.createDirectories(errorDir);
}
for (File file : files) {
Path target = errorDir.resolve(file.getName());
Files.move(file.toPath(), target);
log.info("Moved failed file to error directory for investigation: {}", target);
}
}
private void moveAndRenameFiles(List<Resource> resources, PipelineConfig config, Path dir)
throws IOException {
for (Resource resource : resources) {
String outputFileName = createOutputFileName(resource, config);
Path outputPath = determineOutputPath(config, dir);
if (!Files.exists(outputPath)) {
Files.createDirectories(outputPath);
log.info("Created directory: {}", outputPath);
}
Path outputFile = outputPath.resolve(outputFileName);
try (OutputStream os = new FileOutputStream(outputFile.toFile())) {
os.write(((ByteArrayResource) resource).getByteArray());
}
log.info("File moved and renamed to {}", outputFile);
}
}
private String createOutputFileName(Resource resource, PipelineConfig config) {
String resourceName = resource.getFilename();
String baseName = resourceName.substring(0, resourceName.lastIndexOf('.'));
String extension = resourceName.substring(resourceName.lastIndexOf('.') + 1);
String outputFileName =
config.getOutputPattern()
.replace("{filename}", baseName)
.replace("{pipelineName}", config.getName())
.replace(
"{date}",
LocalDate.now()
.format(DateTimeFormatter.ofPattern("yyyyMMdd")))
.replace(
"{time}",
LocalTime.now()
.format(DateTimeFormatter.ofPattern("HHmmss")))
+ "."
+ extension;
return outputFileName;
}
private Path determineOutputPath(PipelineConfig config, Path dir) {
String outputDir =
config.getOutputDir()
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString())
.replaceAll("\\\\?watchedFolders", "");
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
}
private void deleteOriginalFiles(List<File> filesToProcess, Path processingDir)
throws IOException {
for (File file : filesToProcess) {
Files.deleteIfExists(processingDir.resolve(file.getName()));
log.info("Deleted original file: {}", file.getName());
}
}
private void moveFilesBack(List<File> filesToProcess, Path processingDir) {
for (File file : filesToProcess) {
try {
Files.move(processingDir.resolve(file.getName()), file.toPath());
log.info(
"Moved file back to original location: {} , {}",
file.toPath(),
file.getName());
} catch (IOException e) {
log.error("Error moving file back to original location: {}", file.getName(), e);
}
}
}
}
@@ -0,0 +1,382 @@
package stirling.software.SPDF.controller.api.pipeline;
import java.io.*;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.github.pixee.security.ZipSecurity;
import jakarta.servlet.ServletContext;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.SPDFApplication;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.service.UserServiceInterface;
@Service
@Slf4j
public class PipelineProcessor {
private final ApiDocService apiDocService;
private final UserServiceInterface userService;
private final ServletContext servletContext;
public PipelineProcessor(
ApiDocService apiDocService,
@Autowired(required = false) UserServiceInterface userService,
ServletContext servletContext) {
this.apiDocService = apiDocService;
this.userService = userService;
this.servletContext = servletContext;
}
public static String removeTrailingNaming(String filename) {
// Splitting filename into name and extension
int dotIndex = filename.lastIndexOf(".");
if (dotIndex == -1) {
// No extension found
return filename;
}
String name = filename.substring(0, dotIndex);
String extension = filename.substring(dotIndex);
// Finding the last underscore
int underscoreIndex = name.lastIndexOf("_");
if (underscoreIndex == -1) {
// No underscore found
return filename;
}
// Removing the last part and reattaching the extension
return name.substring(0, underscoreIndex) + extension;
}
private String getApiKeyForUser() {
if (userService == null) return "";
return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId());
}
private String getBaseUrl() {
String contextPath = servletContext.getContextPath();
String port = SPDFApplication.getStaticPort();
return "http://localhost:" + port + contextPath + "/";
}
PipelineResult runPipelineAgainstFiles(List<Resource> outputFiles, PipelineConfig config)
throws Exception {
PipelineResult result = new PipelineResult();
ByteArrayOutputStream logStream = new ByteArrayOutputStream();
PrintStream logPrintStream = new PrintStream(logStream);
boolean hasErrors = false;
boolean filtersApplied = false;
for (PipelineOperation pipelineOperation : config.getOperations()) {
String operation = pipelineOperation.getOperation();
boolean isMultiInputOperation = apiDocService.isMultiInput(operation);
log.info(
"Running operation: {} isMultiInputOperation {}",
operation,
isMultiInputOperation);
Map<String, Object> parameters = pipelineOperation.getParameters();
List<String> inputFileTypes = apiDocService.getExtensionTypes(false, operation);
if (inputFileTypes == null) {
inputFileTypes = new ArrayList<String>(Arrays.asList("ALL"));
}
// List outputFileTypes = apiDocService.getExtensionTypes(true, operation);
String url = getBaseUrl() + operation;
List<Resource> newOutputFiles = new ArrayList<>();
if (!isMultiInputOperation) {
for (Resource file : outputFiles) {
boolean hasInputFileType = false;
for (String extension : inputFileTypes) {
if ("ALL".equals(extension)
|| file.getFilename().toLowerCase().endsWith(extension)) {
hasInputFileType = true;
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", file);
for (Entry<String, Object> entry : parameters.entrySet()) {
if (entry.getValue() instanceof List<?> entryList) {
for (Object item : entryList) {
body.add(entry.getKey(), item);
}
} else {
body.add(entry.getKey(), entry.getValue());
}
}
ResponseEntity<byte[]> response = sendWebRequest(url, body);
// If the operation is filter and the response body is null or empty,
// skip
// this
// file
if (operation.startsWith("filter-")
&& (response.getBody() == null
|| response.getBody().length == 0)) {
filtersApplied = true;
log.info("Skipping file due to filtering {}", operation);
continue;
}
if (!HttpStatus.OK.equals(response.getStatusCode())) {
logPrintStream.println("Error: " + response.getBody());
hasErrors = true;
continue;
}
processOutputFiles(operation, response, newOutputFiles);
}
}
if (!hasInputFileType) {
logPrintStream.println(
"No files with extension "
+ String.join(", ", inputFileTypes)
+ " found for operation "
+ operation);
hasErrors = true;
}
}
} else {
// Filter and collect all files that match the inputFileExtension
List<Resource> matchingFiles;
if (inputFileTypes.contains("ALL")) {
matchingFiles = new ArrayList<>(outputFiles);
} else {
final List<String> finalinputFileTypes = inputFileTypes;
matchingFiles =
outputFiles.stream()
.filter(
file ->
finalinputFileTypes.stream()
.anyMatch(
file.getFilename().toLowerCase()
::endsWith))
.toList();
}
// Check if there are matching files
if (!matchingFiles.isEmpty()) {
// Create a new MultiValueMap for the request body
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
// Add all matching files to the body
for (Resource file : matchingFiles) {
body.add("fileInput", file);
}
for (Entry<String, Object> entry : parameters.entrySet()) {
if (entry.getValue() instanceof List<?> entryList) {
for (Object item : entryList) {
body.add(entry.getKey(), item);
}
} else {
body.add(entry.getKey(), entry.getValue());
}
}
ResponseEntity<byte[]> response = sendWebRequest(url, body);
// Handle the response
if (HttpStatus.OK.equals(response.getStatusCode())) {
processOutputFiles(operation, response, newOutputFiles);
} else {
// Log error if the response status is not OK
logPrintStream.println(
"Error in multi-input operation: " + response.getBody());
hasErrors = true;
}
} else {
logPrintStream.println(
"No files with extension "
+ String.join(", ", inputFileTypes)
+ " found for multi-input operation "
+ operation);
hasErrors = true;
}
}
logPrintStream.close();
outputFiles = newOutputFiles;
}
if (hasErrors) {
log.error("Errors occurred during processing. Log: {}", logStream.toString());
}
result.setHasErrors(hasErrors);
result.setFiltersApplied(filtersApplied);
result.setOutputFiles(outputFiles);
return result;
}
/* package */ ResponseEntity<byte[]> sendWebRequest(
String url, MultiValueMap<String, Object> body) {
RestTemplate restTemplate = new RestTemplate();
// Set up headers, including API key
HttpHeaders headers = new HttpHeaders();
String apiKey = getApiKeyForUser();
headers.add("X-API-KEY", apiKey);
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
// Create HttpEntity with the body and headers
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
// Make the request to the REST endpoint
return restTemplate.exchange(url, HttpMethod.POST, entity, byte[].class);
}
private List<Resource> processOutputFiles(
String operation, ResponseEntity<byte[]> response, List<Resource> newOutputFiles)
throws IOException {
// Define filename
String newFilename;
if (operation.contains("auto-rename")) {
// If the operation is "auto-rename", generate a new filename.
// This is a simple example of generating a filename using current timestamp.
// Modify as per your needs.
newFilename = extractFilename(response);
} else {
// Otherwise, keep the original filename.
newFilename = removeTrailingNaming(extractFilename(response));
}
// Check if the response body is a zip file
if (isZip(response.getBody())) {
// Unzip the file and add all the files to the new output files
newOutputFiles.addAll(unzip(response.getBody()));
} else {
Resource outputResource =
new ByteArrayResource(response.getBody()) {
@Override
public String getFilename() {
return newFilename;
}
};
newOutputFiles.add(outputResource);
}
return newOutputFiles;
}
public String extractFilename(ResponseEntity<byte[]> response) {
// Default filename if not found
String filename = "default-filename.ext";
HttpHeaders headers = response.getHeaders();
String contentDisposition = headers.getFirst(HttpHeaders.CONTENT_DISPOSITION);
if (contentDisposition != null && !contentDisposition.isEmpty()) {
String[] parts = contentDisposition.split(";");
for (String part : parts) {
if (part.trim().startsWith("filename")) {
// Extracts filename and removes quotes if present
filename = part.split("=")[1].trim().replace("\"", "");
filename = URLDecoder.decode(filename, StandardCharsets.UTF_8);
break;
}
}
}
return filename;
}
List<Resource> generateInputFiles(File[] files) throws Exception {
if (files == null || files.length == 0) {
log.info("No files");
return null;
}
List<Resource> outputFiles = new ArrayList<>();
for (File file : files) {
Path path = Paths.get(file.getAbsolutePath());
// debug statement
log.info("Reading file: " + path);
if (Files.exists(path)) {
Resource fileResource =
new ByteArrayResource(Files.readAllBytes(path)) {
@Override
public String getFilename() {
return file.getName();
}
};
outputFiles.add(fileResource);
} else {
log.info("File not found: " + path);
}
}
log.info("Files successfully loaded. Starting processing...");
return outputFiles;
}
List<Resource> generateInputFiles(MultipartFile[] files) throws Exception {
if (files == null || files.length == 0) {
log.info("No files");
return null;
}
List<Resource> outputFiles = new ArrayList<>();
for (MultipartFile file : files) {
Resource fileResource =
new ByteArrayResource(file.getBytes()) {
@Override
public String getFilename() {
return Filenames.toSimpleFileName(file.getOriginalFilename());
}
};
outputFiles.add(fileResource);
}
log.info("Files successfully loaded. Starting processing...");
return outputFiles;
}
private boolean isZip(byte[] data) {
if (data == null || data.length < 4) {
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 List<Resource> unzip(byte[] data) throws IOException {
log.info("Unzipping data of length: {}", data.length);
List<Resource> unzippedFiles = new ArrayList<>();
try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
ZipInputStream zis = ZipSecurity.createHardenedInputStream(bais)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int count;
while ((count = zis.read(buffer)) != -1) {
baos.write(buffer, 0, count);
}
final String filename = entry.getName();
Resource fileResource =
new ByteArrayResource(baos.toByteArray()) {
@Override
public String getFilename() {
return filename;
}
};
// If the unzipped file is a zip file, unzip it
if (isZip(baos.toByteArray())) {
log.info("File {} is a zip file. Unzipping...", filename);
unzippedFiles.addAll(unzip(baos.toByteArray()));
} else {
unzippedFiles.add(fileResource);
}
}
}
log.info("Unzipping completed. {} files were unzipped.", unzippedFiles.size());
return unzippedFiles;
}
}
@@ -0,0 +1,350 @@
package stirling.software.SPDF.controller.api.security;
import java.awt.*;
import java.beans.PropertyEditorSupport;
import java.io.*;
import java.nio.file.Files;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Calendar;
import java.util.List;
import org.apache.commons.io.FileUtils;
import org.apache.pdfbox.examples.signature.CreateSignatureBase;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts.FontName;
import org.apache.pdfbox.pdmodel.graphics.blend.BlendMode;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.util.Matrix;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x500.style.IETFUtils;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMDecryptorProvider;
import org.bouncycastle.openssl.PEMEncryptedKeyPair;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder;
import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder;
import org.bouncycastle.operator.InputDecryptorProvider;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
import org.bouncycastle.pkcs.PKCSException;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
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.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.model.api.security.SignPDFWithCertRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/security")
@Slf4j
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
public class CertSignController {
static {
Security.addProvider(new BouncyCastleProvider());
}
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(
MultipartFile.class,
new PropertyEditorSupport() {
@Override
public void setAsText(String text) throws IllegalArgumentException {
setValue(null);
}
});
}
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static void sign(
CustomPDFDocumentFactory pdfDocumentFactory,
MultipartFile input,
OutputStream output,
CreateSignature instance,
Boolean showSignature,
Integer pageNumber,
String name,
String location,
String reason,
Boolean showLogo) {
try (PDDocument doc = pdfDocumentFactory.load(input)) {
PDSignature signature = new PDSignature();
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
signature.setName(name);
signature.setLocation(location);
signature.setReason(reason);
signature.setSignDate(Calendar.getInstance());
if (Boolean.TRUE.equals(showSignature)) {
SignatureOptions signatureOptions = new SignatureOptions();
signatureOptions.setVisualSignature(
instance.createVisibleSignature(doc, signature, pageNumber, showLogo));
signatureOptions.setPage(pageNumber);
doc.addSignature(signature, instance, signatureOptions);
} else {
doc.addSignature(signature, instance);
}
doc.saveIncremental(output);
} catch (Exception e) {
log.error("exception", e);
}
}
@PostMapping(
consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE
},
value = "/cert-sign")
@Operation(
summary = "Sign PDF with a Digital Certificate",
description =
"This endpoint accepts a PDF file, a digital certificate and related"
+ " information to sign the PDF. It then returns the digitally signed PDF"
+ " file. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> signPDFWithCert(@ModelAttribute SignPDFWithCertRequest request)
throws Exception {
MultipartFile pdf = request.getFileInput();
String certType = request.getCertType();
MultipartFile privateKeyFile = request.getPrivateKeyFile();
MultipartFile certFile = request.getCertFile();
MultipartFile p12File = request.getP12File();
MultipartFile jksfile = request.getJksFile();
String password = request.getPassword();
Boolean showSignature = request.getShowSignature();
String reason = request.getReason();
String location = request.getLocation();
String name = request.getName();
// Convert 1-indexed page number (user input) to 0-indexed page number (API requirement)
Integer pageNumber = request.getPageNumber() != null ? (request.getPageNumber() - 1) : null;
Boolean showLogo = request.getShowLogo();
if (certType == null) {
throw new IllegalArgumentException("Cert type must be provided");
}
KeyStore ks = null;
switch (certType) {
case "PEM":
ks = KeyStore.getInstance("JKS");
ks.load(null);
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password);
Certificate cert = (Certificate) getCertificateFromPEM(certFile.getBytes());
ks.setKeyEntry(
"alias", privateKey, password.toCharArray(), new Certificate[] {cert});
break;
case "PKCS12":
ks = KeyStore.getInstance("PKCS12");
ks.load(p12File.getInputStream(), password.toCharArray());
break;
case "JKS":
ks = KeyStore.getInstance("JKS");
ks.load(jksfile.getInputStream(), password.toCharArray());
break;
default:
throw new IllegalArgumentException("Invalid cert type: " + certType);
}
CreateSignature createSignature = new CreateSignature(ks, password.toCharArray());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
sign(
pdfDocumentFactory,
pdf,
baos,
createSignature,
showSignature,
pageNumber,
name,
location,
reason,
showLogo);
return WebResponseUtils.boasToWebResponse(
baos,
Filenames.toSimpleFileName(pdf.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
+ "_signed.pdf");
}
private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password)
throws IOException, OperatorCreationException, PKCSException {
try (PEMParser pemParser =
new PEMParser(new InputStreamReader(new ByteArrayInputStream(pemBytes)))) {
Object pemObject = pemParser.readObject();
JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
PrivateKeyInfo pkInfo;
if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo pkcs8EncryptedPrivateKeyInfo) {
InputDecryptorProvider decProv =
new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password.toCharArray());
pkInfo = pkcs8EncryptedPrivateKeyInfo.decryptPrivateKeyInfo(decProv);
} else if (pemObject instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) {
PEMDecryptorProvider decProv =
new JcePEMDecryptorProviderBuilder().build(password.toCharArray());
pkInfo = pemEncryptedKeyPair.decryptKeyPair(decProv).getPrivateKeyInfo();
} else {
pkInfo = ((PEMKeyPair) pemObject).getPrivateKeyInfo();
}
return converter.getPrivateKey(pkInfo);
}
}
private Certificate getCertificateFromPEM(byte[] pemBytes)
throws IOException, CertificateException {
try (ByteArrayInputStream bis = new ByteArrayInputStream(pemBytes)) {
return CertificateFactory.getInstance("X.509").generateCertificate(bis);
}
}
class CreateSignature extends CreateSignatureBase {
File logoFile;
public CreateSignature(KeyStore keystore, char[] pin)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
super(keystore, pin);
ClassPathResource resource = new ClassPathResource("static/images/signature.png");
try (InputStream is = resource.getInputStream()) {
logoFile = Files.createTempFile("signature", ".png").toFile();
FileUtils.copyInputStreamToFile(is, logoFile);
} catch (IOException e) {
log.error("Failed to load image signature file");
throw e;
}
}
public InputStream createVisibleSignature(
PDDocument srcDoc, PDSignature signature, Integer pageNumber, Boolean showLogo)
throws IOException {
// modified from org.apache.pdfbox.examples.signature.CreateVisibleSignature2
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(srcDoc.getPage(pageNumber).getMediaBox());
doc.addPage(page);
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
PDSignatureField signatureField = new PDSignatureField(acroForm);
PDAnnotationWidget widget = signatureField.getWidgets().get(0);
List<PDField> acroFormFields = acroForm.getFields();
acroForm.setSignaturesExist(true);
acroForm.setAppendOnly(true);
acroForm.getCOSObject().setDirect(true);
acroFormFields.add(signatureField);
PDRectangle rect = new PDRectangle(0, 0, 200, 50);
widget.setRectangle(rect);
// from PDVisualSigBuilder.createHolderForm()
PDStream stream = new PDStream(doc);
PDFormXObject form = new PDFormXObject(stream);
PDResources res = new PDResources();
form.setResources(res);
form.setFormType(1);
PDRectangle bbox = new PDRectangle(rect.getWidth(), rect.getHeight());
float height = bbox.getHeight();
form.setBBox(bbox);
PDFont font = new PDType1Font(FontName.TIMES_BOLD);
// from PDVisualSigBuilder.createAppearanceDictionary()
PDAppearanceDictionary appearance = new PDAppearanceDictionary();
appearance.getCOSObject().setDirect(true);
PDAppearanceStream appearanceStream = new PDAppearanceStream(form.getCOSObject());
appearance.setNormalAppearance(appearanceStream);
widget.setAppearance(appearance);
try (PDPageContentStream cs = new PDPageContentStream(doc, appearanceStream)) {
if (Boolean.TRUE.equals(showLogo)) {
cs.saveGraphicsState();
PDExtendedGraphicsState extState = new PDExtendedGraphicsState();
extState.setBlendMode(BlendMode.MULTIPLY);
extState.setNonStrokingAlphaConstant(0.5f);
cs.setGraphicsStateParameters(extState);
cs.transform(Matrix.getScaleInstance(0.08f, 0.08f));
PDImageXObject img =
PDImageXObject.createFromFileByExtension(logoFile, doc);
cs.drawImage(img, 100, 0);
cs.restoreGraphicsState();
}
// show text
float fontSize = 10;
float leading = fontSize * 1.5f;
cs.beginText();
cs.setFont(font, fontSize);
cs.setNonStrokingColor(Color.black);
cs.newLineAtOffset(fontSize, height - leading);
cs.setLeading(leading);
X509Certificate cert = (X509Certificate) getCertificateChain()[0];
// https://stackoverflow.com/questions/2914521/
X500Name x500Name = new X500Name(cert.getSubjectX500Principal().getName());
RDN cn = x500Name.getRDNs(BCStyle.CN)[0];
String name = IETFUtils.valueToString(cn.getFirst().getValue());
String date = signature.getSignDate().getTime().toString();
String reason = signature.getReason();
cs.showText("Signed by " + name);
cs.newLine();
cs.showText(date);
cs.newLine();
cs.showText(reason);
cs.endText();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return new ByteArrayInputStream(baos.toByteArray());
}
}
}
}
@@ -0,0 +1,805 @@
package stirling.software.SPDF.controller.api.security;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.*;
import org.apache.pdfbox.cos.COSInputStream;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.*;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureNode;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot;
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
import org.apache.pdfbox.pdmodel.encryption.PDEncryption;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.color.PDColorSpace;
import org.apache.pdfbox.pdmodel.graphics.color.PDICCBased;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentGroup;
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentProperties;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.xmpbox.XMPMetadata;
import org.apache.xmpbox.xml.DomXmpParser;
import org.apache.xmpbox.xml.XmpParsingException;
import org.apache.xmpbox.xml.XmpSerializer;
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 com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
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.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/security")
@Slf4j
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
public class GetInfoOnPDF {
static ObjectMapper objectMapper = new ObjectMapper();
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static void addOutlinesToArray(PDOutlineItem outline, ArrayNode arrayNode) {
if (outline == null) return;
ObjectNode outlineNode = objectMapper.createObjectNode();
outlineNode.put("Title", outline.getTitle());
// You can add other properties if needed
arrayNode.add(outlineNode);
PDOutlineItem child = outline.getFirstChild();
while (child != null) {
addOutlinesToArray(child, arrayNode);
child = child.getNextSibling();
}
}
/**
* Generates structured summary data about the PDF highlighting its unique characteristics such
* as encryption status, permission restrictions, and standards compliance.
*
* @param document The PDF document to analyze
* @return An ObjectNode containing structured summary data
*/
private ObjectNode generatePDFSummaryData(PDDocument document) {
ObjectNode summaryData = objectMapper.createObjectNode();
// Check if encrypted
if (document.isEncrypted()) {
summaryData.put("encrypted", true);
}
// Check permissions
AccessPermission ap = document.getCurrentAccessPermission();
ArrayNode restrictedPermissions = objectMapper.createArrayNode();
if (!ap.canAssembleDocument()) restrictedPermissions.add("document assembly");
if (!ap.canExtractContent()) restrictedPermissions.add("content extraction");
if (!ap.canExtractForAccessibility()) restrictedPermissions.add("accessibility extraction");
if (!ap.canFillInForm()) restrictedPermissions.add("form filling");
if (!ap.canModify()) restrictedPermissions.add("modification");
if (!ap.canModifyAnnotations()) restrictedPermissions.add("annotation modification");
if (!ap.canPrint()) restrictedPermissions.add("printing");
if (restrictedPermissions.size() > 0) {
summaryData.set("restrictedPermissions", restrictedPermissions);
summaryData.put("restrictedPermissionsCount", restrictedPermissions.size());
}
// Check standard compliance
if (checkForStandard(document, "PDF/A")) {
summaryData.put("standardCompliance", "PDF/A");
summaryData.put("standardPurpose", "long-term archiving");
} else if (checkForStandard(document, "PDF/X")) {
summaryData.put("standardCompliance", "PDF/X");
summaryData.put("standardPurpose", "graphic exchange");
} else if (checkForStandard(document, "PDF/UA")) {
summaryData.put("standardCompliance", "PDF/UA");
summaryData.put("standardPurpose", "universal accessibility");
} else if (checkForStandard(document, "PDF/E")) {
summaryData.put("standardCompliance", "PDF/E");
summaryData.put("standardPurpose", "engineering workflows");
} else if (checkForStandard(document, "PDF/VT")) {
summaryData.put("standardCompliance", "PDF/VT");
summaryData.put("standardPurpose", "variable and transactional printing");
}
return summaryData;
}
public static boolean checkForStandard(PDDocument document, String standardKeyword) {
// Check XMP Metadata
try {
PDMetadata pdMetadata = document.getDocumentCatalog().getMetadata();
if (pdMetadata != null) {
COSInputStream metaStream = pdMetadata.createInputStream();
DomXmpParser domXmpParser = new DomXmpParser();
XMPMetadata xmpMeta = domXmpParser.parse(metaStream);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
new XmpSerializer().serialize(xmpMeta, baos, true);
String xmpString = new String(baos.toByteArray(), StandardCharsets.UTF_8);
if (xmpString.contains(standardKeyword)) {
return true;
}
}
} catch (
Exception
e) { // Catching general exception for brevity, ideally you'd catch specific
// exceptions.
log.error("exception", e);
}
return false;
}
@PostMapping(consumes = "multipart/form-data", value = "/get-info-on-pdf")
@Operation(summary = "Summary here", description = "desc. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<byte[]> getPdfInfo(@ModelAttribute PDFFile request) throws IOException {
MultipartFile inputFile = request.getFileInput();
boolean readonly = true;
try (PDDocument pdfBoxDoc = pdfDocumentFactory.load(inputFile, readonly); ) {
ObjectMapper objectMapper = new ObjectMapper();
ObjectNode jsonOutput = objectMapper.createObjectNode();
// Metadata using PDFBox
PDDocumentInformation info = pdfBoxDoc.getDocumentInformation();
ObjectNode metadata = objectMapper.createObjectNode();
ObjectNode basicInfo = objectMapper.createObjectNode();
ObjectNode docInfoNode = objectMapper.createObjectNode();
ObjectNode compliancy = objectMapper.createObjectNode();
ObjectNode encryption = objectMapper.createObjectNode();
ObjectNode other = objectMapper.createObjectNode();
metadata.put("Title", info.getTitle());
metadata.put("Author", info.getAuthor());
metadata.put("Subject", info.getSubject());
metadata.put("Keywords", info.getKeywords());
metadata.put("Producer", info.getProducer());
metadata.put("Creator", info.getCreator());
metadata.put("CreationDate", formatDate(info.getCreationDate()));
metadata.put("ModificationDate", formatDate(info.getModificationDate()));
jsonOutput.set("Metadata", metadata);
// Total file size of the PDF
long fileSizeInBytes = inputFile.getSize();
basicInfo.put("FileSizeInBytes", fileSizeInBytes);
// Number of words, paragraphs, and images in the entire document
String fullText = new PDFTextStripper().getText(pdfBoxDoc);
String[] words = fullText.split("\\s+");
int wordCount = words.length;
int paragraphCount = fullText.split("\r\n|\r|\n").length;
basicInfo.put("WordCount", wordCount);
basicInfo.put("ParagraphCount", paragraphCount);
// Number of characters in the entire document (including spaces and special characters)
int charCount = fullText.length();
basicInfo.put("CharacterCount", charCount);
// Initialize the flags and types
boolean hasCompression = false;
String compressionType = "None";
basicInfo.put("Compression", hasCompression);
if (hasCompression) basicInfo.put("CompressionType", compressionType);
String language = pdfBoxDoc.getDocumentCatalog().getLanguage();
basicInfo.put("Language", language);
basicInfo.put("Number of pages", pdfBoxDoc.getNumberOfPages());
PDDocumentCatalog catalog = pdfBoxDoc.getDocumentCatalog();
String pageMode = catalog.getPageMode().name();
// Document Information using PDFBox
docInfoNode.put("PDF version", pdfBoxDoc.getVersion());
docInfoNode.put("Trapped", info.getTrapped());
docInfoNode.put("Page Mode", getPageModeDescription(pageMode));
;
PDAcroForm acroForm = pdfBoxDoc.getDocumentCatalog().getAcroForm();
ObjectNode formFieldsNode = objectMapper.createObjectNode();
if (acroForm != null) {
for (PDField field : acroForm.getFieldTree()) {
formFieldsNode.put(field.getFullyQualifiedName(), field.getValueAsString());
}
}
jsonOutput.set("FormFields", formFieldsNode);
// Generate structured summary data about PDF characteristics
ObjectNode summaryData = generatePDFSummaryData(pdfBoxDoc);
if (summaryData != null && summaryData.size() > 0) {
jsonOutput.set("SummaryData", summaryData);
}
// embeed files TODO size
if (catalog.getNames() != null) {
PDEmbeddedFilesNameTreeNode efTree = catalog.getNames().getEmbeddedFiles();
ArrayNode embeddedFilesArray = objectMapper.createArrayNode();
if (efTree != null) {
Map<String, PDComplexFileSpecification> efMap = efTree.getNames();
if (efMap != null) {
for (Map.Entry<String, PDComplexFileSpecification> entry :
efMap.entrySet()) {
ObjectNode embeddedFileNode = objectMapper.createObjectNode();
embeddedFileNode.put("Name", entry.getKey());
PDEmbeddedFile embeddedFile = entry.getValue().getEmbeddedFile();
if (embeddedFile != null) {
embeddedFileNode.put(
"FileSize", embeddedFile.getLength()); // size in bytes
}
embeddedFilesArray.add(embeddedFileNode);
}
}
}
other.set("EmbeddedFiles", embeddedFilesArray);
}
// attachments TODO size
ArrayNode attachmentsArray = objectMapper.createArrayNode();
for (PDPage page : pdfBoxDoc.getPages()) {
for (PDAnnotation annotation : page.getAnnotations()) {
if (annotation instanceof PDAnnotationFileAttachment fileAttachmentAnnotation) {
ObjectNode attachmentNode = objectMapper.createObjectNode();
attachmentNode.put("Name", fileAttachmentAnnotation.getAttachmentName());
attachmentNode.put("Description", fileAttachmentAnnotation.getContents());
attachmentsArray.add(attachmentNode);
}
}
}
other.set("Attachments", attachmentsArray);
// Javascript
PDDocumentNameDictionary namesDict = catalog.getNames();
ArrayNode javascriptArray = objectMapper.createArrayNode();
if (namesDict != null) {
PDJavascriptNameTreeNode javascriptDict = namesDict.getJavaScript();
if (javascriptDict != null) {
try {
Map<String, PDActionJavaScript> jsEntries = javascriptDict.getNames();
for (Map.Entry<String, PDActionJavaScript> entry : jsEntries.entrySet()) {
ObjectNode jsNode = objectMapper.createObjectNode();
jsNode.put("JS Name", entry.getKey());
PDActionJavaScript jsAction = entry.getValue();
if (jsAction != null) {
String jsCodeStr = jsAction.getAction();
if (jsCodeStr != null) {
jsNode.put("JS Script Length", jsCodeStr.length());
}
}
javascriptArray.add(jsNode);
}
} catch (IOException e) {
log.error("exception", e);
}
}
}
other.set("JavaScript", javascriptArray);
// TODO size
PDOptionalContentProperties ocProperties =
pdfBoxDoc.getDocumentCatalog().getOCProperties();
ArrayNode layersArray = objectMapper.createArrayNode();
if (ocProperties != null) {
for (PDOptionalContentGroup ocg : ocProperties.getOptionalContentGroups()) {
ObjectNode layerNode = objectMapper.createObjectNode();
layerNode.put("Name", ocg.getName());
layersArray.add(layerNode);
}
}
other.set("Layers", layersArray);
// TODO Security
PDStructureTreeRoot structureTreeRoot =
pdfBoxDoc.getDocumentCatalog().getStructureTreeRoot();
ArrayNode structureTreeArray;
try {
if (structureTreeRoot != null) {
structureTreeArray = exploreStructureTree(structureTreeRoot.getKids());
other.set("StructureTree", structureTreeArray);
}
} catch (Exception e) {
// TODO Auto-generated catch block
log.error("exception", e);
}
boolean isPdfACompliant = checkForStandard(pdfBoxDoc, "PDF/A");
boolean isPdfXCompliant = checkForStandard(pdfBoxDoc, "PDF/X");
boolean isPdfECompliant = checkForStandard(pdfBoxDoc, "PDF/E");
boolean isPdfVTCompliant = checkForStandard(pdfBoxDoc, "PDF/VT");
boolean isPdfUACompliant = checkForStandard(pdfBoxDoc, "PDF/UA");
boolean isPdfBCompliant =
checkForStandard(
pdfBoxDoc,
"PDF/B"); // If you want to check for PDF/Broadcast, though this isn't
// an official ISO standard.
boolean isPdfSECCompliant =
checkForStandard(
pdfBoxDoc,
"PDF/SEC"); // This might not be effective since PDF/SEC was under
// development in 2021.
compliancy.put("IsPDF/ACompliant", isPdfACompliant);
compliancy.put("IsPDF/XCompliant", isPdfXCompliant);
compliancy.put("IsPDF/ECompliant", isPdfECompliant);
compliancy.put("IsPDF/VTCompliant", isPdfVTCompliant);
compliancy.put("IsPDF/UACompliant", isPdfUACompliant);
compliancy.put("IsPDF/BCompliant", isPdfBCompliant);
compliancy.put("IsPDF/SECCompliant", isPdfSECCompliant);
PDOutlineNode root = pdfBoxDoc.getDocumentCatalog().getDocumentOutline();
ArrayNode bookmarksArray = objectMapper.createArrayNode();
if (root != null) {
for (PDOutlineItem child : root.children()) {
addOutlinesToArray(child, bookmarksArray);
}
}
other.set("Bookmarks/Outline/TOC", bookmarksArray);
PDMetadata pdMetadata = pdfBoxDoc.getDocumentCatalog().getMetadata();
String xmpString = null;
if (pdMetadata != null) {
try {
COSInputStream is = pdMetadata.createInputStream();
DomXmpParser domXmpParser = new DomXmpParser();
XMPMetadata xmpMeta = domXmpParser.parse(is);
ByteArrayOutputStream os = new ByteArrayOutputStream();
new XmpSerializer().serialize(xmpMeta, os, true);
xmpString = new String(os.toByteArray(), StandardCharsets.UTF_8);
} catch (XmpParsingException | IOException e) {
log.error("exception", e);
}
}
other.put("XMPMetadata", xmpString);
if (pdfBoxDoc.isEncrypted()) {
encryption.put("IsEncrypted", true);
// Retrieve encryption details using getEncryption()
PDEncryption pdfEncryption = pdfBoxDoc.getEncryption();
encryption.put("EncryptionAlgorithm", pdfEncryption.getFilter());
encryption.put("KeyLength", pdfEncryption.getLength());
// Add other encryption-related properties as needed
} else {
encryption.put("IsEncrypted", false);
}
ObjectNode permissionsNode = objectMapper.createObjectNode();
setNodePermissions(pdfBoxDoc, permissionsNode);
ObjectNode pageInfoParent = objectMapper.createObjectNode();
for (int pageNum = 0; pageNum < pdfBoxDoc.getNumberOfPages(); pageNum++) {
ObjectNode pageInfo = objectMapper.createObjectNode();
// Retrieve the page
PDPage page = pdfBoxDoc.getPage(pageNum);
// Page-level Information
PDRectangle mediaBox = page.getMediaBox();
float width = mediaBox.getWidth();
float height = mediaBox.getHeight();
ObjectNode sizeInfo = objectMapper.createObjectNode();
getDimensionInfo(sizeInfo, width, height);
sizeInfo.put("Standard Page", getPageSize(width, height));
pageInfo.set("Size", sizeInfo);
pageInfo.put("Rotation", page.getRotation());
pageInfo.put("Page Orientation", getPageOrientation(width, height));
// Boxes
pageInfo.put("MediaBox", mediaBox.toString());
// Assuming the following boxes are defined for your document; if not, you may get
// null values.
PDRectangle cropBox = page.getCropBox();
pageInfo.put("CropBox", cropBox == null ? "Undefined" : cropBox.toString());
PDRectangle bleedBox = page.getBleedBox();
pageInfo.put("BleedBox", bleedBox == null ? "Undefined" : bleedBox.toString());
PDRectangle trimBox = page.getTrimBox();
pageInfo.put("TrimBox", trimBox == null ? "Undefined" : trimBox.toString());
PDRectangle artBox = page.getArtBox();
pageInfo.put("ArtBox", artBox == null ? "Undefined" : artBox.toString());
// Content Extraction
PDFTextStripper textStripper = new PDFTextStripper();
textStripper.setStartPage(pageNum + 1);
textStripper.setEndPage(pageNum + 1);
String pageText = textStripper.getText(pdfBoxDoc);
pageInfo.put("Text Characters Count", pageText.length()); //
// Annotations
List<PDAnnotation> annotations = page.getAnnotations();
int subtypeCount = 0;
int contentsCount = 0;
for (PDAnnotation annotation : annotations) {
if (annotation.getSubtype() != null) {
subtypeCount++; // Increase subtype count
}
if (annotation.getContents() != null) {
contentsCount++; // Increase contents count
}
}
ObjectNode annotationsObject = objectMapper.createObjectNode();
annotationsObject.put("AnnotationsCount", annotations.size());
annotationsObject.put("SubtypeCount", subtypeCount);
annotationsObject.put("ContentsCount", contentsCount);
pageInfo.set("Annotations", annotationsObject);
// Images (simplified)
// This part is non-trivial as images can be embedded in multiple ways in a PDF.
// Here is a basic structure to recognize image XObjects on a page.
ArrayNode imagesArray = objectMapper.createArrayNode();
PDResources resources = page.getResources();
for (COSName name : resources.getXObjectNames()) {
PDXObject xObject = resources.getXObject(name);
if (xObject instanceof PDImageXObject image) {
ObjectNode imageNode = objectMapper.createObjectNode();
imageNode.put("Width", image.getWidth());
imageNode.put("Height", image.getHeight());
if (image.getMetadata() != null
&& image.getMetadata().getFile() != null
&& image.getMetadata().getFile().getFile() != null) {
imageNode.put("Name", image.getMetadata().getFile().getFile());
}
if (image.getColorSpace() != null) {
imageNode.put("ColorSpace", image.getColorSpace().getName());
}
imagesArray.add(imageNode);
}
}
pageInfo.set("Images", imagesArray);
// Links
ArrayNode linksArray = objectMapper.createArrayNode();
Set<String> uniqueURIs = new HashSet<>(); // To store unique URIs
for (PDAnnotation annotation : annotations) {
if (annotation instanceof PDAnnotationLink linkAnnotation) {
if (linkAnnotation.getAction() instanceof PDActionURI uriAction) {
String uri = uriAction.getURI();
uniqueURIs.add(uri); // Add to set to ensure uniqueness
}
}
}
// Add unique URIs to linksArray
for (String uri : uniqueURIs) {
ObjectNode linkNode = objectMapper.createObjectNode();
linkNode.put("URI", uri);
linksArray.add(linkNode);
}
pageInfo.set("Links", linksArray);
// Fonts
ArrayNode fontsArray = objectMapper.createArrayNode();
Map<String, ObjectNode> uniqueFontsMap = new HashMap<>();
for (COSName fontName : resources.getFontNames()) {
PDFont font = resources.getFont(fontName);
ObjectNode fontNode = objectMapper.createObjectNode();
fontNode.put("IsEmbedded", font.isEmbedded());
// PDFBox provides Font's BaseFont (i.e., the font name) directly
fontNode.put("Name", font.getName());
fontNode.put("Subtype", font.getType());
PDFontDescriptor fontDescriptor = font.getFontDescriptor();
if (fontDescriptor != null) {
fontNode.put("ItalicAngle", fontDescriptor.getItalicAngle());
int flags = fontDescriptor.getFlags();
fontNode.put("IsItalic", (flags & 1) != 0);
fontNode.put("IsBold", (flags & 64) != 0);
fontNode.put("IsFixedPitch", (flags & 2) != 0);
fontNode.put("IsSerif", (flags & 4) != 0);
fontNode.put("IsSymbolic", (flags & 8) != 0);
fontNode.put("IsScript", (flags & 16) != 0);
fontNode.put("IsNonsymbolic", (flags & 32) != 0);
fontNode.put("FontFamily", fontDescriptor.getFontFamily());
// Font stretch and BBox are not directly available in PDFBox's API, so
// these are omitted for simplicity
fontNode.put("FontWeight", fontDescriptor.getFontWeight());
}
// Create a unique key for this font node based on its attributes
String uniqueKey = fontNode.toString();
// Increment count if this font exists, or initialize it if new
if (uniqueFontsMap.containsKey(uniqueKey)) {
ObjectNode existingFontNode = uniqueFontsMap.get(uniqueKey);
int count = existingFontNode.get("Count").asInt() + 1;
existingFontNode.put("Count", count);
} else {
fontNode.put("Count", 1);
uniqueFontsMap.put(uniqueKey, fontNode);
}
}
// Add unique font entries to fontsArray
for (ObjectNode uniqueFontNode : uniqueFontsMap.values()) {
fontsArray.add(uniqueFontNode);
}
pageInfo.set("Fonts", fontsArray);
// Access resources dictionary
ArrayNode colorSpacesArray = objectMapper.createArrayNode();
Iterable<COSName> colorSpaceNames = resources.getColorSpaceNames();
for (COSName name : colorSpaceNames) {
PDColorSpace colorSpace = resources.getColorSpace(name);
if (colorSpace instanceof PDICCBased iccBased) {
PDStream iccData = iccBased.getPDStream();
byte[] iccBytes = iccData.toByteArray();
// TODO: Further decode and analyze the ICC data if needed
ObjectNode iccProfileNode = objectMapper.createObjectNode();
iccProfileNode.put("ICC Profile Length", iccBytes.length);
colorSpacesArray.add(iccProfileNode);
}
}
pageInfo.set("Color Spaces & ICC Profiles", colorSpacesArray);
// Other XObjects
Map<String, Integer> xObjectCountMap =
new HashMap<>(); // To store the count for each type
for (COSName name : resources.getXObjectNames()) {
PDXObject xObject = resources.getXObject(name);
String xObjectType;
if (xObject instanceof PDImageXObject) {
xObjectType = "Image";
} else if (xObject instanceof PDFormXObject) {
xObjectType = "Form";
} else {
xObjectType = "Other";
}
// Increment the count for this type in the map
xObjectCountMap.put(
xObjectType, xObjectCountMap.getOrDefault(xObjectType, 0) + 1);
}
// Add the count map to pageInfo (or wherever you want to store it)
ObjectNode xObjectCountNode = objectMapper.createObjectNode();
for (Map.Entry<String, Integer> entry : xObjectCountMap.entrySet()) {
xObjectCountNode.put(entry.getKey(), entry.getValue());
}
pageInfo.set("XObjectCounts", xObjectCountNode);
ArrayNode multimediaArray = objectMapper.createArrayNode();
for (PDAnnotation annotation : annotations) {
if ("RichMedia".equals(annotation.getSubtype())) {
ObjectNode multimediaNode = objectMapper.createObjectNode();
// Extract details from the annotation as needed
multimediaArray.add(multimediaNode);
}
}
pageInfo.set("Multimedia", multimediaArray);
pageInfoParent.set("Page " + (pageNum + 1), pageInfo);
}
jsonOutput.set("BasicInfo", basicInfo);
jsonOutput.set("DocumentInfo", docInfoNode);
jsonOutput.set("Compliancy", compliancy);
jsonOutput.set("Encryption", encryption);
jsonOutput.set("Permissions", permissionsNode); // set the node under "Permissions"
jsonOutput.set("Other", other);
jsonOutput.set("PerPageInfo", pageInfoParent);
// Save JSON to file
String jsonString =
objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonOutput);
return WebResponseUtils.bytesToWebResponse(
jsonString.getBytes(StandardCharsets.UTF_8),
"response.json",
MediaType.APPLICATION_JSON);
} catch (Exception e) {
log.error("exception", e);
}
return null;
}
private void setNodePermissions(PDDocument pdfBoxDoc, ObjectNode permissionsNode) {
AccessPermission ap = pdfBoxDoc.getCurrentAccessPermission();
permissionsNode.put("Document Assembly", getPermissionState(ap.canAssembleDocument()));
permissionsNode.put("Extracting Content", getPermissionState(ap.canExtractContent()));
permissionsNode.put(
"Extracting for accessibility",
getPermissionState(ap.canExtractForAccessibility()));
permissionsNode.put("Form Filling", getPermissionState(ap.canFillInForm()));
permissionsNode.put("Modifying", getPermissionState(ap.canModify()));
permissionsNode.put("Modifying annotations", getPermissionState(ap.canModifyAnnotations()));
permissionsNode.put("Printing", getPermissionState(ap.canPrint()));
}
private String getPermissionState(boolean state) {
return state ? "Allowed" : "Not Allowed";
}
public String getPageOrientation(double width, double height) {
if (width > height) {
return "Landscape";
} else if (height > width) {
return "Portrait";
} else {
return "Square";
}
}
public String getPageSize(float width, float height) {
// Define standard page sizes
Map<String, PDRectangle> standardSizes = new HashMap<>();
standardSizes.put("Letter", PDRectangle.LETTER);
standardSizes.put("LEGAL", PDRectangle.LEGAL);
standardSizes.put("A0", PDRectangle.A0);
standardSizes.put("A1", PDRectangle.A1);
standardSizes.put("A2", PDRectangle.A2);
standardSizes.put("A3", PDRectangle.A3);
standardSizes.put("A4", PDRectangle.A4);
standardSizes.put("A5", PDRectangle.A5);
standardSizes.put("A6", PDRectangle.A6);
for (Map.Entry<String, PDRectangle> entry : standardSizes.entrySet()) {
PDRectangle size = entry.getValue();
if (isCloseToSize(width, height, size.getWidth(), size.getHeight())) {
return entry.getKey();
}
}
return "Custom";
}
private boolean isCloseToSize(
float width, float height, float standardWidth, float standardHeight) {
float tolerance = 1.0f; // You can adjust the tolerance as needed
return Math.abs(width - standardWidth) <= tolerance
&& Math.abs(height - standardHeight) <= tolerance;
}
public ObjectNode getDimensionInfo(ObjectNode dimensionInfo, float width, float height) {
float ppi = 72; // Points Per Inch
float widthInInches = width / ppi;
float heightInInches = height / ppi;
float widthInCm = widthInInches * 2.54f;
float heightInCm = heightInInches * 2.54f;
dimensionInfo.put("Width (px)", String.format("%.2f", width));
dimensionInfo.put("Height (px)", String.format("%.2f", height));
dimensionInfo.put("Width (in)", String.format("%.2f", widthInInches));
dimensionInfo.put("Height (in)", String.format("%.2f", heightInInches));
dimensionInfo.put("Width (cm)", String.format("%.2f", widthInCm));
dimensionInfo.put("Height (cm)", String.format("%.2f", heightInCm));
return dimensionInfo;
}
public ArrayNode exploreStructureTree(List<Object> nodes) {
ArrayNode elementsArray = objectMapper.createArrayNode();
if (nodes != null) {
for (Object obj : nodes) {
if (obj instanceof PDStructureNode node) {
ObjectNode elementNode = objectMapper.createObjectNode();
if (node instanceof PDStructureElement structureElement) {
elementNode.put("Type", structureElement.getStructureType());
elementNode.put("Content", getContent(structureElement));
// Recursively explore child elements
ArrayNode childElements = exploreStructureTree(structureElement.getKids());
if (childElements.size() > 0) {
elementNode.set("Children", childElements);
}
}
elementsArray.add(elementNode);
}
}
}
return elementsArray;
}
public String getContent(PDStructureElement structureElement) {
StringBuilder contentBuilder = new StringBuilder();
for (Object item : structureElement.getKids()) {
if (item instanceof COSString cosString) {
contentBuilder.append(cosString.getString());
} else if (item instanceof PDStructureElement) {
// For simplicity, we're handling only COSString and PDStructureElement here
// but a more comprehensive method would handle other types too
contentBuilder.append(getContent((PDStructureElement) item));
}
}
return contentBuilder.toString();
}
private String formatDate(Calendar calendar) {
if (calendar != null) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return sdf.format(calendar.getTime());
} else {
return null;
}
}
private String getPageModeDescription(String pageMode) {
return pageMode != null ? pageMode.toString().replaceFirst("/", "") : "Unknown";
}
}
@@ -0,0 +1,107 @@
package stirling.software.SPDF.controller.api.security;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
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.security.AddPasswordRequest;
import stirling.software.SPDF.model.api.security.PDFPasswordRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
public class PasswordController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-password")
@Operation(
summary = "Remove password from a PDF file",
description =
"This endpoint removes the password from a protected PDF file. Users need to"
+ " provide the existing password. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> removePassword(@ModelAttribute PDFPasswordRequest request)
throws IOException {
MultipartFile fileInput = request.getFileInput();
String password = request.getPassword();
PDDocument document = pdfDocumentFactory.load(fileInput, password);
document.setAllSecurityToBeRemoved(true);
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(fileInput.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_password_removed.pdf");
}
@PostMapping(consumes = "multipart/form-data", value = "/add-password")
@Operation(
summary = "Add password to a PDF file",
description =
"This endpoint adds password protection to a PDF file. Users can specify a set"
+ " of permissions that should be applied to the file. Input:PDF"
+ " Output:PDF")
public ResponseEntity<byte[]> addPassword(@ModelAttribute AddPasswordRequest request)
throws IOException {
MultipartFile fileInput = request.getFileInput();
String ownerPassword = request.getOwnerPassword();
String password = request.getPassword();
int keyLength = request.getKeyLength();
boolean preventAssembly = Boolean.TRUE.equals(request.getPreventAssembly());
boolean preventExtractContent = Boolean.TRUE.equals(request.getPreventExtractContent());
boolean preventExtractForAccessibility =
Boolean.TRUE.equals(request.getPreventExtractForAccessibility());
boolean preventFillInForm = Boolean.TRUE.equals(request.getPreventFillInForm());
boolean preventModify = Boolean.TRUE.equals(request.getPreventModify());
boolean preventModifyAnnotations =
Boolean.TRUE.equals(request.getPreventModifyAnnotations());
boolean preventPrinting = Boolean.TRUE.equals(request.getPreventPrinting());
boolean preventPrintingFaithful = Boolean.TRUE.equals(request.getPreventPrintingFaithful());
PDDocument document = pdfDocumentFactory.load(fileInput);
AccessPermission ap = new AccessPermission();
ap.setCanAssembleDocument(!preventAssembly);
ap.setCanExtractContent(!preventExtractContent);
ap.setCanExtractForAccessibility(!preventExtractForAccessibility);
ap.setCanFillInForm(!preventFillInForm);
ap.setCanModify(!preventModify);
ap.setCanModifyAnnotations(!preventModifyAnnotations);
ap.setCanPrint(!preventPrinting);
ap.setCanPrintFaithful(!preventPrintingFaithful);
StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPassword, password, ap);
if (!"".equals(ownerPassword) || !"".equals(password)) {
spp.setEncryptionKeyLength(keyLength);
}
spp.setPermissions(ap);
document.protect(spp);
if ("".equals(ownerPassword) && "".equals(password))
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(fileInput.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_permissions.pdf");
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(fileInput.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_passworded.pdf");
}
}
@@ -0,0 +1,268 @@
package stirling.software.SPDF.controller.api.security;
import java.awt.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
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.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.model.PDFText;
import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest;
import stirling.software.SPDF.model.api.security.RedactPdfRequest;
import stirling.software.SPDF.pdf.TextFinder;
import stirling.software.common.model.api.security.RedactionArea;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.common.util.propertyeditor.StringToArrayListPropertyEditor;
@RestController
@RequestMapping("/api/v1/security")
@Slf4j
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
public class RedactController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(
List.class, "redactions", new StringToArrayListPropertyEditor());
}
@PostMapping(value = "/redact", consumes = "multipart/form-data")
@Operation(
summary = "Redacts areas and pages in a PDF document",
description =
"This operation takes an input PDF file with a list of areas, page"
+ " number(s)/range(s)/function(s) to redact. Input:PDF, Output:PDF,"
+ " Type:SISO")
public ResponseEntity<byte[]> redactPDF(@ModelAttribute ManualRedactPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
List<RedactionArea> redactionAreas = request.getRedactions();
PDDocument document = pdfDocumentFactory.load(file);
PDPageTree allPages = document.getDocumentCatalog().getPages();
redactPages(request, document, allPages);
redactAreas(redactionAreas, document, allPages);
if (Boolean.TRUE.equals(request.getConvertPDFToImage())) {
PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document);
document.close();
document = convertedPdf;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
document.close();
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
+ "_redacted.pdf");
}
private void redactAreas(
List<RedactionArea> redactionAreas, PDDocument document, PDPageTree allPages)
throws IOException {
// Group redaction areas by page
Map<Integer, List<RedactionArea>> redactionsByPage = new HashMap<>();
// Process and validate each redaction area
for (RedactionArea redactionArea : redactionAreas) {
if (redactionArea.getPage() == null
|| redactionArea.getPage() <= 0
|| redactionArea.getHeight() == null
|| redactionArea.getHeight() <= 0.0D
|| redactionArea.getWidth() == null
|| redactionArea.getWidth() <= 0.0D) continue;
// Group by page number
redactionsByPage
.computeIfAbsent(redactionArea.getPage(), k -> new ArrayList<>())
.add(redactionArea);
}
// Process each page only once
for (Map.Entry<Integer, List<RedactionArea>> entry : redactionsByPage.entrySet()) {
Integer pageNumber = entry.getKey();
List<RedactionArea> areasForPage = entry.getValue();
if (pageNumber > allPages.getCount()) {
continue; // Skip if page number is out of bounds
}
PDPage page = allPages.get(pageNumber - 1);
PDRectangle box = page.getBBox();
// Create only one content stream per page
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
// Process all redactions for this page
for (RedactionArea redactionArea : areasForPage) {
Color redactColor = decodeOrDefault(redactionArea.getColor(), Color.BLACK);
contentStream.setNonStrokingColor(redactColor);
float x = redactionArea.getX().floatValue();
float y = redactionArea.getY().floatValue();
float width = redactionArea.getWidth().floatValue();
float height = redactionArea.getHeight().floatValue();
contentStream.addRect(x, box.getHeight() - y - height, width, height);
contentStream.fill();
}
contentStream.close();
}
}
private void redactPages(
ManualRedactPdfRequest request, PDDocument document, PDPageTree allPages)
throws IOException {
Color redactColor = decodeOrDefault(request.getPageRedactionColor(), Color.BLACK);
List<Integer> pageNumbers = getPageNumbers(request, allPages.getCount());
for (Integer pageNumber : pageNumbers) {
PDPage page = allPages.get(pageNumber);
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
contentStream.setNonStrokingColor(redactColor);
PDRectangle box = page.getBBox();
contentStream.addRect(0, 0, box.getWidth(), box.getHeight());
contentStream.fill();
contentStream.close();
}
}
private Color decodeOrDefault(String hex, Color defaultColor) {
try {
if (hex != null && !hex.startsWith("#")) {
hex = "#" + hex;
}
return Color.decode(hex);
} catch (Exception e) {
return defaultColor;
}
}
private List<Integer> getPageNumbers(ManualRedactPdfRequest request, int pagesCount) {
String pageNumbersInput = request.getPageNumbers();
String[] parsedPageNumbers =
pageNumbersInput != null ? pageNumbersInput.split(",") : new String[0];
List<Integer> pageNumbers = GeneralUtils.parsePageList(parsedPageNumbers, pagesCount, false);
Collections.sort(pageNumbers);
return pageNumbers;
}
@PostMapping(value = "/auto-redact", consumes = "multipart/form-data")
@Operation(
summary = "Redacts listOfText in a PDF document",
description =
"This operation takes an input PDF file and redacts the provided listOfText."
+ " Input:PDF, Output:PDF, Type:SISO")
public ResponseEntity<byte[]> redactPdf(@ModelAttribute RedactPdfRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
String listOfTextString = request.getListOfText();
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
String colorString = request.getRedactColor();
float customPadding = request.getCustomPadding();
boolean convertPDFToImage = Boolean.TRUE.equals(request.getConvertPDFToImage());
String[] listOfText = listOfTextString.split("\n");
PDDocument document = pdfDocumentFactory.load(file);
Color redactColor;
try {
if (!colorString.startsWith("#")) {
colorString = "#" + colorString;
}
redactColor = Color.decode(colorString);
} catch (NumberFormatException e) {
log.warn("Invalid color string provided. Using default color BLACK for redaction.");
redactColor = Color.BLACK;
}
for (String text : listOfText) {
text = text.trim();
TextFinder textFinder = new TextFinder(text, useRegex, wholeWordSearchBool);
List<PDFText> foundTexts = textFinder.getTextLocations(document);
redactFoundText(document, foundTexts, customPadding, redactColor);
}
if (convertPDFToImage) {
PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document);
document.close();
document = convertedPdf;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
document.close();
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
+ "_redacted.pdf");
}
private void redactFoundText(
PDDocument document, List<PDFText> blocks, float customPadding, Color redactColor)
throws IOException {
var allPages = document.getDocumentCatalog().getPages();
for (PDFText block : blocks) {
var page = allPages.get(block.getPageIndex());
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
contentStream.setNonStrokingColor(redactColor);
float padding = (block.getY2() - block.getY1()) * 0.3f + customPadding;
PDRectangle pageBox = page.getBBox();
contentStream.addRect(
block.getX1(),
pageBox.getHeight() - block.getY1() - padding,
block.getX2() - block.getX1(),
block.getY2() - block.getY1() + 2 * padding);
contentStream.fill();
contentStream.close();
}
}
}
@@ -0,0 +1,70 @@
package stirling.software.SPDF.controller.api.security;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
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.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
public class RemoveCertSignController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign")
@Operation(
summary = "Remove digital signature from PDF",
description =
"This endpoint accepts a PDF file and returns the PDF file without the digital"
+ " signature. Input:PDF, Output:PDF Type:SISO")
public ResponseEntity<byte[]> removeCertSignPDF(@ModelAttribute PDFFile request)
throws Exception {
MultipartFile pdf = request.getFileInput();
// Load the PDF document
PDDocument document = pdfDocumentFactory.load(pdf);
// Get the document catalog
PDDocumentCatalog catalog = document.getDocumentCatalog();
// Get the AcroForm
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
// Remove signature fields safely
List<PDField> fieldsToRemove =
acroForm.getFields().stream()
.filter(field -> field instanceof PDSignatureField)
.toList();
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(fieldsToRemove, false);
}
}
// Return the modified PDF as a response
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(pdf.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
+ "_unsigned.pdf");
}
}
@@ -0,0 +1,200 @@
package stirling.software.SPDF.controller.api.security;
import java.io.IOException;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.pdfbox.pdmodel.interactive.action.PDAction;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionLaunch;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.action.PDFormFieldAdditionalActions;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
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.security.SanitizePdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
public class SanitizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf")
@Operation(
summary = "Sanitize a PDF file",
description =
"This endpoint processes a PDF file and removes specific elements based on the"
+ " provided options. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> sanitizePDF(@ModelAttribute SanitizePdfRequest request)
throws IOException {
MultipartFile inputFile = request.getFileInput();
boolean removeJavaScript = Boolean.TRUE.equals(request.getRemoveJavaScript());
boolean removeEmbeddedFiles = Boolean.TRUE.equals(request.getRemoveEmbeddedFiles());
boolean removeXMPMetadata = Boolean.TRUE.equals(request.getRemoveXMPMetadata());
boolean removeMetadata = Boolean.TRUE.equals(request.getRemoveMetadata());
boolean removeLinks = Boolean.TRUE.equals(request.getRemoveLinks());
boolean removeFonts = Boolean.TRUE.equals(request.getRemoveFonts());
PDDocument document = pdfDocumentFactory.load(inputFile, true);
if (removeJavaScript) {
sanitizeJavaScript(document);
}
if (removeEmbeddedFiles) {
sanitizeEmbeddedFiles(document);
}
if (removeXMPMetadata) {
sanitizeXMPMetadata(document);
}
if (removeMetadata) {
sanitizeDocumentInfoMetadata(document);
}
if (removeLinks) {
sanitizeLinks(document);
}
if (removeFonts) {
sanitizeFonts(document);
}
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_sanitized.pdf");
}
private void sanitizeJavaScript(PDDocument document) throws IOException {
// Get the root dictionary (catalog) of the PDF
PDDocumentCatalog catalog = document.getDocumentCatalog();
// Get the Names dictionary
COSDictionary namesDict =
(COSDictionary) catalog.getCOSObject().getDictionaryObject(COSName.NAMES);
if (namesDict != null) {
// Get the JavaScript dictionary
COSDictionary javaScriptDict =
(COSDictionary) namesDict.getDictionaryObject(COSName.getPDFName("JavaScript"));
if (javaScriptDict != null) {
// Remove the JavaScript dictionary
namesDict.removeItem(COSName.getPDFName("JavaScript"));
}
}
for (PDPage page : document.getPages()) {
for (PDAnnotation annotation : page.getAnnotations()) {
if (annotation instanceof PDAnnotationWidget widget) {
PDAction action = widget.getAction();
if (action instanceof PDActionJavaScript) {
widget.setAction(null);
}
}
}
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
if (acroForm != null) {
for (PDField field : acroForm.getFields()) {
PDFormFieldAdditionalActions actions = field.getActions();
if (actions != null) {
if (actions.getC() instanceof PDActionJavaScript) {
actions.setC(null);
}
if (actions.getF() instanceof PDActionJavaScript) {
actions.setF(null);
}
if (actions.getK() instanceof PDActionJavaScript) {
actions.setK(null);
}
if (actions.getV() instanceof PDActionJavaScript) {
actions.setV(null);
}
}
}
}
}
}
private void sanitizeEmbeddedFiles(PDDocument document) {
PDPageTree allPages = document.getPages();
for (PDPage page : allPages) {
PDResources res = page.getResources();
if (res != null && res.getCOSObject() != null) {
res.getCOSObject().removeItem(COSName.getPDFName("EmbeddedFiles"));
}
}
}
private void sanitizeXMPMetadata(PDDocument document) {
if (document.getDocumentCatalog() != null) {
PDMetadata metadata = document.getDocumentCatalog().getMetadata();
if (metadata != null) {
document.getDocumentCatalog().setMetadata(null);
}
}
}
private void sanitizeDocumentInfoMetadata(PDDocument document) {
PDDocumentInformation docInfo = document.getDocumentInformation();
if (docInfo != null) {
PDDocumentInformation newInfo = new PDDocumentInformation();
document.setDocumentInformation(newInfo);
}
}
private void sanitizeLinks(PDDocument document) throws IOException {
for (PDPage page : document.getPages()) {
for (PDAnnotation annotation : page.getAnnotations()) {
if (annotation != null && annotation instanceof PDAnnotationLink linkAnnotation) {
PDAction action = linkAnnotation.getAction();
if (action != null
&& (action instanceof PDActionLaunch
|| action instanceof PDActionURI)) {
linkAnnotation.setAction(null);
}
}
}
}
}
private void sanitizeFonts(PDDocument document) {
for (PDPage page : document.getPages()) {
if (page != null
&& page.getResources() != null
&& page.getResources().getCOSObject() != null) {
page.getResources().getCOSObject().removeItem(COSName.getPDFName("Font"));
}
}
}
}
@@ -0,0 +1,191 @@
package stirling.software.SPDF.controller.api.security;
import java.beans.PropertyEditorSupport;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPublicKey;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cms.CMSProcessable;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.SignerInformationStore;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
import org.bouncycastle.util.Store;
import org.springframework.http.MediaType;
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.model.api.security.SignatureValidationRequest;
import stirling.software.SPDF.model.api.security.SignatureValidationResult;
import stirling.software.SPDF.service.CertificateValidationService;
import stirling.software.common.service.CustomPDFDocumentFactory;
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
public class ValidateSignatureController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final CertificateValidationService certValidationService;
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(
MultipartFile.class,
new PropertyEditorSupport() {
@Override
public void setAsText(String text) throws IllegalArgumentException {
setValue(null);
}
});
}
@Operation(
summary = "Validate PDF Digital Signature",
description =
"Validates the digital signatures in a PDF file against default or custom"
+ " certificates. Input:PDF Output:JSON Type:SISO")
@PostMapping(value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<List<SignatureValidationResult>> validateSignature(
@ModelAttribute SignatureValidationRequest request) throws IOException {
List<SignatureValidationResult> results = new ArrayList<>();
MultipartFile file = request.getFileInput();
MultipartFile certFile = request.getCertFile();
// Load custom certificate if provided
X509Certificate customCert = null;
if (certFile != null && !certFile.isEmpty()) {
try (ByteArrayInputStream certStream = new ByteArrayInputStream(certFile.getBytes())) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
customCert = (X509Certificate) cf.generateCertificate(certStream);
} catch (CertificateException e) {
throw new RuntimeException("Invalid certificate file: " + e.getMessage());
}
}
try (PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
List<PDSignature> signatures = document.getSignatureDictionaries();
for (PDSignature sig : signatures) {
SignatureValidationResult result = new SignatureValidationResult();
try {
byte[] signedContent = sig.getSignedContent(file.getInputStream());
byte[] signatureBytes = sig.getContents(file.getInputStream());
CMSProcessable content = new CMSProcessableByteArray(signedContent);
CMSSignedData signedData = new CMSSignedData(content, signatureBytes);
Store<X509CertificateHolder> certStore = signedData.getCertificates();
SignerInformationStore signerStore = signedData.getSignerInfos();
for (SignerInformation signer : signerStore.getSigners()) {
X509CertificateHolder certHolder =
(X509CertificateHolder)
certStore.getMatches(signer.getSID()).iterator().next();
X509Certificate cert =
new JcaX509CertificateConverter().getCertificate(certHolder);
boolean isValid =
signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert));
result.setValid(isValid);
// Additional validations
result.setChainValid(
customCert != null
? certValidationService
.validateCertificateChainWithCustomCert(
cert, customCert)
: certValidationService.validateCertificateChain(cert));
result.setTrustValid(
customCert != null
? certValidationService.validateTrustWithCustomCert(
cert, customCert)
: certValidationService.validateTrustStore(cert));
result.setNotRevoked(!certValidationService.isRevoked(cert));
result.setNotExpired(!cert.getNotAfter().before(new Date()));
// Set basic signature info
result.setSignerName(sig.getName());
result.setSignatureDate(sig.getSignDate().getTime().toString());
result.setReason(sig.getReason());
result.setLocation(sig.getLocation());
// Set new certificate details
result.setIssuerDN(cert.getIssuerX500Principal().getName());
result.setSubjectDN(cert.getSubjectX500Principal().getName());
result.setSerialNumber(cert.getSerialNumber().toString(16)); // Hex format
result.setValidFrom(cert.getNotBefore().toString());
result.setValidUntil(cert.getNotAfter().toString());
result.setSignatureAlgorithm(cert.getSigAlgName());
// Get key size (if possible)
try {
result.setKeySize(
((RSAPublicKey) cert.getPublicKey()).getModulus().bitLength());
} catch (Exception e) {
// If not RSA or error, set to 0
result.setKeySize(0);
}
result.setVersion(String.valueOf(cert.getVersion()));
// Set key usage
List<String> keyUsages = new ArrayList<>();
boolean[] keyUsageFlags = cert.getKeyUsage();
if (keyUsageFlags != null) {
String[] keyUsageLabels = {
"Digital Signature", "Non-Repudiation", "Key Encipherment",
"Data Encipherment", "Key Agreement", "Certificate Signing",
"CRL Signing", "Encipher Only", "Decipher Only"
};
for (int i = 0; i < keyUsageFlags.length; i++) {
if (keyUsageFlags[i]) {
keyUsages.add(keyUsageLabels[i]);
}
}
}
result.setKeyUsages(keyUsages);
// Check if self-signed
result.setSelfSigned(
cert.getSubjectX500Principal()
.equals(cert.getIssuerX500Principal()));
}
} catch (Exception e) {
result.setValid(false);
result.setErrorMessage("Signature validation failed: " + e.getMessage());
}
results.add(result);
}
}
return ResponseEntity.ok(results);
}
}
@@ -0,0 +1,312 @@
package stirling.software.SPDF.controller.api.security;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.beans.PropertyEditorSupport;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import javax.imageio.ImageIO;
import org.apache.commons.io.IOUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;
import org.apache.pdfbox.util.Matrix;
import org.springframework.core.io.ClassPathResource;
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.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.security.AddWatermarkRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
public class WatermarkController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(
MultipartFile.class,
new PropertyEditorSupport() {
@Override
public void setAsText(String text) throws IllegalArgumentException {
setValue(null);
}
});
}
@PostMapping(consumes = "multipart/form-data", value = "/add-watermark")
@Operation(
summary = "Add watermark to a PDF file",
description =
"This endpoint adds a watermark to a given PDF file. Users can specify the"
+ " watermark type (text or image), rotation, opacity, width spacer, and"
+ " height spacer. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> addWatermark(@ModelAttribute AddWatermarkRequest request)
throws IOException, Exception {
MultipartFile pdfFile = request.getFileInput();
String watermarkType = request.getWatermarkType();
String watermarkText = request.getWatermarkText();
MultipartFile watermarkImage = request.getWatermarkImage();
String alphabet = request.getAlphabet();
float fontSize = request.getFontSize();
float rotation = request.getRotation();
float opacity = request.getOpacity();
int widthSpacer = request.getWidthSpacer();
int heightSpacer = request.getHeightSpacer();
String customColor = request.getCustomColor();
boolean convertPdfToImage = Boolean.TRUE.equals(request.getConvertPDFToImage());
// Load the input PDF
PDDocument document = pdfDocumentFactory.load(pdfFile);
// Create a page in the document
for (PDPage page : document.getPages()) {
// Get the page's content stream
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
// Set transparency
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
graphicsState.setNonStrokingAlphaConstant(opacity);
contentStream.setGraphicsStateParameters(graphicsState);
if ("text".equalsIgnoreCase(watermarkType)) {
addTextWatermark(
contentStream,
watermarkText,
document,
page,
rotation,
widthSpacer,
heightSpacer,
fontSize,
alphabet,
customColor);
} else if ("image".equalsIgnoreCase(watermarkType)) {
addImageWatermark(
contentStream,
watermarkImage,
document,
page,
rotation,
widthSpacer,
heightSpacer,
fontSize);
}
// Close the content stream
contentStream.close();
}
if (convertPdfToImage) {
PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document);
document.close();
document = convertedPdf;
}
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_watermarked.pdf");
}
private void addTextWatermark(
PDPageContentStream contentStream,
String watermarkText,
PDDocument document,
PDPage page,
float rotation,
int widthSpacer,
int heightSpacer,
float fontSize,
String alphabet,
String colorString)
throws IOException {
String resourceDir = "";
PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
switch (alphabet) {
case "arabic":
resourceDir = "static/fonts/NotoSansArabic-Regular.ttf";
break;
case "japanese":
resourceDir = "static/fonts/Meiryo.ttf";
break;
case "korean":
resourceDir = "static/fonts/malgun.ttf";
break;
case "chinese":
resourceDir = "static/fonts/SimSun.ttf";
break;
case "roman":
default:
resourceDir = "static/fonts/NotoSans-Regular.ttf";
break;
}
if (!"".equals(resourceDir)) {
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf("."));
File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile();
try (InputStream is = classPathResource.getInputStream();
FileOutputStream os = new FileOutputStream(tempFile)) {
IOUtils.copy(is, os);
font = PDType0Font.load(document, tempFile);
} finally {
if (tempFile != null) Files.deleteIfExists(tempFile.toPath());
}
}
contentStream.setFont(font, fontSize);
Color redactColor;
try {
if (!colorString.startsWith("#")) {
colorString = "#" + colorString;
}
redactColor = Color.decode(colorString);
} catch (NumberFormatException e) {
redactColor = Color.LIGHT_GRAY;
}
contentStream.setNonStrokingColor(redactColor);
String[] textLines = watermarkText.split("\\\\n");
float maxLineWidth = 0;
for (int i = 0; i < textLines.length; ++i) {
maxLineWidth = Math.max(maxLineWidth, font.getStringWidth(textLines[i]));
}
// Set size and location of text watermark
float watermarkWidth = widthSpacer + maxLineWidth * fontSize / 1000;
float watermarkHeight = heightSpacer + fontSize * textLines.length;
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
// Calculating the new width and height depending on the angle.
float radians = (float) Math.toRadians(rotation);
float newWatermarkWidth =
(float)
(Math.abs(watermarkWidth * Math.cos(radians))
+ Math.abs(watermarkHeight * Math.sin(radians)));
float newWatermarkHeight =
(float)
(Math.abs(watermarkWidth * Math.sin(radians))
+ Math.abs(watermarkHeight * Math.cos(radians)));
// Calculating the number of rows and columns.
int watermarkRows = (int) (pageHeight / newWatermarkHeight + 1);
int watermarkCols = (int) (pageWidth / newWatermarkWidth + 1);
// Add the text watermark
for (int i = 0; i <= watermarkRows; i++) {
for (int j = 0; j <= watermarkCols; j++) {
contentStream.beginText();
contentStream.setTextMatrix(
Matrix.getRotateInstance(
(float) Math.toRadians(rotation),
j * newWatermarkWidth,
i * newWatermarkHeight));
for (int k = 0; k < textLines.length; ++k) {
contentStream.showText(textLines[k]);
contentStream.newLineAtOffset(0, -fontSize);
}
contentStream.endText();
}
}
}
private void addImageWatermark(
PDPageContentStream contentStream,
MultipartFile watermarkImage,
PDDocument document,
PDPage page,
float rotation,
int widthSpacer,
int heightSpacer,
float fontSize)
throws IOException {
// Load the watermark image
BufferedImage image = ImageIO.read(watermarkImage.getInputStream());
// Compute width based on original aspect ratio
float aspectRatio = (float) image.getWidth() / (float) image.getHeight();
// Desired physical height (in PDF points)
float desiredPhysicalHeight = fontSize;
// Desired physical width based on the aspect ratio
float desiredPhysicalWidth = desiredPhysicalHeight * aspectRatio;
// Convert the BufferedImage to PDImageXObject
PDImageXObject xobject = LosslessFactory.createFromImage(document, image);
// Calculate the number of rows and columns for watermarks
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
int watermarkRows =
(int) ((pageHeight + heightSpacer) / (desiredPhysicalHeight + heightSpacer));
int watermarkCols =
(int) ((pageWidth + widthSpacer) / (desiredPhysicalWidth + widthSpacer));
for (int i = 0; i < watermarkRows; i++) {
for (int j = 0; j < watermarkCols; j++) {
float x = j * (desiredPhysicalWidth + widthSpacer);
float y = i * (desiredPhysicalHeight + heightSpacer);
// Save the graphics state
contentStream.saveGraphicsState();
// Create rotation matrix and rotate
contentStream.transform(
Matrix.getTranslateInstance(
x + desiredPhysicalWidth / 2, y + desiredPhysicalHeight / 2));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
contentStream.transform(
Matrix.getTranslateInstance(
-desiredPhysicalWidth / 2, -desiredPhysicalHeight / 2));
// Draw the image and restore the graphics state
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
contentStream.restoreGraphicsState();
}
}
}
}
@@ -0,0 +1,130 @@
package stirling.software.SPDF.controller.web;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.ModelAndView;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import stirling.software.common.util.CheckProgramInstall;
@Controller
@Tag(name = "Convert", description = "Convert APIs")
public class ConverterWebController {
@GetMapping("/img-to-pdf")
@Hidden
public String convertImgToPdfForm(Model model) {
model.addAttribute("currentPage", "img-to-pdf");
return "convert/img-to-pdf";
}
@GetMapping("/html-to-pdf")
@Hidden
public String convertHTMLToPdfForm(Model model) {
model.addAttribute("currentPage", "html-to-pdf");
return "convert/html-to-pdf";
}
@GetMapping("/markdown-to-pdf")
@Hidden
public String convertMarkdownToPdfForm(Model model) {
model.addAttribute("currentPage", "markdown-to-pdf");
return "convert/markdown-to-pdf";
}
@GetMapping("/pdf-to-markdown")
@Hidden
public String convertPdfToMarkdownForm(Model model) {
model.addAttribute("currentPage", "pdf-to-markdown");
return "convert/pdf-to-markdown";
}
@GetMapping("/url-to-pdf")
@Hidden
public String convertURLToPdfForm(Model model) {
model.addAttribute("currentPage", "url-to-pdf");
return "convert/url-to-pdf";
}
@GetMapping("/file-to-pdf")
@Hidden
public String convertToPdfForm(Model model) {
model.addAttribute("currentPage", "file-to-pdf");
return "convert/file-to-pdf";
}
// PDF TO......
@GetMapping("/pdf-to-img")
@Hidden
public String pdfToimgForm(Model model) {
boolean isPython = CheckProgramInstall.isPythonAvailable();
model.addAttribute("isPython", isPython);
model.addAttribute("currentPage", "pdf-to-img");
return "convert/pdf-to-img";
}
@GetMapping("/pdf-to-html")
@Hidden
public ModelAndView pdfToHTML() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-html");
modelAndView.addObject("currentPage", "pdf-to-html");
return modelAndView;
}
@GetMapping("/pdf-to-presentation")
@Hidden
public ModelAndView pdfToPresentation() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-presentation");
modelAndView.addObject("currentPage", "pdf-to-presentation");
return modelAndView;
}
@GetMapping("/pdf-to-text")
@Hidden
public ModelAndView pdfToText() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-text");
modelAndView.addObject("currentPage", "pdf-to-text");
return modelAndView;
}
@GetMapping("/pdf-to-word")
@Hidden
public ModelAndView pdfToWord() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-word");
modelAndView.addObject("currentPage", "pdf-to-word");
return modelAndView;
}
@GetMapping("/pdf-to-xml")
@Hidden
public ModelAndView pdfToXML() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-xml");
modelAndView.addObject("currentPage", "pdf-to-xml");
return modelAndView;
}
@GetMapping("/pdf-to-csv")
@Hidden
public ModelAndView pdfToCSV() {
ModelAndView modelAndView = new ModelAndView("convert/pdf-to-csv");
modelAndView.addObject("currentPage", "pdf-to-csv");
return modelAndView;
}
@GetMapping("/pdf-to-pdfa")
@Hidden
public String pdfToPdfAForm(Model model) {
model.addAttribute("currentPage", "pdf-to-pdfa");
return "convert/pdf-to-pdfa";
}
@GetMapping("/eml-to-pdf")
@Hidden
public String convertEmlToPdfForm(Model model) {
model.addAttribute("currentPage", "eml-to-pdf");
return "convert/eml-to-pdf";
}
}
@@ -0,0 +1,346 @@
package stirling.software.SPDF.controller.web;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.GeneralUtils;
@Controller
@Tag(name = "General", description = "General APIs")
@Slf4j
public class GeneralWebController {
private final SignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public GeneralWebController(
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
this.signatureService = signatureService;
this.userService = userService;
this.resourceLoader = resourceLoader;
this.runtimePathConfig = runtimePathConfig;
}
@GetMapping("/pipeline")
@Hidden
public String pipelineForm(Model model) {
model.addAttribute("currentPage", "pipeline");
List<String> pipelineConfigs = new ArrayList<>();
List<Map<String, String>> pipelineConfigsWithNames = new ArrayList<>();
if (new File(runtimePathConfig.getPipelineDefaultWebUiConfigs()).exists()) {
try (Stream<Path> paths =
Files.walk(Paths.get(runtimePathConfig.getPipelineDefaultWebUiConfigs()))) {
List<Path> jsonFiles =
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".json"))
.toList();
for (Path jsonFile : jsonFiles) {
String content = Files.readString(jsonFile, StandardCharsets.UTF_8);
pipelineConfigs.add(content);
}
for (String config : pipelineConfigs) {
Map<String, Object> jsonContent =
new ObjectMapper()
.readValue(config, new TypeReference<Map<String, Object>>() {});
String name = (String) jsonContent.get("name");
if (name == null || name.length() < 1) {
String filename =
jsonFiles
.get(pipelineConfigs.indexOf(config))
.getFileName()
.toString();
name = filename.substring(0, filename.lastIndexOf('.'));
}
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", config);
configWithName.put("name", name);
pipelineConfigsWithNames.add(configWithName);
}
} catch (IOException e) {
log.error("exception", e);
}
}
if (pipelineConfigsWithNames.size() == 0) {
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", "");
configWithName.put("name", "No preloaded configs found");
pipelineConfigsWithNames.add(configWithName);
}
model.addAttribute("pipelineConfigsWithNames", pipelineConfigsWithNames);
model.addAttribute("pipelineConfigs", pipelineConfigs);
return "pipeline";
}
@GetMapping("/merge-pdfs")
@Hidden
public String mergePdfForm(Model model) {
model.addAttribute("currentPage", "merge-pdfs");
return "merge-pdfs";
}
@GetMapping("/split-pdf-by-sections")
@Hidden
public String splitPdfBySections(Model model) {
model.addAttribute("currentPage", "split-pdf-by-sections");
return "split-pdf-by-sections";
}
@GetMapping("/split-pdf-by-chapters")
@Hidden
public String splitPdfByChapters(Model model) {
model.addAttribute("currentPage", "split-pdf-by-chapters");
return "split-pdf-by-chapters";
}
@GetMapping("/view-pdf")
@Hidden
public String ViewPdfForm2(Model model) {
model.addAttribute("currentPage", "view-pdf");
return "view-pdf";
}
@GetMapping("/multi-tool")
@Hidden
public String multiToolForm(Model model) {
model.addAttribute("currentPage", "multi-tool");
return "multi-tool";
}
@GetMapping("/remove-pages")
@Hidden
public String pageDeleter(Model model) {
model.addAttribute("currentPage", "remove-pages");
return "remove-pages";
}
@GetMapping("/pdf-organizer")
@Hidden
public String pageOrganizer(Model model) {
model.addAttribute("currentPage", "pdf-organizer");
return "pdf-organizer";
}
@GetMapping("/extract-page")
@Hidden
public String extractPages(Model model) {
model.addAttribute("currentPage", "extract-page");
return "extract-page";
}
@GetMapping("/pdf-to-single-page")
@Hidden
public String pdfToSinglePage(Model model) {
model.addAttribute("currentPage", "pdf-to-single-page");
return "pdf-to-single-page";
}
@GetMapping("/rotate-pdf")
@Hidden
public String rotatePdfForm(Model model) {
model.addAttribute("currentPage", "rotate-pdf");
return "rotate-pdf";
}
@GetMapping("/split-pdfs")
@Hidden
public String splitPdfForm(Model model) {
model.addAttribute("currentPage", "split-pdfs");
return "split-pdfs";
}
@GetMapping("/sign")
@Hidden
public String signForm(Model model) {
String username = "";
if (userService != null) {
username = userService.getCurrentUsername();
}
// Get signatures from both personal and ALL_USERS folders
List<SignatureFile> signatures = signatureService.getAvailableSignatures(username);
model.addAttribute("currentPage", "sign");
model.addAttribute("fonts", getFontNames());
model.addAttribute("signatures", signatures);
return "sign";
}
@GetMapping("/multi-page-layout")
@Hidden
public String multiPageLayoutForm(Model model) {
model.addAttribute("currentPage", "multi-page-layout");
return "multi-page-layout";
}
@GetMapping("/scale-pages")
@Hidden
public String scalePagesFrom(Model model) {
model.addAttribute("currentPage", "scale-pages");
return "scale-pages";
}
@GetMapping("/split-by-size-or-count")
@Hidden
public String splitBySizeOrCount(Model model) {
model.addAttribute("currentPage", "split-by-size-or-count");
return "split-by-size-or-count";
}
@GetMapping("/overlay-pdf")
@Hidden
public String overlayPdf(Model model) {
model.addAttribute("currentPage", "overlay-pdf");
return "overlay-pdf";
}
private List<FontResource> getFontNames() {
List<FontResource> fontNames = new ArrayList<>();
// Extract font names from classpath
fontNames.addAll(getFontNamesFromLocation("classpath:static/fonts/*.woff2"));
// Extract font names from external directory
fontNames.addAll(
getFontNamesFromLocation(
"file:"
+ InstallationPathConfig.getStaticPath()
+ "fonts"
+ File.separator
+ "*"));
return fontNames;
}
private List<FontResource> getFontNamesFromLocation(String locationPattern) {
try {
Resource[] resources =
GeneralUtils.getResourcesFromLocationPattern(locationPattern, resourceLoader);
return Arrays.stream(resources)
.map(
resource -> {
try {
String filename = resource.getFilename();
if (filename != null) {
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex != -1) {
String name = filename.substring(0, lastDotIndex);
String extension = filename.substring(lastDotIndex + 1);
return new FontResource(name, extension);
}
}
return null;
} catch (Exception e) {
throw new RuntimeException("Error processing filename", e);
}
})
.filter(Objects::nonNull)
.toList();
} catch (Exception e) {
throw new RuntimeException("Failed to read font directory from " + locationPattern, e);
}
}
public String getFormatFromExtension(String extension) {
switch (extension) {
case "ttf":
return "truetype";
case "woff":
return "woff";
case "woff2":
return "woff2";
case "eot":
return "embedded-opentype";
case "svg":
return "svg";
default:
// or throw an exception if an unexpected extension is encountered
return "";
}
}
@GetMapping("/crop")
@Hidden
public String cropForm(Model model) {
model.addAttribute("currentPage", "crop");
return "crop";
}
@GetMapping("/auto-split-pdf")
@Hidden
public String autoSPlitPDFForm(Model model) {
model.addAttribute("currentPage", "auto-split-pdf");
return "auto-split-pdf";
}
@GetMapping("/remove-image-pdf")
@Hidden
public String removeImagePdfForm(Model model) {
model.addAttribute("currentPage", "remove-image-pdf");
return "remove-image-pdf";
}
public class FontResource {
private String name;
private String extension;
private String type;
public FontResource(String name, String extension) {
this.name = name;
this.extension = extension;
this.type = getFormatFromExtension(extension);
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getExtension() {
return extension;
}
public void setExtension(String extension) {
this.extension = extension;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
}
}
@@ -0,0 +1,96 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.SPDF.model.Dependency;
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeWebController {
private final ApplicationProperties applicationProperties;
@GetMapping("/about")
@Hidden
public String gameForm(Model model) {
model.addAttribute("currentPage", "about");
return "about";
}
@GetMapping("/licenses")
@Hidden
public String licensesForm(Model model) {
model.addAttribute("currentPage", "licenses");
Resource resource = new ClassPathResource("static/3rdPartyLicenses.json");
try {
InputStream is = resource.getInputStream();
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
Map<String, List<Dependency>> data =
mapper.readValue(json, new TypeReference<>() {
});
model.addAttribute("dependencies", data.get("dependencies"));
} catch (IOException e) {
log.error("exception", e);
}
return "licenses";
}
@GetMapping("/releases")
public String getReleaseNotes(Model model) {
return "releases";
}
@GetMapping("/")
public String home(Model model) {
model.addAttribute("currentPage", "home");
String showSurvey = System.getenv("SHOW_SURVEY");
boolean showSurveyValue = showSurvey == null || "true".equalsIgnoreCase(showSurvey);
model.addAttribute("showSurveyFromDocker", showSurveyValue);
return "home";
}
@GetMapping("/home")
public String root(Model model) {
return "redirect:/";
}
@GetMapping("/home-legacy")
public String redirectHomeLegacy() {
return "redirect:/";
}
@GetMapping(value = "/robots.txt", produces = MediaType.TEXT_PLAIN_VALUE)
@ResponseBody
@Hidden
public String getRobotsTxt() {
Boolean allowGoogle = applicationProperties.getSystem().getGooglevisibility();
if (Boolean.TRUE.equals(allowGoogle)) {
return "User-agent: Googlebot\nAllow: /\n\nUser-agent: *\nAllow: /";
} else {
return "User-agent: Googlebot\nDisallow: /\n\nUser-agent: *\nDisallow: /";
}
}
}
@@ -0,0 +1,392 @@
package stirling.software.SPDF.controller.web;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointInspector;
import stirling.software.SPDF.config.StartupApplicationListener;
import stirling.software.common.model.ApplicationProperties;
@RestController
@RequestMapping("/api/v1/info")
@Tag(name = "Info", description = "Info APIs")
@Slf4j
@RequiredArgsConstructor
public class MetricsController {
private final ApplicationProperties applicationProperties;
private final MeterRegistry meterRegistry;
private final EndpointInspector endpointInspector;
private boolean metricsEnabled;
@PostConstruct
public void init() {
Boolean metricsEnabled = applicationProperties.getMetrics().getEnabled();
if (metricsEnabled == null) metricsEnabled = true;
this.metricsEnabled = metricsEnabled;
}
@GetMapping("/status")
@Operation(
summary = "Application status and version",
description =
"This endpoint returns the status of the application and its version number.")
public ResponseEntity<?> getStatus() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
Map<String, String> status = new HashMap<>();
status.put("status", "UP");
status.put("version", getClass().getPackage().getImplementationVersion());
return ResponseEntity.ok(status);
}
@GetMapping("/load")
@Operation(
summary = "GET request count",
description =
"This endpoint returns the total count of GET requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getPageLoads(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
try {
double count = getRequestCount("GET", endpoint);
return ResponseEntity.ok(count);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/load/unique")
@Operation(
summary = "Unique users count for GET requests",
description =
"This endpoint returns the count of unique users for GET requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getUniquePageLoads(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
try {
double count = getUniqueUserCount("GET", endpoint);
return ResponseEntity.ok(count);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/load/all")
@Operation(
summary = "GET requests count for all endpoints",
description = "This endpoint returns the count of GET requests for each endpoint.")
public ResponseEntity<?> getAllEndpointLoads() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
try {
List<EndpointCount> results = getEndpointCounts("GET");
return ResponseEntity.ok(results);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/load/all/unique")
@Operation(
summary = "Unique users count for GET requests for all endpoints",
description =
"This endpoint returns the count of unique users for GET requests for each endpoint.")
public ResponseEntity<?> getAllUniqueEndpointLoads() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
try {
List<EndpointCount> results = getUniqueUserCounts("GET");
return ResponseEntity.ok(results);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/requests")
@Operation(
summary = "POST request count",
description =
"This endpoint returns the total count of POST requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getTotalRequests(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
try {
double count = getRequestCount("POST", endpoint);
return ResponseEntity.ok(count);
} catch (Exception e) {
return ResponseEntity.ok(-1);
}
}
@GetMapping("/requests/unique")
@Operation(
summary = "Unique users count for POST requests",
description =
"This endpoint returns the count of unique users for POST requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getUniqueTotalRequests(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
try {
double count = getUniqueUserCount("POST", endpoint);
return ResponseEntity.ok(count);
} catch (Exception e) {
return ResponseEntity.ok(-1);
}
}
@GetMapping("/requests/all")
@Operation(
summary = "POST requests count for all endpoints",
description = "This endpoint returns the count of POST requests for each endpoint.")
public ResponseEntity<?> getAllPostRequests() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
try {
List<EndpointCount> results = getEndpointCounts("POST");
return ResponseEntity.ok(results);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/requests/all/unique")
@Operation(
summary = "Unique users count for POST requests for all endpoints",
description =
"This endpoint returns the count of unique users for POST requests for each endpoint.")
public ResponseEntity<?> getAllUniquePostRequests() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
try {
List<EndpointCount> results = getUniqueUserCounts("POST");
return ResponseEntity.ok(results);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
private double getRequestCount(String method, Optional<String> endpoint) {
return meterRegistry.find("http.requests").tag("method", method).counters().stream()
.filter(
counter -> {
String uri = counter.getId().getTag("uri");
// Apply filtering logic - Skip if uri is null
if (uri == null) {
return false;
}
// For POST requests, only include if they start with /api/v1
if ("POST".equals(method) && !uri.contains("api/v1")) {
return false;
}
if (uri.contains(".txt")) {
return false;
}
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
log.debug("Skipping invalid GET endpoint: {}", uri);
return false;
}
// Filter for specific endpoint if provided
return !endpoint.isPresent() || endpoint.get().equals(uri);
})
.mapToDouble(Counter::count)
.sum();
}
private List<EndpointCount> getEndpointCounts(String method) {
Map<String, Double> counts = new HashMap<>();
meterRegistry
.find("http.requests")
.tag("method", method)
.counters()
.forEach(
counter -> {
String uri = counter.getId().getTag("uri");
// Skip if uri is null
if (uri == null) {
return;
}
// For POST requests, only include if they start with /api/v1
if ("POST".equals(method) && !uri.contains("api/v1")) {
return;
}
if (uri.contains(".txt")) {
return;
}
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
log.debug("Skipping invalid GET endpoint: {}", uri);
return;
}
counts.merge(uri, counter.count(), Double::sum);
});
return counts.entrySet().stream()
.map(entry -> new EndpointCount(entry.getKey(), entry.getValue()))
.sorted(Comparator.comparing(EndpointCount::getCount).reversed())
.toList();
}
private double getUniqueUserCount(String method, Optional<String> endpoint) {
Set<String> uniqueUsers = new HashSet<>();
meterRegistry.find("http.requests").tag("method", method).counters().stream()
.filter(
counter -> {
String uri = counter.getId().getTag("uri");
// Skip if uri is null
if (uri == null) {
return false;
}
// For POST requests, only include if they start with /api/v1
if ("POST".equals(method) && !uri.contains("api/v1")) {
return false;
}
if (uri.contains(".txt")) {
return false;
}
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
log.debug("Skipping invalid GET endpoint: {}", uri);
return false;
}
return !endpoint.isPresent() || endpoint.get().equals(uri);
})
.forEach(
counter -> {
String session = counter.getId().getTag("session");
if (session != null) {
uniqueUsers.add(session);
}
});
return uniqueUsers.size();
}
private List<EndpointCount> getUniqueUserCounts(String method) {
Map<String, Set<String>> uniqueUsers = new HashMap<>();
meterRegistry
.find("http.requests")
.tag("method", method)
.counters()
.forEach(
counter -> {
String uri = counter.getId().getTag("uri");
String session = counter.getId().getTag("session");
if (uri != null && session != null) {
uniqueUsers.computeIfAbsent(uri, k -> new HashSet<>()).add(session);
}
});
return uniqueUsers.entrySet().stream()
.map(entry -> new EndpointCount(entry.getKey(), entry.getValue().size()))
.sorted(Comparator.comparing(EndpointCount::getCount).reversed())
.toList();
}
@GetMapping("/uptime")
public ResponseEntity<?> getUptime() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
LocalDateTime now = LocalDateTime.now();
Duration uptime = Duration.between(StartupApplicationListener.startTime, now);
return ResponseEntity.ok(formatDuration(uptime));
}
private String formatDuration(Duration duration) {
long days = duration.toDays();
long hours = duration.toHoursPart();
long minutes = duration.toMinutesPart();
long seconds = duration.toSecondsPart();
return String.format("%dd %dh %dm %ds", days, hours, minutes, seconds);
}
public static class EndpointCount {
private String endpoint;
private double count;
public EndpointCount(String endpoint, double count) {
this.endpoint = endpoint;
this.count = count;
}
public String getEndpoint() {
return endpoint;
}
public void setEndpoint(String endpoint) {
this.endpoint = endpoint;
}
public double getCount() {
return count;
}
public void setCount(double count) {
this.count = count;
}
}
}
@@ -0,0 +1,194 @@
package stirling.software.SPDF.controller.web;
import java.io.File;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.ModelAndView;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.CheckProgramInstall;
@Controller
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class OtherWebController {
private final ApplicationProperties applicationProperties;
@GetMapping("/compress-pdf")
@Hidden
public String compressPdfForm(Model model) {
model.addAttribute("currentPage", "compress-pdf");
return "misc/compress-pdf";
}
@GetMapping("/replace-and-invert-color-pdf")
@Hidden
public String replaceAndInvertColorPdfForm(Model model) {
model.addAttribute("currentPage", "replace-invert-color-pdf");
return "misc/replace-color";
}
@GetMapping("/extract-image-scans")
@Hidden
public ModelAndView extractImageScansForm() {
ModelAndView modelAndView = new ModelAndView("misc/extract-image-scans");
boolean isPython = CheckProgramInstall.isPythonAvailable();
modelAndView.addObject("isPython", isPython);
modelAndView.addObject("currentPage", "extract-image-scans");
return modelAndView;
}
@GetMapping("/show-javascript")
@Hidden
public String extractJavascriptForm(Model model) {
model.addAttribute("currentPage", "show-javascript");
return "misc/show-javascript";
}
@GetMapping("/stamp")
@Hidden
public String stampForm(Model model) {
model.addAttribute("currentPage", "stamp");
return "misc/stamp";
}
@GetMapping("/add-page-numbers")
@Hidden
public String addPageNumbersForm(Model model) {
model.addAttribute("currentPage", "add-page-numbers");
return "misc/add-page-numbers";
}
@GetMapping("/fake-scan")
@Hidden
public String fakeScanForm(Model model) {
model.addAttribute("currentPage", "fake-scan");
return "misc/fake-scan";
}
@GetMapping("/extract-images")
@Hidden
public String extractImagesForm(Model model) {
model.addAttribute("currentPage", "extract-images");
return "misc/extract-images";
}
@GetMapping("/flatten")
@Hidden
public String flattenForm(Model model) {
model.addAttribute("currentPage", "flatten");
return "misc/flatten";
}
@GetMapping("/change-metadata")
@Hidden
public String addWatermarkForm(Model model) {
model.addAttribute("currentPage", "change-metadata");
return "misc/change-metadata";
}
@GetMapping("/unlock-pdf-forms")
@Hidden
public String unlockPDFForms(Model model) {
model.addAttribute("currentPage", "unlock-pdf-forms");
return "misc/unlock-pdf-forms";
}
@GetMapping("/compare")
@Hidden
public String compareForm(Model model) {
model.addAttribute("currentPage", "compare");
return "misc/compare";
}
@GetMapping("/print-file")
@Hidden
public String printFileForm(Model model) {
model.addAttribute("currentPage", "print-file");
return "misc/print-file";
}
public List<String> getAvailableTesseractLanguages() {
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
File[] files = new File(tessdataDir).listFiles();
if (files == null) {
return Collections.emptyList();
}
return Arrays.stream(files)
.filter(file -> file.getName().endsWith(".traineddata"))
.map(file -> file.getName().replace(".traineddata", ""))
.filter(lang -> !"osd".equalsIgnoreCase(lang))
.sorted()
.toList();
}
@GetMapping("/ocr-pdf")
@Hidden
public ModelAndView ocrPdfPage() {
ModelAndView modelAndView = new ModelAndView("misc/ocr-pdf");
List<String> languages = getAvailableTesseractLanguages();
modelAndView.addObject("languages", languages);
modelAndView.addObject("currentPage", "ocr-pdf");
return modelAndView;
}
@GetMapping("/add-image")
@Hidden
public String overlayImage(Model model) {
model.addAttribute("currentPage", "add-image");
return "misc/add-image";
}
@GetMapping("/adjust-contrast")
@Hidden
public String contrast(Model model) {
model.addAttribute("currentPage", "adjust-contrast");
return "misc/adjust-contrast";
}
@GetMapping("/repair")
@Hidden
public String repairForm(Model model) {
model.addAttribute("currentPage", "repair");
return "misc/repair";
}
@GetMapping("/remove-blanks")
@Hidden
public String removeBlanksForm(Model model) {
model.addAttribute("currentPage", "remove-blanks");
return "misc/remove-blanks";
}
@GetMapping("/remove-annotations")
@Hidden
public String removeAnnotationsForm(Model model) {
model.addAttribute("currentPage", "remove-annotations");
return "misc/remove-annotations";
}
@GetMapping("/auto-crop")
@Hidden
public String autoCropForm(Model model) {
model.addAttribute("currentPage", "auto-crop");
return "misc/auto-crop";
}
@GetMapping("/auto-rename")
@Hidden
public String autoRenameForm(Model model) {
model.addAttribute("currentPage", "auto-rename");
return "misc/auto-rename";
}
}
@@ -0,0 +1,89 @@
package stirling.software.SPDF.controller.web;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
@Controller
@Tag(name = "Security", description = "Security APIs")
public class SecurityWebController {
@GetMapping("/auto-redact")
@Hidden
public String autoRedactForm(Model model) {
model.addAttribute("currentPage", "auto-redact");
return "security/auto-redact";
}
@GetMapping("/redact")
public String redactForm(Model model) {
model.addAttribute("currentPage", "redact");
return "security/redact";
}
@GetMapping("/add-password")
@Hidden
public String addPasswordForm(Model model) {
model.addAttribute("currentPage", "add-password");
return "security/add-password";
}
@GetMapping("/change-permissions")
@Hidden
public String permissionsForm(Model model) {
model.addAttribute("currentPage", "change-permissions");
return "security/change-permissions";
}
@GetMapping("/remove-password")
@Hidden
public String removePasswordForm(Model model) {
model.addAttribute("currentPage", "remove-password");
return "security/remove-password";
}
@GetMapping("/add-watermark")
@Hidden
public String addWatermarkForm(Model model) {
model.addAttribute("currentPage", "add-watermark");
return "security/add-watermark";
}
@GetMapping("/cert-sign")
@Hidden
public String certSignForm(Model model) {
model.addAttribute("currentPage", "cert-sign");
return "security/cert-sign";
}
@GetMapping("/validate-signature")
@Hidden
public String certSignVerifyForm(Model model) {
model.addAttribute("currentPage", "validate-signature");
return "security/validate-signature";
}
@GetMapping("/remove-cert-sign")
@Hidden
public String certUnSignForm(Model model) {
model.addAttribute("currentPage", "remove-cert-sign");
return "security/remove-cert-sign";
}
@GetMapping("/sanitize-pdf")
@Hidden
public String sanitizeForm(Model model) {
model.addAttribute("currentPage", "sanitize-pdf");
return "security/sanitize-pdf";
}
@GetMapping("/get-info-on-pdf")
@Hidden
public String getInfo(Model model) {
model.addAttribute("currentPage", "get-info-on-pdf");
return "security/get-info-on-pdf";
}
}
@@ -0,0 +1,49 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.service.UserServiceInterface;
@Controller
@RequestMapping("/api/v1/general")
public class SignatureController {
private final SignatureService signatureService;
private final UserServiceInterface userService;
public SignatureController(
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.signatureService = signatureService;
this.userService = userService;
}
@GetMapping("/sign/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName)
throws IOException {
String username = "NON_SECURITY_USER";
if (userService != null) {
username = userService.getCurrentUsername();
}
// Verify access permission
if (!signatureService.hasAccessToFile(username, fileName)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
byte[] imageBytes = signatureService.getSignatureBytes(username, fileName);
return ResponseEntity.ok()
.contentType( // Adjust based on file type
MediaType.IMAGE_JPEG)
.body(imageBytes);
}
}
@@ -0,0 +1,58 @@
package stirling.software.SPDF.controller.web;
import java.util.Locale;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Service
@Slf4j
public class UploadLimitService {
@Autowired private ApplicationProperties applicationProperties;
public long getUploadLimit() {
String maxUploadSize =
applicationProperties.getSystem().getFileUploadLimit() != null
? applicationProperties.getSystem().getFileUploadLimit()
: "";
if (maxUploadSize.isEmpty()) {
return 0;
} else if (!Pattern.compile("^[1-9][0-9]{0,2}[KMGkmg][Bb]$")
.matcher(maxUploadSize)
.matches()) {
log.error(
"Invalid maxUploadSize format. Expected format: [1-9][0-9]{0,2}[KMGkmg][Bb], but got: {}",
maxUploadSize);
return 0;
} else {
String unit = maxUploadSize.replaceAll("[1-9][0-9]{0,2}", "").toUpperCase();
String number = maxUploadSize.replaceAll("[KMGkmg][Bb]", "");
long size = Long.parseLong(number);
return switch (unit) {
case "KB" -> size * 1024;
case "MB" -> size * 1024 * 1024;
case "GB" -> size * 1024 * 1024 * 1024;
default -> 0;
};
}
}
// TODO: why do this server side not client?
public String getReadableUploadLimit() {
return humanReadableByteCount(getUploadLimit());
}
private String humanReadableByteCount(long bytes) {
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);
}
}
@@ -0,0 +1,42 @@
package stirling.software.SPDF.model;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
public class ApiEndpoint {
private final String name;
private Map<String, JsonNode> parameters;
private final String description;
public ApiEndpoint(String name, JsonNode postNode) {
this.name = name;
this.parameters = new HashMap<>();
postNode.path("parameters")
.forEach(
paramNode -> {
String paramName = paramNode.path("name").asText();
parameters.put(paramName, paramNode);
});
this.description = postNode.path("description").asText();
}
public boolean areParametersValid(Map<String, Object> providedParams) {
for (String requiredParam : parameters.keySet()) {
if (!providedParams.containsKey(requiredParam)) {
return false;
}
}
return true;
}
public String getDescription() {
return description;
}
@Override
public String toString() {
return "ApiEndpoint [name=" + name + ", parameters=" + parameters + "]";
}
}
@@ -0,0 +1,12 @@
package stirling.software.SPDF.model;
import lombok.Data;
@Data
public class Dependency {
private String moduleName;
private String moduleUrl;
private String moduleVersion;
private String moduleLicense;
private String moduleLicenseUrl;
}
@@ -0,0 +1,13 @@
package stirling.software.SPDF.model;
import lombok.Data;
@Data
public class PDFText {
private final int pageIndex;
private final float x1;
private final float y1;
private final float x2;
private final float y2;
private final String text;
}
@@ -0,0 +1,20 @@
package stirling.software.SPDF.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class PipelineConfig {
private String name;
@JsonProperty("pipeline")
private List<PipelineOperation> operations;
private String outputDir;
@JsonProperty("outputFileName")
private String outputPattern;
}
@@ -0,0 +1,11 @@
package stirling.software.SPDF.model;
import java.util.Map;
import lombok.Data;
@Data
public class PipelineOperation {
private String operation;
private Map<String, Object> parameters;
}
@@ -0,0 +1,14 @@
package stirling.software.SPDF.model;
import java.util.List;
import org.springframework.core.io.Resource;
import lombok.Data;
@Data
public class PipelineResult {
private List<Resource> outputFiles;
private boolean hasErrors;
private boolean filtersApplied;
}
@@ -0,0 +1,11 @@
package stirling.software.SPDF.model;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class SignatureFile {
private String fileName;
private String category; // "Personal" or "Shared"
}
@@ -0,0 +1,15 @@
package stirling.software.SPDF.model;
public enum SortTypes {
CUSTOM,
REVERSE_ORDER,
DUPLEX_SORT,
BOOKLET_SORT,
SIDE_STITCH_BOOKLET_SORT,
ODD_EVEN_SPLIT,
ODD_EVEN_MERGE,
REMOVE_FIRST,
REMOVE_LAST,
REMOVE_FIRST_AND_LAST,
DUPLICATE
}
@@ -0,0 +1,22 @@
package stirling.software.SPDF.model.api;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class HandleDataRequest {
@Schema(description = "The input files", requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile[] fileInput;
@Schema(
description = "JSON String",
defaultValue = "{}",
requiredMode = Schema.RequiredMode.REQUIRED)
private String json;
}
@@ -0,0 +1,18 @@
package stirling.software.SPDF.model.api;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class ImageFile {
@Schema(
description = "The input image file",
requiredMode = Schema.RequiredMode.REQUIRED,
format = "binary")
private MultipartFile fileInput;
}
@@ -0,0 +1,15 @@
package stirling.software.SPDF.model.api;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class MultiplePDFFiles {
@Schema(description = "The input PDF files", requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile[] fileInput;
}
@@ -0,0 +1,19 @@
package stirling.software.SPDF.model.api;
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 PDFComparison extends PDFFile {
@Schema(
description = "The comparison type, accepts Greater, Equal, Less than",
allowableValues = {"Greater", "Equal", "Less"},
requiredMode = Schema.RequiredMode.REQUIRED)
private String comparator;
}

Some files were not shown because too many files have changed in this diff Show More