added unit tests

This commit is contained in:
Dario Ghunney Ware 2025-06-11 15:18:43 +01:00
parent 1490b29ec0
commit f2468d26c9
44 changed files with 1378 additions and 302 deletions

View File

@ -473,6 +473,7 @@ spotless {
target sourceSets.main.allJava target sourceSets.main.allJava
target project(':common').sourceSets.main.allJava target project(':common').sourceSets.main.allJava
target project(':proprietary').sourceSets.main.allJava target project(':proprietary').sourceSets.main.allJava
target project(':stirling-pdf').sourceSets.main.allJava
googleJavaFormat("1.27.0").aosp().reorderImports(false) googleJavaFormat("1.27.0").aosp().reorderImports(false)
@ -574,4 +575,4 @@ tasks.named('build') {
doFirst { doFirst {
println "Delegating to :stirling-pdf:bootJar" println "Delegating to :stirling-pdf:bootJar"
} }
} }

View File

@ -1,7 +1,5 @@
package stirling.software.common.configuration; package stirling.software.common.configuration;
import io.github.pixee.security.SystemCommand;
import jakarta.annotation.PostConstruct;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -10,29 +8,22 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Properties; import java.util.Properties;
import java.util.function.Predicate; import java.util.function.Predicate;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.context.annotation.Scope; import org.springframework.context.annotation.Scope;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader; import org.springframework.core.io.ResourceLoader;
import org.thymeleaf.spring6.SpringTemplateEngine; import org.thymeleaf.spring6.SpringTemplateEngine;
import org.springframework.core.Ordered;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
@Lazy @Lazy
@ -257,7 +248,7 @@ public class AppConfig {
return applicationProperties.getSystem().getDatasource(); return applicationProperties.getSystem().getDatasource();
} }
@Bean(name = "runningProOrHigher") @Bean(name = "runningProOrHigher")
@Profile("default") @Profile("default")
public boolean runningProOrHigher() { public boolean runningProOrHigher() {
@ -275,14 +266,14 @@ public class AppConfig {
public boolean googleDriveEnabled() { public boolean googleDriveEnabled() {
return false; return false;
} }
@Bean(name = "license") @Bean(name = "license")
@Profile("default") @Profile("default")
public String licenseType() { public String licenseType() {
return "NORMAL"; return "NORMAL";
} }
@Bean(name = "disablePixel") @Bean(name = "disablePixel")
public boolean disablePixel() { public boolean disablePixel() {
return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false")); return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));

View File

@ -0,0 +1,83 @@
package stirling.software.proprietary.security.service;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.repository.TeamRepository;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TeamServiceTest {
@Mock
private TeamRepository teamRepository;
@InjectMocks
private TeamService teamService;
@Test
void getDefaultTeam() {
var team = new Team();
team.setName("Marleyans");
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
.thenReturn(Optional.of(team));
Team result = teamService.getOrCreateDefaultTeam();
assertEquals(team, result);
}
@Test
void createDefaultTeam_whenRepositoryIsEmpty() {
String teamName = "Default";
var defaultTeam = new Team();
defaultTeam.setId(1L);
defaultTeam.setName(teamName);
when(teamRepository.findByName(teamName))
.thenReturn(Optional.empty());
when(teamRepository.save(any(Team.class))).thenReturn(defaultTeam);
Team result = teamService.getOrCreateDefaultTeam();
assertEquals(TeamService.DEFAULT_TEAM_NAME, result.getName());
}
@Test
void getInternalTeam() {
var team = new Team();
team.setName("Eldians");
when(teamRepository.findByName(TeamService.INTERNAL_TEAM_NAME))
.thenReturn(Optional.of(team));
Team result = teamService.getOrCreateInternalTeam();
assertEquals(team, result);
}
@Test
void createInternalTeam_whenRepositoryIsEmpty() {
String teamName = "Internal";
Team internalTeam = new Team();
internalTeam.setId(2L);
internalTeam.setName(teamName);
when(teamRepository.findByName(teamName))
.thenReturn(Optional.empty());
when(teamRepository.save(any(Team.class))).thenReturn(internalTeam);
when(teamRepository.findByName(TeamService.INTERNAL_TEAM_NAME))
.thenReturn(Optional.empty());
Team result = teamService.getOrCreateInternalTeam();
assertEquals(internalTeam, result);
}
}

View File

@ -0,0 +1,317 @@
package stirling.software.proprietary.security.service;
import java.sql.SQLException;
import java.util.Locale;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private TeamRepository teamRepository;
@Mock
private AuthorityRepository authorityRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private MessageSource messageSource;
@Mock
private SessionPersistentRegistry sessionPersistentRegistry;
@Mock
private DatabaseServiceInterface databaseService;
@Mock
private ApplicationProperties.Security.OAUTH2 oauth2Properties;
@InjectMocks
private UserService userService;
private Team mockTeam;
private User mockUser;
@BeforeEach
void setUp() {
mockTeam = new Team();
mockTeam.setId(1L);
mockTeam.setName("Test Team");
mockUser = new User();
mockUser.setId(1L);
mockUser.setUsername("testuser");
mockUser.setEnabled(true);
}
@Test
void testSaveUser_WithUsernameAndAuthenticationType_Success() throws Exception {
// Given
String username = "testuser";
AuthenticationType authType = AuthenticationType.WEB;
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, authType);
// Then
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithUsernamePasswordAndTeamId_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, password, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder).encode(password);
verify(teamRepository).findById(teamId);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithTeamAndRole_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
String role = Role.ADMIN.getRoleId();
boolean firstLogin = true;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, password, mockTeam, role, firstLogin);
// Then
assertNotNull(result);
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithInvalidUsername_ThrowsException() throws Exception {
// Given
String invalidUsername = "ab"; // Too short (less than 3 characters)
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(invalidUsername, authType)
);
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_WithNullPassword_Success() throws Exception {
// Given
String username = "testuser";
Long teamId = 1L;
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, null, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithEmptyPassword_Success() throws Exception {
// Given
String username = "testuser";
String emptyPassword = "";
Long teamId = 1L;
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, emptyPassword, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithValidEmail_Success() throws Exception {
// Given
String emailUsername = "test@example.com";
AuthenticationType authType = AuthenticationType.SSO;
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(emailUsername, authType);
// Then
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithReservedUsername_ThrowsException() throws Exception {
// Given
String reservedUsername = "all_users";
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(reservedUsername, authType)
);
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_WithAnonymousUser_ThrowsException() throws Exception {
// Given
String anonymousUsername = "anonymoususer";
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(anonymousUsername, authType)
);
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_DatabaseExportThrowsException_StillSavesUser() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doThrow(new SQLException("Database export failed")).when(databaseService).exportDatabase();
// When & Then
assertThrows(SQLException.class, () -> userService.saveUser(username, password, teamId));
// Verify user was still saved before the exception
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithFirstLoginFlag_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
boolean firstLogin = true;
boolean enabled = false;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, password, teamId, firstLogin, enabled);
// Then
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithCustomRole_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String customRole = Role.LIMITED_API_USER.getRoleId();
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, password, teamId, customRole);
// Then
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
}

View File

@ -13,8 +13,6 @@ import java.util.Properties;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; 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.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@ -210,7 +208,7 @@ public class SPDFApplication {
if (arg.startsWith("--spring.profiles.active=")) { if (arg.startsWith("--spring.profiles.active=")) {
String[] provided = arg.substring(arg.indexOf('=') + 1).split(","); String[] provided = arg.substring(arg.indexOf('=') + 1).split(",");
if (provided.length > 0) { if (provided.length > 0) {
log.info("#######0000000000000###############################"); log.info("#######0000000000000###############################");
return provided; return provided;
} }
} }
@ -218,14 +216,16 @@ public class SPDFApplication {
} }
log.info("######################################"); log.info("######################################");
// 2. Detect if SecurityConfiguration is present on classpath // 2. Detect if SecurityConfiguration is present on classpath
if (isClassPresent("stirling.software.proprietary.security.configuration.SecurityConfiguration")) { if (isClassPresent(
log.info("security"); "stirling.software.proprietary.security.configuration.SecurityConfiguration")) {
return new String[] { "security" }; log.info("security");
return new String[] {"security"};
} else { } else {
log.info("default"); log.info("default");
return new String[] { "default" }; return new String[] {"default"};
} }
} }
private static boolean isClassPresent(String className) { private static boolean isClassPresent(String className) {
try { try {
Class.forName(className, false, SPDFApplication.class.getClassLoader()); Class.forName(className, false, SPDFApplication.class.getClassLoader());

View File

@ -80,4 +80,4 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
HttpServletResponse response, HttpServletResponse response,
Object handler, Object handler,
Exception ex) {} Exception ex) {}
} }

View File

@ -209,4 +209,4 @@ public class EndpointInspector implements ApplicationListener<ContextRefreshedEv
} }
logger.info("=== END: All discovered GET endpoints ==="); logger.info("=== END: All discovered GET endpoints ===");
} }
} }

View File

@ -49,4 +49,4 @@ public class EndpointInterceptor implements HandlerInterceptor {
} }
return true; return true;
} }
} }

View File

@ -155,4 +155,4 @@ public class ExternalAppDepConfig {
} }
endpointConfiguration.logDisabledEndpointsSummary(); endpointConfiguration.logDisabledEndpointsSummary();
} }
} }

View File

@ -25,9 +25,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
// Handler for external static resources // Handler for external static resources
registry.addResourceHandler("/**") registry.addResourceHandler("/**")
.addResourceLocations( .addResourceLocations(
"file:" + InstallationPathConfig.getStaticPath(), "file:" + InstallationPathConfig.getStaticPath(), "classpath:/static/");
"classpath:/static/"
);
registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/"); registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/");
registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/"); registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/");
// .setCachePeriod(0); // Optional: disable caching // .setCachePeriod(0); // Optional: disable caching

View File

@ -49,18 +49,18 @@ public class EditTableOfContentsController {
summary = "Extract PDF Bookmarks", summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.") description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@ResponseBody @ResponseBody
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file) public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
throws Exception { throws Exception {
PDDocument document = null; PDDocument document = null;
try { try {
document = pdfDocumentFactory.load(file); document = pdfDocumentFactory.load(file);
PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline(); PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
if (outline == null) { if (outline == null) {
log.info("No outline/bookmarks found in PDF"); log.info("No outline/bookmarks found in PDF");
return new ArrayList<>(); return new ArrayList<>();
} }
return extractBookmarkItems(document, outline); return extractBookmarkItems(document, outline);
} finally { } finally {
if (document != null) { if (document != null) {
@ -68,18 +68,19 @@ public class EditTableOfContentsController {
} }
} }
} }
private List<Map<String, Object>> extractBookmarkItems(PDDocument document, PDDocumentOutline outline) throws Exception { private List<Map<String, Object>> extractBookmarkItems(
PDDocument document, PDDocumentOutline outline) throws Exception {
List<Map<String, Object>> bookmarks = new ArrayList<>(); List<Map<String, Object>> bookmarks = new ArrayList<>();
PDOutlineItem current = outline.getFirstChild(); PDOutlineItem current = outline.getFirstChild();
while (current != null) { while (current != null) {
Map<String, Object> bookmark = new HashMap<>(); Map<String, Object> bookmark = new HashMap<>();
// Get bookmark title // Get bookmark title
String title = current.getTitle(); String title = current.getTitle();
bookmark.put("title", title); bookmark.put("title", title);
// Get page number (1-based for UI purposes) // Get page number (1-based for UI purposes)
PDPage page = current.findDestinationPage(document); PDPage page = current.findDestinationPage(document);
if (page != null) { if (page != null) {
@ -88,39 +89,40 @@ public class EditTableOfContentsController {
} else { } else {
bookmark.put("pageNumber", 1); bookmark.put("pageNumber", 1);
} }
// Process children if any // Process children if any
PDOutlineItem child = current.getFirstChild(); PDOutlineItem child = current.getFirstChild();
if (child != null) { if (child != null) {
List<Map<String, Object>> children = new ArrayList<>(); List<Map<String, Object>> children = new ArrayList<>();
PDOutlineNode parent = current; PDOutlineNode parent = current;
while (child != null) { while (child != null) {
// Recursively process child items // Recursively process child items
Map<String, Object> childBookmark = processChild(document, child); Map<String, Object> childBookmark = processChild(document, child);
children.add(childBookmark); children.add(childBookmark);
child = child.getNextSibling(); child = child.getNextSibling();
} }
bookmark.put("children", children); bookmark.put("children", children);
} else { } else {
bookmark.put("children", new ArrayList<>()); bookmark.put("children", new ArrayList<>());
} }
bookmarks.add(bookmark); bookmarks.add(bookmark);
current = current.getNextSibling(); current = current.getNextSibling();
} }
return bookmarks; return bookmarks;
} }
private Map<String, Object> processChild(PDDocument document, PDOutlineItem item) throws Exception { private Map<String, Object> processChild(PDDocument document, PDOutlineItem item)
throws Exception {
Map<String, Object> bookmark = new HashMap<>(); Map<String, Object> bookmark = new HashMap<>();
// Get bookmark title // Get bookmark title
String title = item.getTitle(); String title = item.getTitle();
bookmark.put("title", title); bookmark.put("title", title);
// Get page number (1-based for UI purposes) // Get page number (1-based for UI purposes)
PDPage page = item.findDestinationPage(document); PDPage page = item.findDestinationPage(document);
if (page != null) { if (page != null) {
@ -129,92 +131,94 @@ public class EditTableOfContentsController {
} else { } else {
bookmark.put("pageNumber", 1); bookmark.put("pageNumber", 1);
} }
// Process children if any // Process children if any
PDOutlineItem child = item.getFirstChild(); PDOutlineItem child = item.getFirstChild();
if (child != null) { if (child != null) {
List<Map<String, Object>> children = new ArrayList<>(); List<Map<String, Object>> children = new ArrayList<>();
while (child != null) { while (child != null) {
// Recursively process child items // Recursively process child items
Map<String, Object> childBookmark = processChild(document, child); Map<String, Object> childBookmark = processChild(document, child);
children.add(childBookmark); children.add(childBookmark);
child = child.getNextSibling(); child = child.getNextSibling();
} }
bookmark.put("children", children); bookmark.put("children", children);
} else { } else {
bookmark.put("children", new ArrayList<>()); bookmark.put("children", new ArrayList<>());
} }
return bookmark; return bookmark;
} }
@PostMapping(value = "/edit-table-of-contents", consumes = "multipart/form-data") @PostMapping(value = "/edit-table-of-contents", consumes = "multipart/form-data")
@Operation( @Operation(
summary = "Edit Table of Contents", summary = "Edit Table of Contents",
description = "Add or edit bookmarks/table of contents in a PDF document.") description = "Add or edit bookmarks/table of contents in a PDF document.")
public ResponseEntity<byte[]> editTableOfContents(@ModelAttribute EditTableOfContentsRequest request) public ResponseEntity<byte[]> editTableOfContents(
throws Exception { @ModelAttribute EditTableOfContentsRequest request) throws Exception {
MultipartFile file = request.getFileInput(); MultipartFile file = request.getFileInput();
PDDocument document = null; PDDocument document = null;
try { try {
document = pdfDocumentFactory.load(file); document = pdfDocumentFactory.load(file);
// Parse the bookmark data from JSON // Parse the bookmark data from JSON
List<BookmarkItem> bookmarks = objectMapper.readValue( List<BookmarkItem> bookmarks =
request.getBookmarkData(), objectMapper.readValue(
new TypeReference<List<BookmarkItem>>() {}); request.getBookmarkData(), new TypeReference<List<BookmarkItem>>() {});
// Create a new document outline // Create a new document outline
PDDocumentOutline outline = new PDDocumentOutline(); PDDocumentOutline outline = new PDDocumentOutline();
document.getDocumentCatalog().setDocumentOutline(outline); document.getDocumentCatalog().setDocumentOutline(outline);
// Add bookmarks to the outline // Add bookmarks to the outline
addBookmarksToOutline(document, outline, bookmarks); addBookmarksToOutline(document, outline, bookmarks);
// Save the document to a byte array // Save the document to a byte array
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos); document.save(baos);
String filename = file.getOriginalFilename().replaceFirst("[.][^.]+$", ""); String filename = file.getOriginalFilename().replaceFirst("[.][^.]+$", "");
return WebResponseUtils.bytesToWebResponse( return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(), filename + "_with_toc.pdf", MediaType.APPLICATION_PDF); baos.toByteArray(), filename + "_with_toc.pdf", MediaType.APPLICATION_PDF);
} finally { } finally {
if (document != null) { if (document != null) {
document.close(); document.close();
} }
} }
} }
private void addBookmarksToOutline(PDDocument document, PDDocumentOutline outline, List<BookmarkItem> bookmarks) { private void addBookmarksToOutline(
PDDocument document, PDDocumentOutline outline, List<BookmarkItem> bookmarks) {
for (BookmarkItem bookmark : bookmarks) { for (BookmarkItem bookmark : bookmarks) {
PDOutlineItem item = createOutlineItem(document, bookmark); PDOutlineItem item = createOutlineItem(document, bookmark);
outline.addLast(item); outline.addLast(item);
if (bookmark.getChildren() != null && !bookmark.getChildren().isEmpty()) { if (bookmark.getChildren() != null && !bookmark.getChildren().isEmpty()) {
addChildBookmarks(document, item, bookmark.getChildren()); addChildBookmarks(document, item, bookmark.getChildren());
} }
} }
} }
private void addChildBookmarks(PDDocument document, PDOutlineItem parent, List<BookmarkItem> children) { private void addChildBookmarks(
PDDocument document, PDOutlineItem parent, List<BookmarkItem> children) {
for (BookmarkItem child : children) { for (BookmarkItem child : children) {
PDOutlineItem item = createOutlineItem(document, child); PDOutlineItem item = createOutlineItem(document, child);
parent.addLast(item); parent.addLast(item);
if (child.getChildren() != null && !child.getChildren().isEmpty()) { if (child.getChildren() != null && !child.getChildren().isEmpty()) {
addChildBookmarks(document, item, child.getChildren()); addChildBookmarks(document, item, child.getChildren());
} }
} }
} }
private PDOutlineItem createOutlineItem(PDDocument document, BookmarkItem bookmark) { private PDOutlineItem createOutlineItem(PDDocument document, BookmarkItem bookmark) {
PDOutlineItem item = new PDOutlineItem(); PDOutlineItem item = new PDOutlineItem();
item.setTitle(bookmark.getTitle()); item.setTitle(bookmark.getTitle());
// Get the target page - adjust for 0-indexed pages in PDFBox // Get the target page - adjust for 0-indexed pages in PDFBox
int pageIndex = bookmark.getPageNumber() - 1; int pageIndex = bookmark.getPageNumber() - 1;
if (pageIndex < 0) { if (pageIndex < 0) {
@ -222,41 +226,41 @@ public class EditTableOfContentsController {
} else if (pageIndex >= document.getNumberOfPages()) { } else if (pageIndex >= document.getNumberOfPages()) {
pageIndex = document.getNumberOfPages() - 1; pageIndex = document.getNumberOfPages() - 1;
} }
PDPage page = document.getPage(pageIndex); PDPage page = document.getPage(pageIndex);
item.setDestination(page); item.setDestination(page);
return item; return item;
} }
// Inner class to represent bookmarks in JSON // Inner class to represent bookmarks in JSON
public static class BookmarkItem { public static class BookmarkItem {
private String title; private String title;
private int pageNumber; private int pageNumber;
private List<BookmarkItem> children = new ArrayList<>(); private List<BookmarkItem> children = new ArrayList<>();
public String getTitle() { public String getTitle() {
return title; return title;
} }
public void setTitle(String title) { public void setTitle(String title) {
this.title = title; this.title = title;
} }
public int getPageNumber() { public int getPageNumber() {
return pageNumber; return pageNumber;
} }
public void setPageNumber(int pageNumber) { public void setPageNumber(int pageNumber) {
this.pageNumber = pageNumber; this.pageNumber = pageNumber;
} }
public List<BookmarkItem> getChildren() { public List<BookmarkItem> getChildren() {
return children; return children;
} }
public void setChildren(List<BookmarkItem> children) { public void setChildren(List<BookmarkItem> children) {
this.children = children; this.children = children;
} }
} }
} }

View File

@ -117,9 +117,9 @@ public class MergeController {
// Create the document outline // Create the document outline
PDDocumentOutline outline = new PDDocumentOutline(); PDDocumentOutline outline = new PDDocumentOutline();
mergedDocument.getDocumentCatalog().setDocumentOutline(outline); mergedDocument.getDocumentCatalog().setDocumentOutline(outline);
int pageIndex = 0; // Current page index in the merged document int pageIndex = 0; // Current page index in the merged document
// Iterate through the original files // Iterate through the original files
for (MultipartFile file : files) { for (MultipartFile file : files) {
// Get the filename without extension to use as bookmark title // Get the filename without extension to use as bookmark title
@ -128,20 +128,20 @@ public class MergeController {
if (title != null && title.contains(".")) { if (title != null && title.contains(".")) {
title = title.substring(0, title.lastIndexOf('.')); title = title.substring(0, title.lastIndexOf('.'));
} }
// Create an outline item for this file // Create an outline item for this file
PDOutlineItem item = new PDOutlineItem(); PDOutlineItem item = new PDOutlineItem();
item.setTitle(title); item.setTitle(title);
// Set the destination to the first page of this file in the merged document // Set the destination to the first page of this file in the merged document
if (pageIndex < mergedDocument.getNumberOfPages()) { if (pageIndex < mergedDocument.getNumberOfPages()) {
PDPage page = mergedDocument.getPage(pageIndex); PDPage page = mergedDocument.getPage(pageIndex);
item.setDestination(page); item.setDestination(page);
} }
// Add the item to the outline // Add the item to the outline
outline.addLast(item); outline.addLast(item);
// Increment page index for the next file // Increment page index for the next file
try (PDDocument doc = pdfDocumentFactory.load(file)) { try (PDDocument doc = pdfDocumentFactory.load(file)) {
pageIndex += doc.getNumberOfPages(); pageIndex += doc.getNumberOfPages();
@ -212,7 +212,7 @@ public class MergeController {
} }
} }
} }
// Add table of contents if generateToc is true // Add table of contents if generateToc is true
if (generateToc && files.length > 0) { if (generateToc && files.length > 0) {
addTableOfContents(mergedDocument, files); addTableOfContents(mergedDocument, files);
@ -245,4 +245,4 @@ public class MergeController {
} }
} }
} }
} }

View File

@ -50,4 +50,4 @@ public class SettingsController {
public ResponseEntity<Map<String, Boolean>> getDisabledEndpoints() { public ResponseEntity<Map<String, Boolean>> getDisabledEndpoints() {
return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses()); return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses());
} }
} }

View File

@ -27,9 +27,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import stirling.software.SPDF.model.api.PDFWithPageNums;
@RestController @RestController
@RequestMapping("/api/v1/general") @RequestMapping("/api/v1/general")

View File

@ -31,11 +31,11 @@ import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
import stirling.software.common.model.PdfMetadata; import stirling.software.common.model.PdfMetadata;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService; import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
@RestController @RestController
@RequestMapping("/api/v1/general") @RequestMapping("/api/v1/general")

View File

@ -31,9 +31,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
@RestController @RestController
@RequestMapping("/api/v1/general") @RequestMapping("/api/v1/general")

View File

@ -16,8 +16,10 @@ import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames; import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.converters.EmlToPdfRequest; import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
@ -39,9 +41,9 @@ public class ConvertEmlToPDF {
summary = "Convert EML to PDF", summary = "Convert EML to PDF",
description = description =
"This endpoint converts EML (email) files to PDF format with extensive" "This endpoint converts EML (email) files to PDF format with extensive"
+ " customization options. Features include font settings, image constraints, display modes, attachment handling," + " customization options. Features include font settings, image constraints, display modes, attachment handling,"
+ " and HTML debug output. Input: EML file, Output: PDF" + " and HTML debug output. Input: EML file, Output: PDF"
+ " or HTML file. Type: SISO") + " or HTML file. Type: SISO")
public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) { public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
MultipartFile inputFile = request.getFileInput(); MultipartFile inputFile = request.getFileInput();
@ -94,7 +96,8 @@ public class ConvertEmlToPDF {
try { try {
byte[] pdfBytes = byte[] pdfBytes =
EmlToPdf.convertEmlToPdf( EmlToPdf.convertEmlToPdf(
runtimePathConfig.getWeasyPrintPath(), // Use configured WeasyPrint path runtimePathConfig
.getWeasyPrintPath(), // Use configured WeasyPrint path
request, request,
fileBytes, fileBytes,
originalFilename, originalFilename,
@ -119,12 +122,20 @@ public class ConvertEmlToPDF {
.body("Conversion was interrupted".getBytes(StandardCharsets.UTF_8)); .body("Conversion was interrupted".getBytes(StandardCharsets.UTF_8));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
String errorMessage = buildErrorMessage(e, originalFilename); String errorMessage = buildErrorMessage(e, originalFilename);
log.error("EML to PDF conversion failed for {}: {}", originalFilename, errorMessage, e); log.error(
"EML to PDF conversion failed for {}: {}",
originalFilename,
errorMessage,
e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorMessage.getBytes(StandardCharsets.UTF_8)); .body(errorMessage.getBytes(StandardCharsets.UTF_8));
} catch (RuntimeException e) { } catch (RuntimeException e) {
String errorMessage = buildErrorMessage(e, originalFilename); String errorMessage = buildErrorMessage(e, originalFilename);
log.error("EML to PDF conversion failed for {}: {}", originalFilename, errorMessage, e); log.error(
"EML to PDF conversion failed for {}: {}",
originalFilename,
errorMessage,
e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorMessage.getBytes(StandardCharsets.UTF_8)); .body(errorMessage.getBytes(StandardCharsets.UTF_8));
} }

View File

@ -23,9 +23,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FileToPdf; import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;

View File

@ -875,4 +875,4 @@ public class CompressController {
} }
return Math.min(9, currentLevel + 1); return Math.min(9, currentLevel + 1);
} }
} }

View File

@ -36,8 +36,8 @@ import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation; import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult; import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService; import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.service.PostHogService;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PostHogService;
import stirling.software.common.util.FileMonitor; import stirling.software.common.util.FileMonitor;
@Service @Service

View File

@ -184,7 +184,8 @@ public class RedactController {
String pageNumbersInput = request.getPageNumbers(); String pageNumbersInput = request.getPageNumbers();
String[] parsedPageNumbers = String[] parsedPageNumbers =
pageNumbersInput != null ? pageNumbersInput.split(",") : new String[0]; pageNumbersInput != null ? pageNumbersInput.split(",") : new String[0];
List<Integer> pageNumbers = GeneralUtils.parsePageList(parsedPageNumbers, pagesCount, false); List<Integer> pageNumbers =
GeneralUtils.parsePageList(parsedPageNumbers, pagesCount, false);
Collections.sort(pageNumbers); Collections.sort(pageNumbers);
return pageNumbers; return pageNumbers;
} }

View File

@ -128,4 +128,4 @@ public class ConverterWebController {
model.addAttribute("currentPage", "eml-to-pdf"); model.addAttribute("currentPage", "eml-to-pdf");
return "convert/eml-to-pdf"; return "convert/eml-to-pdf";
} }
} }

View File

@ -136,7 +136,7 @@ public class GeneralWebController {
model.addAttribute("currentPage", "edit-table-of-contents"); model.addAttribute("currentPage", "edit-table-of-contents");
return "edit-table-of-contents"; return "edit-table-of-contents";
} }
@GetMapping("/multi-tool") @GetMapping("/multi-tool")
@Hidden @Hidden
public String multiToolForm(Model model) { public String multiToolForm(Model model) {
@ -350,4 +350,4 @@ public class GeneralWebController {
this.type = type; this.type = type;
} }
} }
} }

View File

@ -22,8 +22,8 @@ import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.SPDF.model.Dependency; import stirling.software.SPDF.model.Dependency;
import stirling.software.common.model.ApplicationProperties;
@Slf4j @Slf4j
@Controller @Controller
@ -48,9 +48,7 @@ public class HomeWebController {
InputStream is = resource.getInputStream(); InputStream is = resource.getInputStream();
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8); String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
Map<String, List<Dependency>> data = Map<String, List<Dependency>> data = mapper.readValue(json, new TypeReference<>() {});
mapper.readValue(json, new TypeReference<>() {
});
model.addAttribute("dependencies", data.get("dependencies")); model.addAttribute("dependencies", data.get("dependencies"));
} catch (IOException e) { } catch (IOException e) {
log.error("exception", e); log.error("exception", e);

View File

@ -4,15 +4,21 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
@Data @Data
@EqualsAndHashCode(callSuper = false) @EqualsAndHashCode(callSuper = false)
public class EditTableOfContentsRequest extends PDFFile { public class EditTableOfContentsRequest extends PDFFile {
@Schema(description = "Bookmark structure in JSON format", example = "[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2}]}]") @Schema(
description = "Bookmark structure in JSON format",
example =
"[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2}]}]")
private String bookmarkData; private String bookmarkData;
@Schema(description = "Whether to replace existing bookmarks or append to them", example = "true") @Schema(
description = "Whether to replace existing bookmarks or append to them",
example = "true")
private Boolean replaceExisting; private Boolean replaceExisting;
} }

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
@Data @Data

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
@Data @Data

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.SPDF.model.api.PDFWithPageNums; import stirling.software.SPDF.model.api.PDFWithPageNums;
@Data @Data
@ -11,31 +12,31 @@ import stirling.software.SPDF.model.api.PDFWithPageNums;
public class ConvertToImageRequest extends PDFWithPageNums { public class ConvertToImageRequest extends PDFWithPageNums {
@Schema( @Schema(
description = "The output image format", description = "The output image format",
defaultValue = "png", defaultValue = "png",
allowableValues = {"png", "jpeg", "jpg", "gif", "webp"}, allowableValues = {"png", "jpeg", "jpg", "gif", "webp"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String imageFormat; private String imageFormat;
@Schema( @Schema(
description = description =
"Choose between a single image containing all pages or separate images for each" "Choose between a single image containing all pages or separate images for each"
+ " page", + " page",
defaultValue = "multiple", defaultValue = "multiple",
allowableValues = {"single", "multiple"}, allowableValues = {"single", "multiple"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String singleOrMultiple; private String singleOrMultiple;
@Schema( @Schema(
description = "The color type of the output image(s)", description = "The color type of the output image(s)",
defaultValue = "color", defaultValue = "color",
allowableValues = {"color", "greyscale", "blackwhite"}, allowableValues = {"color", "greyscale", "blackwhite"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String colorType; private String colorType;
@Schema( @Schema(
description = "The DPI (dots per inch) for the output image(s)", description = "The DPI (dots per inch) for the output image(s)",
defaultValue = "300", defaultValue = "300",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Integer dpi; private Integer dpi;
} }

View File

@ -12,8 +12,8 @@ import stirling.software.SPDF.model.api.PDFWithPageNums;
public class ContainsTextRequest extends PDFWithPageNums { public class ContainsTextRequest extends PDFWithPageNums {
@Schema( @Schema(
description = "The text to check for", description = "The text to check for",
defaultValue = "text", defaultValue = "text",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String text; private String text;
} }

View File

@ -12,8 +12,8 @@ import stirling.software.SPDF.model.api.PDFComparison;
public class FileSizeRequest extends PDFComparison { public class FileSizeRequest extends PDFComparison {
@Schema( @Schema(
description = "Size of the file in bytes", description = "Size of the file in bytes",
requiredMode = Schema.RequiredMode.REQUIRED, requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "0") defaultValue = "0")
private long fileSize; private long fileSize;
} }

View File

@ -12,8 +12,8 @@ import stirling.software.SPDF.model.api.PDFComparison;
public class PageRotationRequest extends PDFComparison { public class PageRotationRequest extends PDFComparison {
@Schema( @Schema(
description = "Rotation in degrees", description = "Rotation in degrees",
requiredMode = Schema.RequiredMode.REQUIRED, requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "0") defaultValue = "0")
private int rotation; private int rotation;
} }

View File

@ -12,9 +12,9 @@ import stirling.software.SPDF.model.api.PDFComparison;
public class PageSizeRequest extends PDFComparison { public class PageSizeRequest extends PDFComparison {
@Schema( @Schema(
description = "Standard Page Size", description = "Standard Page Size",
allowableValues = {"A0", "A1", "A2", "A3", "A4", "A5", "A6", "LETTER", "LEGAL"}, allowableValues = {"A0", "A1", "A2", "A3", "A4", "A5", "A6", "LETTER", "LEGAL"},
defaultValue = "A4", defaultValue = "A4",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String standardPageSize; private String standardPageSize;
} }

View File

@ -32,11 +32,11 @@ public class MergePdfsRequest extends MultiplePDFFiles {
requiredMode = Schema.RequiredMode.REQUIRED, requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "true") defaultValue = "true")
private Boolean removeCertSign; private Boolean removeCertSign;
@Schema( @Schema(
description = description =
"Flag indicating whether to generate a table of contents for the merged PDF. If true, a table of contents will be created using the input filenames as chapter names.", "Flag indicating whether to generate a table of contents for the merged PDF. If true, a table of contents will be created using the input filenames as chapter names.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED, requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false") defaultValue = "false")
private boolean generateToc = false; private boolean generateToc = false;
} }

View File

@ -14,33 +14,33 @@ import stirling.software.common.model.api.PDFFile;
public class OverlayPdfsRequest extends PDFFile { public class OverlayPdfsRequest extends PDFFile {
@Schema( @Schema(
description = description =
"An array of PDF files to be used as overlays on the base PDF. The order in" "An array of PDF files to be used as overlays on the base PDF. The order in"
+ " these files is applied based on the selected mode.", + " these files is applied based on the selected mode.",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile[] overlayFiles; private MultipartFile[] overlayFiles;
@Schema( @Schema(
description = description =
"The mode of overlaying: 'SequentialOverlay' for sequential application," "The mode of overlaying: 'SequentialOverlay' for sequential application,"
+ " 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay'" + " 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay'"
+ " for fixed repetition based on provided counts", + " for fixed repetition based on provided counts",
allowableValues = {"SequentialOverlay", "InterleavedOverlay", "FixedRepeatOverlay"}, allowableValues = {"SequentialOverlay", "InterleavedOverlay", "FixedRepeatOverlay"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String overlayMode; private String overlayMode;
@Schema( @Schema(
description = description =
"An array of integers specifying the number of times each corresponding overlay" "An array of integers specifying the number of times each corresponding overlay"
+ " file should be applied in the 'FixedRepeatOverlay' mode. This should" + " file should be applied in the 'FixedRepeatOverlay' mode. This should"
+ " match the length of the overlayFiles array.", + " match the length of the overlayFiles array.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private int[] counts; private int[] counts;
@Schema( @Schema(
description = "Overlay position 0 is Foregound, 1 is Background", description = "Overlay position 0 is Foregound, 1 is Background",
allowableValues = {"0", "1"}, allowableValues = {"0", "1"},
requiredMode = Schema.RequiredMode.REQUIRED, requiredMode = Schema.RequiredMode.REQUIRED,
type = "number") type = "number")
private int overlayPosition; private int overlayPosition;
} }

View File

@ -14,9 +14,9 @@ import stirling.software.SPDF.model.api.PDFWithPageNums;
public class AddStampRequest extends PDFWithPageNums { public class AddStampRequest extends PDFWithPageNums {
@Schema( @Schema(
description = "The stamp type (text or image)", description = "The stamp type (text or image)",
allowableValues = {"text", "image"}, allowableValues = {"text", "image"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String stampType; private String stampType;
@Schema(description = "The stamp text", defaultValue = "Stirling Software") @Schema(description = "The stamp text", defaultValue = "Stirling Software")
@ -26,60 +26,60 @@ public class AddStampRequest extends PDFWithPageNums {
private MultipartFile stampImage; private MultipartFile stampImage;
@Schema( @Schema(
description = "The selected alphabet of the stamp text", description = "The selected alphabet of the stamp text",
allowableValues = {"roman", "arabic", "japanese", "korean", "chinese"}, allowableValues = {"roman", "arabic", "japanese", "korean", "chinese"},
defaultValue = "roman") defaultValue = "roman")
private String alphabet = "roman"; private String alphabet = "roman";
@Schema( @Schema(
description = "The font size of the stamp text and image", description = "The font size of the stamp text and image",
defaultValue = "30", defaultValue = "30",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float fontSize; private float fontSize;
@Schema( @Schema(
description = "The rotation of the stamp in degrees", description = "The rotation of the stamp in degrees",
defaultValue = "0", defaultValue = "0",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float rotation; private float rotation;
@Schema( @Schema(
description = "The opacity of the stamp (0.0 - 1.0)", description = "The opacity of the stamp (0.0 - 1.0)",
defaultValue = "0.5", defaultValue = "0.5",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float opacity; private float opacity;
@Schema( @Schema(
description = description =
"Position for stamp placement based on a 1-9 grid (1: bottom-left, 2: bottom-center," "Position for stamp placement based on a 1-9 grid (1: bottom-left, 2: bottom-center,"
+ " 3: bottom-right, 4: middle-left, 5: middle-center, 6: middle-right," + " 3: bottom-right, 4: middle-left, 5: middle-center, 6: middle-right,"
+ " 7: top-left, 8: top-center, 9: top-right)", + " 7: top-left, 8: top-center, 9: top-right)",
allowableValues = {"1", "2", "3", "4", "5", "6", "7", "8", "9"}, allowableValues = {"1", "2", "3", "4", "5", "6", "7", "8", "9"},
defaultValue = "5", defaultValue = "5",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private int position; private int position;
@Schema( @Schema(
description = description =
"Override X coordinate for stamp placement. If set, it will override the" "Override X coordinate for stamp placement. If set, it will override the"
+ " position-based calculation. Negative value means no override.", + " position-based calculation. Negative value means no override.",
defaultValue = "-1", defaultValue = "-1",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float overrideX; // Default to -1 indicating no override private float overrideX; // Default to -1 indicating no override
@Schema( @Schema(
description = description =
"Override Y coordinate for stamp placement. If set, it will override the" "Override Y coordinate for stamp placement. If set, it will override the"
+ " position-based calculation. Negative value means no override.", + " position-based calculation. Negative value means no override.",
defaultValue = "-1", defaultValue = "-1",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float overrideY; // Default to -1 indicating no override private float overrideY; // Default to -1 indicating no override
@Schema( @Schema(
description = "Specifies the margin size for the stamp.", description = "Specifies the margin size for the stamp.",
allowableValues = {"small", "medium", "large", "x-large"}, allowableValues = {"small", "medium", "large", "x-large"},
defaultValue = "medium", defaultValue = "medium",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String customMargin; private String customMargin;
@Schema(description = "The color of the stamp text", defaultValue = "#d3d3d3") @Schema(description = "The color of the stamp text", defaultValue = "#d3d3d3")

View File

@ -14,71 +14,71 @@ import stirling.software.common.model.api.PDFFile;
public class MetadataRequest extends PDFFile { public class MetadataRequest extends PDFFile {
@Schema( @Schema(
description = "Delete all metadata if set to true", description = "Delete all metadata if set to true",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean deleteAll; private Boolean deleteAll;
@Schema( @Schema(
description = "The author of the document", description = "The author of the document",
defaultValue = "author", defaultValue = "author",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String author; private String author;
@Schema( @Schema(
description = "The creation date of the document (format: yyyy/MM/dd HH:mm:ss)", description = "The creation date of the document (format: yyyy/MM/dd HH:mm:ss)",
pattern = "yyyy/MM/dd HH:mm:ss", pattern = "yyyy/MM/dd HH:mm:ss",
defaultValue = "2023/10/01 12:00:00", defaultValue = "2023/10/01 12:00:00",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String creationDate; private String creationDate;
@Schema( @Schema(
description = "The creator of the document", description = "The creator of the document",
defaultValue = "creator", defaultValue = "creator",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String creator; private String creator;
@Schema( @Schema(
description = "The keywords for the document", description = "The keywords for the document",
defaultValue = "keywords", defaultValue = "keywords",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String keywords; private String keywords;
@Schema( @Schema(
description = "The modification date of the document (format: yyyy/MM/dd HH:mm:ss)", description = "The modification date of the document (format: yyyy/MM/dd HH:mm:ss)",
pattern = "yyyy/MM/dd HH:mm:ss", pattern = "yyyy/MM/dd HH:mm:ss",
defaultValue = "2023/10/01 12:00:00", defaultValue = "2023/10/01 12:00:00",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String modificationDate; private String modificationDate;
@Schema( @Schema(
description = "The producer of the document", description = "The producer of the document",
defaultValue = "producer", defaultValue = "producer",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String producer; private String producer;
@Schema( @Schema(
description = "The subject of the document", description = "The subject of the document",
defaultValue = "subject", defaultValue = "subject",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String subject; private String subject;
@Schema( @Schema(
description = "The title of the document", description = "The title of the document",
defaultValue = "title", defaultValue = "title",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String title; private String title;
@Schema( @Schema(
description = "The trapped status of the document", description = "The trapped status of the document",
defaultValue = "False", defaultValue = "False",
allowableValues = {"True", "False", "Unknown"}, allowableValues = {"True", "False", "Unknown"},
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String trapped; private String trapped;
@Schema( @Schema(
description = description =
"Map list of key and value of custom parameters. Note these must start with" "Map list of key and value of custom parameters. Note these must start with"
+ " customKey and customValue if they are non-standard") + " customKey and customValue if they are non-standard")
private Map<String, String> allRequestParams; private Map<String, String> allRequestParams;
} }

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
@Data @Data

View File

@ -12,24 +12,24 @@ import stirling.software.common.model.api.PDFFile;
public class AddPasswordRequest extends PDFFile { public class AddPasswordRequest extends PDFFile {
@Schema( @Schema(
description = description =
"The owner password to be added to the PDF file (Restricts what can be done" "The owner password to be added to the PDF file (Restricts what can be done"
+ " with the document once it is opened)", + " with the document once it is opened)",
format = "password") format = "password")
private String ownerPassword; private String ownerPassword;
@Schema( @Schema(
description = description =
"The password to be added to the PDF file (Restricts the opening of the" "The password to be added to the PDF file (Restricts the opening of the"
+ " document itself.)", + " document itself.)",
format = "password") format = "password")
private String password; private String password;
@Schema( @Schema(
description = "The length of the encryption key", description = "The length of the encryption key",
allowableValues = {"40", "128", "256"}, allowableValues = {"40", "128", "256"},
defaultValue = "256", defaultValue = "256",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private int keyLength = 256; private int keyLength = 256;
@Schema(description = "Whether document assembly is prevented", defaultValue = "false") @Schema(description = "Whether document assembly is prevented", defaultValue = "false")
@ -39,8 +39,8 @@ public class AddPasswordRequest extends PDFFile {
private Boolean preventExtractContent; private Boolean preventExtractContent;
@Schema( @Schema(
description = "Whether content extraction for accessibility is prevented", description = "Whether content extraction for accessibility is prevented",
defaultValue = "false") defaultValue = "false")
private Boolean preventExtractForAccessibility; private Boolean preventExtractForAccessibility;
@Schema(description = "Whether form filling is prevented", defaultValue = "false") @Schema(description = "Whether form filling is prevented", defaultValue = "false")
@ -50,8 +50,8 @@ public class AddPasswordRequest extends PDFFile {
private Boolean preventModify; private Boolean preventModify;
@Schema( @Schema(
description = "Whether modification of annotations is prevented", description = "Whether modification of annotations is prevented",
defaultValue = "false") defaultValue = "false")
private Boolean preventModifyAnnotations; private Boolean preventModifyAnnotations;
@Schema(description = "Whether printing of the document is prevented", defaultValue = "false") @Schema(description = "Whether printing of the document is prevented", defaultValue = "false")

View File

@ -14,19 +14,19 @@ import stirling.software.common.model.api.security.RedactionArea;
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ManualRedactPdfRequest extends PDFWithPageNums { public class ManualRedactPdfRequest extends PDFWithPageNums {
@Schema( @Schema(
description = "A list of areas that should be redacted", description = "A list of areas that should be redacted",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private List<RedactionArea> redactions; private List<RedactionArea> redactions;
@Schema( @Schema(
description = "Convert the redacted PDF to an image", description = "Convert the redacted PDF to an image",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean convertPDFToImage; private Boolean convertPDFToImage;
@Schema( @Schema(
description = "The color used to fully redact certain pages", description = "The color used to fully redact certain pages",
defaultValue = "#000000", defaultValue = "#000000",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String pageRedactionColor; private String pageRedactionColor;
} }

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
@Data @Data
@ -11,38 +12,38 @@ import stirling.software.common.model.api.PDFFile;
public class RedactPdfRequest extends PDFFile { public class RedactPdfRequest extends PDFFile {
@Schema( @Schema(
description = "List of text to redact from the PDF", description = "List of text to redact from the PDF",
defaultValue = "text,text2", defaultValue = "text,text2",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String listOfText; private String listOfText;
@Schema( @Schema(
description = "Whether to use regex for the listOfText", description = "Whether to use regex for the listOfText",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean useRegex; private Boolean useRegex;
@Schema( @Schema(
description = "Whether to use whole word search", description = "Whether to use whole word search",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean wholeWordSearch; private Boolean wholeWordSearch;
@Schema( @Schema(
description = "The color for redaction", description = "The color for redaction",
defaultValue = "#000000", defaultValue = "#000000",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String redactColor; private String redactColor;
@Schema( @Schema(
description = "Custom padding for redaction", description = "Custom padding for redaction",
type = "number", type = "number",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float customPadding; private float customPadding;
@Schema( @Schema(
description = "Convert the redacted PDF to an image", description = "Convert the redacted PDF to an image",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean convertPDFToImage; private Boolean convertPDFToImage;
} }

View File

@ -12,38 +12,38 @@ import stirling.software.common.model.api.PDFFile;
public class SanitizePdfRequest extends PDFFile { public class SanitizePdfRequest extends PDFFile {
@Schema( @Schema(
description = "Remove JavaScript actions from the PDF", description = "Remove JavaScript actions from the PDF",
defaultValue = "true", defaultValue = "true",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeJavaScript; private Boolean removeJavaScript;
@Schema( @Schema(
description = "Remove embedded files from the PDF", description = "Remove embedded files from the PDF",
defaultValue = "true", defaultValue = "true",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeEmbeddedFiles; private Boolean removeEmbeddedFiles;
@Schema( @Schema(
description = "Remove XMP metadata from the PDF", description = "Remove XMP metadata from the PDF",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeXMPMetadata; private Boolean removeXMPMetadata;
@Schema( @Schema(
description = "Remove document info metadata from the PDF", description = "Remove document info metadata from the PDF",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeMetadata; private Boolean removeMetadata;
@Schema( @Schema(
description = "Remove links from the PDF", description = "Remove links from the PDF",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeLinks; private Boolean removeLinks;
@Schema( @Schema(
description = "Remove fonts from the PDF", description = "Remove fonts from the PDF",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeFonts; private Boolean removeFonts;
} }

View File

@ -14,8 +14,8 @@ import io.micrometer.core.instrument.search.Search;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import stirling.software.common.service.PostHogService;
import stirling.software.SPDF.config.EndpointInspector; import stirling.software.SPDF.config.EndpointInspector;
import stirling.software.common.service.PostHogService;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor

View File

@ -0,0 +1,382 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.SPDF.controller.api.EditTableOfContentsController.BookmarkItem;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class EditTableOfContentsControllerTest {
@Mock
private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock
private ObjectMapper objectMapper;
@InjectMocks
private EditTableOfContentsController editTableOfContentsController;
private MockMultipartFile mockFile;
private PDDocument mockDocument;
private PDDocumentCatalog mockCatalog;
private PDPageTree mockPages;
private PDPage mockPage1;
private PDPage mockPage2;
private PDDocumentOutline mockOutline;
private PDOutlineItem mockOutlineItem;
@BeforeEach
void setUp() {
mockFile = new MockMultipartFile("file", "test.pdf", "application/pdf", "PDF content".getBytes());
mockDocument = mock(PDDocument.class);
mockCatalog = mock(PDDocumentCatalog.class);
mockPages = mock(PDPageTree.class);
mockPage1 = mock(PDPage.class);
mockPage2 = mock(PDPage.class);
mockOutline = mock(PDDocumentOutline.class);
mockOutlineItem = mock(PDOutlineItem.class);
}
@Test
void testExtractBookmarks_WithExistingBookmarks_Success() throws Exception {
// Given
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockCatalog.getDocumentOutline()).thenReturn(mockOutline);
when(mockOutline.getFirstChild()).thenReturn(mockOutlineItem);
when(mockOutlineItem.getTitle()).thenReturn("Chapter 1");
when(mockOutlineItem.findDestinationPage(mockDocument)).thenReturn(mockPage1);
when(mockDocument.getPages()).thenReturn(mockPages);
when(mockPages.indexOf(mockPage1)).thenReturn(0);
when(mockOutlineItem.getFirstChild()).thenReturn(null);
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(result);
assertEquals(1, result.size());
Map<String, Object> bookmark = result.get(0);
assertEquals("Chapter 1", bookmark.get("title"));
assertEquals(1, bookmark.get("pageNumber")); // 1-based
assertInstanceOf(List.class, bookmark.get("children"));
verify(mockDocument).close();
}
@Test
void testExtractBookmarks_NoOutline_ReturnsEmptyList() throws Exception {
// Given
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockCatalog.getDocumentOutline()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(result);
assertTrue(result.isEmpty());
verify(mockDocument).close();
}
@Test
void testExtractBookmarks_WithNestedBookmarks_Success() throws Exception {
// Given
PDOutlineItem childItem = mock(PDOutlineItem.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockCatalog.getDocumentOutline()).thenReturn(mockOutline);
when(mockOutline.getFirstChild()).thenReturn(mockOutlineItem);
// Parent bookmark
when(mockOutlineItem.getTitle()).thenReturn("Chapter 1");
when(mockOutlineItem.findDestinationPage(mockDocument)).thenReturn(mockPage1);
when(mockDocument.getPages()).thenReturn(mockPages);
when(mockPages.indexOf(mockPage1)).thenReturn(0);
when(mockOutlineItem.getFirstChild()).thenReturn(childItem);
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// Child bookmark
when(childItem.getTitle()).thenReturn("Section 1.1");
when(childItem.findDestinationPage(mockDocument)).thenReturn(mockPage2);
when(mockPages.indexOf(mockPage2)).thenReturn(1);
when(childItem.getFirstChild()).thenReturn(null);
when(childItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(result);
assertEquals(1, result.size());
Map<String, Object> parentBookmark = result.get(0);
assertEquals("Chapter 1", parentBookmark.get("title"));
assertEquals(1, parentBookmark.get("pageNumber"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) parentBookmark.get("children");
assertEquals(1, children.size());
Map<String, Object> childBookmark = children.get(0);
assertEquals("Section 1.1", childBookmark.get("title"));
assertEquals(2, childBookmark.get("pageNumber"));
verify(mockDocument).close();
}
@Test
void testExtractBookmarks_PageNotFound_UsesPageOne() throws Exception {
// Given
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockCatalog.getDocumentOutline()).thenReturn(mockOutline);
when(mockOutline.getFirstChild()).thenReturn(mockOutlineItem);
when(mockOutlineItem.getTitle()).thenReturn("Chapter 1");
when(mockOutlineItem.findDestinationPage(mockDocument)).thenReturn(null); // Page not found
when(mockOutlineItem.getFirstChild()).thenReturn(null);
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(result);
assertEquals(1, result.size());
Map<String, Object> bookmark = result.get(0);
assertEquals("Chapter 1", bookmark.get("title"));
assertEquals(1, bookmark.get("pageNumber")); // Default to page 1
verify(mockDocument).close();
}
@Test
void testEditTableOfContents_Success() throws Exception {
// Given
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
request.setBookmarkData("[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[]}]");
request.setReplaceExisting(true);
List<BookmarkItem> bookmarks = new ArrayList<>();
BookmarkItem bookmark = new BookmarkItem();
bookmark.setTitle("Chapter 1");
bookmark.setPageNumber(1);
bookmark.setChildren(new ArrayList<>());
bookmarks.add(bookmark);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(objectMapper.readValue(eq(request.getBookmarkData()), any(TypeReference.class))).thenReturn(bookmarks);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(0)).thenReturn(mockPage1);
// Mock saving behavior
doAnswer(invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
}).when(mockDocument).save(any(ByteArrayOutputStream.class));
// When
ResponseEntity<byte[]> result = editTableOfContentsController.editTableOfContents(request);
// Then
assertNotNull(result);
assertNotNull(result.getBody());
ArgumentCaptor<PDDocumentOutline> outlineCaptor = ArgumentCaptor.forClass(PDDocumentOutline.class);
verify(mockCatalog).setDocumentOutline(outlineCaptor.capture());
PDDocumentOutline capturedOutline = outlineCaptor.getValue();
assertNotNull(capturedOutline);
verify(mockDocument).close();
}
@Test
void testEditTableOfContents_WithNestedBookmarks_Success() throws Exception {
// Given
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
String bookmarkJson = "[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2,\"children\":[]}]}]";
request.setBookmarkData(bookmarkJson);
List<BookmarkItem> bookmarks = new ArrayList<>();
BookmarkItem parentBookmark = new BookmarkItem();
parentBookmark.setTitle("Chapter 1");
parentBookmark.setPageNumber(1);
BookmarkItem childBookmark = new BookmarkItem();
childBookmark.setTitle("Section 1.1");
childBookmark.setPageNumber(2);
childBookmark.setChildren(new ArrayList<>());
List<BookmarkItem> children = new ArrayList<>();
children.add(childBookmark);
parentBookmark.setChildren(children);
bookmarks.add(parentBookmark);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(objectMapper.readValue(eq(bookmarkJson), any(TypeReference.class))).thenReturn(bookmarks);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(0)).thenReturn(mockPage1);
when(mockDocument.getPage(1)).thenReturn(mockPage2);
doAnswer(invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
}).when(mockDocument).save(any(ByteArrayOutputStream.class));
// When
ResponseEntity<byte[]> result = editTableOfContentsController.editTableOfContents(request);
// Then
assertNotNull(result);
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(mockDocument).close();
}
@Test
void testEditTableOfContents_PageNumberBounds_ClampsValues() throws Exception {
// Given
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
request.setBookmarkData("[{\"title\":\"Chapter 1\",\"pageNumber\":-5,\"children\":[]},{\"title\":\"Chapter 2\",\"pageNumber\":100,\"children\":[]}]");
List<BookmarkItem> bookmarks = new ArrayList<>();
BookmarkItem bookmark1 = new BookmarkItem();
bookmark1.setTitle("Chapter 1");
bookmark1.setPageNumber(-5); // Negative page number
bookmark1.setChildren(new ArrayList<>());
BookmarkItem bookmark2 = new BookmarkItem();
bookmark2.setTitle("Chapter 2");
bookmark2.setPageNumber(100); // Page number exceeds document pages
bookmark2.setChildren(new ArrayList<>());
bookmarks.add(bookmark1);
bookmarks.add(bookmark2);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(objectMapper.readValue(eq(request.getBookmarkData()), any(TypeReference.class))).thenReturn(bookmarks);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(0)).thenReturn(mockPage1); // For negative page number
when(mockDocument.getPage(4)).thenReturn(mockPage2); // For page number exceeding bounds
doAnswer(invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
}).when(mockDocument).save(any(ByteArrayOutputStream.class));
// When
ResponseEntity<byte[]> result = editTableOfContentsController.editTableOfContents(request);
// Then
assertNotNull(result);
verify(mockDocument).getPage(0); // Clamped to first page
verify(mockDocument).getPage(4); // Clamped to last page
verify(mockDocument).close();
}
@Test
void testCreateOutlineItem_ValidPageNumber_Success() throws Exception {
// Given
BookmarkItem bookmark = new BookmarkItem();
bookmark.setTitle("Test Chapter");
bookmark.setPageNumber(3);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(2)).thenReturn(mockPage1); // 0-indexed
// When
Method createOutlineItemMethod = EditTableOfContentsController.class.getDeclaredMethod("createOutlineItem", PDDocument.class, BookmarkItem.class);
createOutlineItemMethod.setAccessible(true);
PDOutlineItem result = (PDOutlineItem) createOutlineItemMethod.invoke(editTableOfContentsController, mockDocument, bookmark);
// Then
assertNotNull(result);
verify(mockDocument).getPage(2);
}
@Test
void testBookmarkItem_GettersAndSetters() {
// Given
BookmarkItem bookmark = new BookmarkItem();
List<BookmarkItem> children = new ArrayList<>();
// When
bookmark.setTitle("Test Title");
bookmark.setPageNumber(5);
bookmark.setChildren(children);
// Then
assertEquals("Test Title", bookmark.getTitle());
assertEquals(5, bookmark.getPageNumber());
assertEquals(children, bookmark.getChildren());
}
@Test
void testEditTableOfContents_IOExceptionDuringLoad_ThrowsException() throws Exception {
// Given
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
when(pdfDocumentFactory.load(mockFile)).thenThrow(new RuntimeException("Failed to load PDF"));
// When & Then
assertThrows(RuntimeException.class, () -> editTableOfContentsController.editTableOfContents(request));
}
@Test
void testExtractBookmarks_IOExceptionDuringLoad_ThrowsException() throws Exception {
// Given
when(pdfDocumentFactory.load(mockFile)).thenThrow(new RuntimeException("Failed to load PDF"));
// When & Then
assertThrows(RuntimeException.class, () -> editTableOfContentsController.extractBookmarks(mockFile));
}
}

View File

@ -0,0 +1,279 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class MergeControllerTest {
@Mock
private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks
private MergeController mergeController;
private MockMultipartFile mockFile1;
private MockMultipartFile mockFile2;
private MockMultipartFile mockFile3;
private PDDocument mockDocument;
private PDDocument mockMergedDocument;
private PDDocumentCatalog mockCatalog;
private PDPageTree mockPages;
private PDPage mockPage1;
private PDPage mockPage2;
@BeforeEach
void setUp() {
mockFile1 = new MockMultipartFile("file1", "document1.pdf", "application/pdf", "PDF content 1".getBytes());
mockFile2 = new MockMultipartFile("file2", "document2.pdf", "application/pdf", "PDF content 2".getBytes());
mockFile3 = new MockMultipartFile("file3", "chapter3.pdf", "application/pdf", "PDF content 3".getBytes());
mockDocument = mock(PDDocument.class);
mockMergedDocument = mock(PDDocument.class);
mockCatalog = mock(PDDocumentCatalog.class);
mockPages = mock(PDPageTree.class);
mockPage1 = mock(PDPage.class);
mockPage2 = mock(PDPage.class);
}
@Test
void testAddTableOfContents_WithMultipleFiles_Success() throws Exception {
// Given
MultipartFile[] files = {mockFile1, mockFile2, mockFile3};
// Mock the merged document setup
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(6);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
when(mockMergedDocument.getPage(2)).thenReturn(mockPage2);
when(mockMergedDocument.getPage(4)).thenReturn(mockPage1);
// Mock individual document loading for page count
PDDocument doc1 = mock(PDDocument.class);
PDDocument doc2 = mock(PDDocument.class);
PDDocument doc3 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(pdfDocumentFactory.load(mockFile2)).thenReturn(doc2);
when(pdfDocumentFactory.load(mockFile3)).thenReturn(doc3);
when(doc1.getNumberOfPages()).thenReturn(2);
when(doc2.getNumberOfPages()).thenReturn(2);
when(doc3.getNumberOfPages()).thenReturn(2);
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
ArgumentCaptor<PDDocumentOutline> outlineCaptor = ArgumentCaptor.forClass(PDDocumentOutline.class);
verify(mockCatalog).setDocumentOutline(outlineCaptor.capture());
PDDocumentOutline capturedOutline = outlineCaptor.getValue();
assertNotNull(capturedOutline);
// Verify that documents were loaded for page count
verify(pdfDocumentFactory).load(mockFile1);
verify(pdfDocumentFactory).load(mockFile2);
verify(pdfDocumentFactory).load(mockFile3);
// Verify document closing
verify(doc1).close();
verify(doc2).close();
verify(doc3).close();
}
@Test
void testAddTableOfContents_WithSingleFile_Success() throws Exception {
// Given
MultipartFile[] files = {mockFile1};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(3);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(3);
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(pdfDocumentFactory).load(mockFile1);
verify(doc1).close();
}
@Test
void testAddTableOfContents_WithEmptyArray_Success() throws Exception {
// Given
MultipartFile[] files = {};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
verify(mockMergedDocument).getDocumentCatalog();
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verifyNoInteractions(pdfDocumentFactory);
}
@Test
void testAddTableOfContents_WithIOException_HandlesGracefully() throws Exception {
// Given
MultipartFile[] files = {mockFile1, mockFile2};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(4);
when(mockMergedDocument.getPage(anyInt())).thenReturn(mockPage1); // Use anyInt() to avoid stubbing conflicts
// First document loads successfully
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(2);
// Second document throws IOException
when(pdfDocumentFactory.load(mockFile2)).thenThrow(new IOException("Failed to load document"));
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
// Should not throw exception
assertDoesNotThrow(() ->
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files)
);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(pdfDocumentFactory).load(mockFile1);
verify(pdfDocumentFactory).load(mockFile2);
verify(doc1).close();
}
@Test
void testAddTableOfContents_FilenameWithoutExtension_UsesFullName() throws Exception {
// Given
MockMultipartFile fileWithoutExtension = new MockMultipartFile("file", "document_no_ext", "application/pdf", "PDF content".getBytes());
MultipartFile[] files = {fileWithoutExtension};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(1);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
PDDocument doc = mock(PDDocument.class);
when(pdfDocumentFactory.load(fileWithoutExtension)).thenReturn(doc);
when(doc.getNumberOfPages()).thenReturn(1);
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(doc).close();
}
@Test
void testAddTableOfContents_PageIndexExceedsDocumentPages_HandlesGracefully() throws Exception {
// Given
MultipartFile[] files = {mockFile1};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(0); // No pages in merged document
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(3);
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
// Should not throw exception
assertDoesNotThrow(() ->
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files)
);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(mockMergedDocument, never()).getPage(anyInt());
verify(doc1).close();
}
@Test
void testMergeDocuments_Success() throws IOException {
// Given
PDDocument doc1 = mock(PDDocument.class);
PDDocument doc2 = mock(PDDocument.class);
List<PDDocument> documents = Arrays.asList(doc1, doc2);
PDPageTree pages1 = mock(PDPageTree.class);
PDPageTree pages2 = mock(PDPageTree.class);
PDPage page1 = mock(PDPage.class);
PDPage page2 = mock(PDPage.class);
when(pdfDocumentFactory.createNewDocument()).thenReturn(mockMergedDocument);
when(doc1.getPages()).thenReturn(pages1);
when(doc2.getPages()).thenReturn(pages2);
when(pages1.iterator()).thenReturn(Arrays.asList(page1).iterator());
when(pages2.iterator()).thenReturn(Arrays.asList(page2).iterator());
// When
PDDocument result = mergeController.mergeDocuments(documents);
// Then
assertNotNull(result);
assertEquals(mockMergedDocument, result);
verify(mockMergedDocument).addPage(page1);
verify(mockMergedDocument).addPage(page2);
}
@Test
void testMergeDocuments_EmptyList_ReturnsEmptyDocument() throws IOException {
// Given
List<PDDocument> documents = Arrays.asList();
when(pdfDocumentFactory.createNewDocument()).thenReturn(mockMergedDocument);
// When
PDDocument result = mergeController.mergeDocuments(documents);
// Then
assertNotNull(result);
assertEquals(mockMergedDocument, result);
verify(mockMergedDocument, never()).addPage(any(PDPage.class));
}
}