diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..6e006423a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(chmod:*)", + "Bash(mkdir:*)", + "Bash(./gradlew:*)", + "Bash(grep:*)", + "Bash(cat:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 365676294..9a4666956 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,6 +83,7 @@ jobs: proprietary/build/reports/tests/ proprietary/build/test-results/ proprietary/build/reports/problems/ + build/reports/problems/ retention-days: 3 if-no-files-found: warn @@ -152,7 +153,7 @@ jobs: - name: Install Docker Compose run: | - sudo curl -SL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo curl -SL "https://github.com/docker/compose/releases/download/v2.37.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose - name: Set up Python diff --git a/Dockerfile b/Dockerfile index cbb20111c..fd02b29f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,11 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \ PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \ UNO_PATH=/usr/lib/libreoffice/program \ URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \ - PATH=$PATH:/opt/venv/bin + PATH=$PATH:/opt/venv/bin \ + STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ + TMPDIR=/tmp/stirling-pdf \ + TEMP=/tmp/stirling-pdf \ + TMP=/tmp/stirling-pdf # JDK for app @@ -78,17 +82,17 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \ ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \ mv /usr/share/tessdata /usr/share/tessdata-original && \ - mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders && \ + mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \ fc-cache -f -v && \ chmod +x /scripts/* && \ chmod +x /scripts/init.sh && \ # User permissions addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ - chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline && \ + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \ chown stirlingpdfuser:stirlingpdfgroup /app.jar EXPOSE 8080/tcp # Set user and run command ENTRYPOINT ["tini", "--", "/scripts/init.sh"] -CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"] +CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 37571373e..15de277b9 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -27,7 +27,11 @@ RUN apt-get update && apt-get install -y \ && apt-get clean && rm -rf /var/lib/apt/lists/* # Setze die Environment Variable für setuptools -ENV SETUPTOOLS_USE_DISTUTILS=local +ENV SETUPTOOLS_USE_DISTUTILS=local \ + STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ + TMPDIR=/tmp/stirling-pdf \ + TEMP=/tmp/stirling-pdf \ + TMP=/tmp/stirling-pdf # Installation der benötigten Python-Pakete RUN python3 -m venv --system-site-packages /opt/venv \ @@ -40,8 +44,9 @@ ENV PATH="/opt/venv/bin:$PATH" COPY . /workspace -RUN adduser --disabled-password --gecos '' devuser \ - && chown -R devuser:devuser /home/devuser /workspace +RUN mkdir -p /tmp/stirling-pdf \ + && adduser --disabled-password --gecos '' devuser \ + && chown -R devuser:devuser /home/devuser /workspace /tmp/stirling-pdf RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \ && chmod 0440 /etc/sudoers.d/devuser diff --git a/Dockerfile.fat b/Dockerfile.fat index 682fac663..666ba98be 100644 --- a/Dockerfile.fat +++ b/Dockerfile.fat @@ -46,7 +46,11 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \ PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \ UNO_PATH=/usr/lib/libreoffice/program \ URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \ - PATH=$PATH:/opt/venv/bin + PATH=$PATH:/opt/venv/bin \ + STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ + TMPDIR=/tmp/stirling-pdf \ + TEMP=/tmp/stirling-pdf \ + TMP=/tmp/stirling-pdf # JDK for app @@ -92,16 +96,16 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \ ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \ mv /usr/share/tessdata /usr/share/tessdata-original && \ - mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders && \ + mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \ fc-cache -f -v && \ chmod +x /scripts/* && \ chmod +x /scripts/init.sh && \ # User permissions addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ - chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline && \ + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \ chown stirlingpdfuser:stirlingpdfgroup /app.jar EXPOSE 8080/tcp # Set user and run command ENTRYPOINT ["tini", "--", "/scripts/init.sh"] -CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"] +CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"] diff --git a/Dockerfile.ultra-lite b/Dockerfile.ultra-lite index 83cd5e9c3..c4eb4ba46 100644 --- a/Dockerfile.ultra-lite +++ b/Dockerfile.ultra-lite @@ -11,7 +11,11 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \ JAVA_CUSTOM_OPTS="" \ PUID=1000 \ PGID=1000 \ - UMASK=022 + UMASK=022 \ + STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ + TMPDIR=/tmp/stirling-pdf \ + TEMP=/tmp/stirling-pdf \ + TMP=/tmp/stirling-pdf # Copy necessary files COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh @@ -35,10 +39,10 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et su-exec \ openjdk21-jre && \ # User permissions - mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto && \ + mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \ chmod +x /scripts/*.sh && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ - chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline && \ + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \ chown stirlingpdfuser:stirlingpdfgroup /app.jar # Set environment variables @@ -48,4 +52,4 @@ EXPOSE 8080/tcp # Run the application ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"] -CMD ["java", "-Dfile.encoding=UTF-8", "-jar", "/app.jar"] +CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"] diff --git a/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java b/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java index 062f3e0a1..8fb729560 100644 --- a/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java +++ b/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java @@ -8,22 +8,22 @@ import org.springframework.web.bind.annotation.RequestMethod; /** * Shortcut for a POST endpoint that is executed through the Stirling "auto‑job" framework. - *

- * Behaviour notes: - *

- *

* - *

Unless stated otherwise an attribute only affects async execution.

+ *

Behaviour notes: + * + *

+ * + *

Unless stated otherwise an attribute only affects async execution. */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @@ -31,42 +31,42 @@ import org.springframework.web.bind.annotation.RequestMethod; @RequestMapping(method = RequestMethod.POST) public @interface AutoJobPostMapping { - /** - * Alias for {@link RequestMapping#value} – the path mapping of the endpoint. - */ + /** Alias for {@link RequestMapping#value} – the path mapping of the endpoint. */ @AliasFor(annotation = RequestMapping.class, attribute = "value") String[] value() default {}; - /** - * MIME types this endpoint accepts. Defaults to {@code multipart/form-data}. - */ + /** MIME types this endpoint accepts. Defaults to {@code multipart/form-data}. */ @AliasFor(annotation = RequestMapping.class, attribute = "consumes") String[] consumes() default {"multipart/form-data"}; /** - * Maximum execution time in milliseconds before the job is aborted. - * A negative value means "use the application default". - *

Only honoured when {@code async=true}.

+ * Maximum execution time in milliseconds before the job is aborted. A negative value means "use + * the application default". + * + *

Only honoured when {@code async=true}. */ long timeout() default -1; /** - * Total number of attempts (initial + retries). Must be at least 1. - * Retries are executed with exponential back‑off. - *

Only honoured when {@code async=true}.

+ * Total number of attempts (initial + retries). Must be at least 1. Retries are executed + * with exponential back‑off. + * + *

Only honoured when {@code async=true}. */ int retryCount() default 1; /** * Record percentage / note updates so they can be retrieved via the REST status endpoint. - *

Only honoured when {@code async=true}.

+ * + *

Only honoured when {@code async=true}. */ boolean trackProgress() default true; /** - * If {@code true} the job may be placed in a queue instead of being rejected when resources - * are scarce. - *

Only honoured when {@code async=true}.

+ * If {@code true} the job may be placed in a queue instead of being rejected when resources are + * scarce. + * + *

Only honoured when {@code async=true}. */ boolean queueable() default false; diff --git a/common/src/main/java/stirling/software/common/config/TempFileConfiguration.java b/common/src/main/java/stirling/software/common/config/TempFileConfiguration.java new file mode 100644 index 000000000..6fce7e0bf --- /dev/null +++ b/common/src/main/java/stirling/software/common/config/TempFileConfiguration.java @@ -0,0 +1,59 @@ +package stirling.software.common.config; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.TempFileRegistry; + +/** + * Configuration for the temporary file management system. Sets up the necessary beans and + * configures system properties. + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class TempFileConfiguration { + + private final ApplicationProperties applicationProperties; + + /** + * Create the TempFileRegistry bean. + * + * @return A new TempFileRegistry instance + */ + @Bean + public TempFileRegistry tempFileRegistry() { + return new TempFileRegistry(); + } + + @PostConstruct + public void initTempFileConfig() { + try { + ApplicationProperties.TempFileManagement tempFiles = + applicationProperties.getSystem().getTempFileManagement(); + String customTempDirectory = tempFiles.getBaseTmpDir(); + + // Create the temp directory if it doesn't exist + Path tempDir = Path.of(customTempDirectory); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + log.info("Created temporary directory: {}", tempDir); + } + + log.debug("Temporary file configuration initialized"); + log.debug("Using temp directory: {}", customTempDirectory); + log.debug("Temp file prefix: {}", tempFiles.getPrefix()); + } catch (Exception e) { + log.error("Failed to initialize temporary file configuration", e); + } + } +} diff --git a/common/src/main/java/stirling/software/common/config/TempFileShutdownHook.java b/common/src/main/java/stirling/software/common/config/TempFileShutdownHook.java new file mode 100644 index 000000000..6fd3bdeff --- /dev/null +++ b/common/src/main/java/stirling/software/common/config/TempFileShutdownHook.java @@ -0,0 +1,84 @@ +package stirling.software.common.config; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.TempFileRegistry; + +/** + * Handles cleanup of temporary files on application shutdown. Implements Spring's DisposableBean + * interface to ensure cleanup happens during normal application shutdown. + */ +@Slf4j +@Component +public class TempFileShutdownHook implements DisposableBean { + + private final TempFileRegistry registry; + + @Autowired + public TempFileShutdownHook(TempFileRegistry registry) { + this.registry = registry; + + // Register a JVM shutdown hook as a backup in case Spring's + // DisposableBean mechanism doesn't trigger (e.g., during a crash) + Runtime.getRuntime().addShutdownHook(new Thread(this::cleanupTempFiles)); + } + + /** Spring's DisposableBean interface method. Called during normal application shutdown. */ + @Override + public void destroy() { + log.info("Application shutting down, cleaning up temporary files"); + cleanupTempFiles(); + } + + /** Clean up all registered temporary files and directories. */ + private void cleanupTempFiles() { + try { + // Clean up all registered files + Set files = registry.getAllRegisteredFiles(); + int deletedCount = 0; + + for (Path file : files) { + try { + if (Files.exists(file)) { + Files.deleteIfExists(file); + deletedCount++; + } + } catch (IOException e) { + log.warn("Failed to delete temp file during shutdown: {}", file, e); + } + } + + // Clean up all registered directories + Set directories = registry.getTempDirectories(); + for (Path dir : directories) { + try { + if (Files.exists(dir)) { + GeneralUtils.deleteDirectory(dir); + deletedCount++; + } + } catch (IOException e) { + log.warn("Failed to delete temp directory during shutdown: {}", dir, e); + } + } + + log.info( + "Shutdown cleanup complete. Deleted {} temporary files/directories", + deletedCount); + + // Clear the registry + registry.clear(); + } catch (Exception e) { + log.error("Error during shutdown cleanup", e); + } + } +} diff --git a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index f5b67c866..0017fa34a 100644 --- a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -292,6 +292,7 @@ public class ApplicationProperties { private Boolean enableUrlToPDF; private CustomPaths customPaths = new CustomPaths(); private String fileUploadLimit; + private TempFileManagement tempFileManagement = new TempFileManagement(); public boolean isAnalyticsEnabled() { return this.getEnableAnalytics() != null && this.getEnableAnalytics(); @@ -317,6 +318,30 @@ public class ApplicationProperties { } } + @Data + public static class TempFileManagement { + private String baseTmpDir = ""; + private String libreofficeDir = ""; + private String systemTempDir = ""; + private String prefix = "stirling-pdf-"; + private long maxAgeHours = 24; + private long cleanupIntervalMinutes = 30; + private boolean startupCleanup = true; + private boolean cleanupSystemTemp = false; + + public String getBaseTmpDir() { + return baseTmpDir != null && !baseTmpDir.isEmpty() + ? baseTmpDir + : java.lang.System.getProperty("java.io.tmpdir") + "/stirling-pdf"; + } + + public String getLibreofficeDir() { + return libreofficeDir != null && !libreofficeDir.isEmpty() + ? libreofficeDir + : getBaseTmpDir() + "/libreoffice"; + } + } + @Data public static class Datasource { private boolean enableCustomDatabase; diff --git a/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java b/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java index e4b9173d0..51f52c34d 100644 --- a/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java +++ b/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java @@ -23,6 +23,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.util.ApplicationContextProvider; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; /** * Adaptive PDF document factory that optimizes memory usage based on file size and available system @@ -402,10 +405,37 @@ public class CustomPDFDocumentFactory { } } - // Temp file handling with enhanced logging + // Temp file handling with enhanced logging and registry integration private Path createTempFile(String prefix) throws IOException { + // Check if TempFileManager is available in the application context + try { + TempFileManager tempFileManager = + ApplicationContextProvider.getBean(TempFileManager.class); + if (tempFileManager != null) { + // Use TempFileManager to create and register the temp file + File file = tempFileManager.createTempFile(".tmp"); + log.debug("Created and registered temp file via TempFileManager: {}", file); + return file.toPath(); + } + } catch (Exception e) { + log.debug("TempFileManager not available, falling back to standard temp file creation"); + } + + // Fallback to standard temp file creation Path file = Files.createTempFile(prefix + tempCounter.incrementAndGet() + "-", ".tmp"); log.debug("Created temp file: {}", file); + + // Try to register the file with a static registry if possible + try { + TempFileRegistry registry = ApplicationContextProvider.getBean(TempFileRegistry.class); + if (registry != null) { + registry.register(file); + log.debug("Registered fallback temp file with registry: {}", file); + } + } catch (Exception e) { + log.debug("Could not register fallback temp file with registry: {}", file); + } + return file; } diff --git a/common/src/main/java/stirling/software/common/service/ResourceMonitor.java b/common/src/main/java/stirling/software/common/service/ResourceMonitor.java index 2791fff90..0e8073d8f 100644 --- a/common/src/main/java/stirling/software/common/service/ResourceMonitor.java +++ b/common/src/main/java/stirling/software/common/service/ResourceMonitor.java @@ -173,7 +173,9 @@ public class ResourceMonitor { log.info("System resource status changed from {} to {}", oldStatus, newStatus); log.info( "Current metrics - CPU: {}%, Memory: {}%, Free Memory: {} MB", - String.format("%.1f", cpuUsage * 100), String.format("%.1f", memoryUsage * 100), freeMemory / (1024 * 1024)); + String.format("%.1f", cpuUsage * 100), + String.format("%.1f", memoryUsage * 100), + freeMemory / (1024 * 1024)); } } catch (Exception e) { log.error("Error updating resource metrics: {}", e.getMessage(), e); diff --git a/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java b/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java new file mode 100644 index 000000000..d53c4ea84 --- /dev/null +++ b/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java @@ -0,0 +1,447 @@ +package stirling.software.common.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PostConstruct; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** + * Service to periodically clean up temporary files. Runs scheduled tasks to delete old temp files + * and directories. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TempFileCleanupService { + + private final TempFileRegistry registry; + private final TempFileManager tempFileManager; + private final ApplicationProperties applicationProperties; + + @Autowired + @Qualifier("machineType") + private String machineType; + + // Maximum recursion depth for directory traversal + private static final int MAX_RECURSION_DEPTH = 5; + + // File patterns that identify our temp files + private static final Predicate IS_OUR_TEMP_FILE = + fileName -> + fileName.startsWith("stirling-pdf-") + || fileName.startsWith("output_") + || fileName.startsWith("compressedPDF") + || fileName.startsWith("pdf-save-") + || fileName.startsWith("pdf-stream-") + || fileName.startsWith("PDFBox") + || fileName.startsWith("input_") + || fileName.startsWith("overlay-"); + + // File patterns that identify common system temp files + private static final Predicate IS_SYSTEM_TEMP_FILE = + fileName -> + fileName.matches("lu\\d+[a-z0-9]*\\.tmp") + || fileName.matches("ocr_process\\d+") + || (fileName.startsWith("tmp") && !fileName.contains("jetty")) + || fileName.startsWith("OSL_PIPE_") + || (fileName.endsWith(".tmp") && !fileName.contains("jetty")); + + // File patterns that should be excluded from cleanup + private static final Predicate SHOULD_SKIP = + fileName -> + fileName.contains("jetty") + || fileName.startsWith("jetty-") + || fileName.equals("proc") + || fileName.equals("sys") + || fileName.equals("dev") + || fileName.equals("hsperfdata_stirlingpdfuser") + || fileName.startsWith("hsperfdata_") + || fileName.equals(".pdfbox.cache"); + + @PostConstruct + public void init() { + // Create necessary directories + ensureDirectoriesExist(); + + // Perform startup cleanup if enabled + if (applicationProperties.getSystem().getTempFileManagement().isStartupCleanup()) { + runStartupCleanup(); + } + } + + /** Ensure that all required temp directories exist */ + private void ensureDirectoriesExist() { + try { + ApplicationProperties.TempFileManagement tempFiles = + applicationProperties.getSystem().getTempFileManagement(); + + // Create the main temp directory + String customTempDirectory = tempFiles.getBaseTmpDir(); + if (customTempDirectory != null && !customTempDirectory.isEmpty()) { + Path tempDir = Path.of(customTempDirectory); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + log.info("Created temp directory: {}", tempDir); + } + } + + // Create LibreOffice temp directory + String libreOfficeTempDir = tempFiles.getLibreofficeDir(); + if (libreOfficeTempDir != null && !libreOfficeTempDir.isEmpty()) { + Path loTempDir = Path.of(libreOfficeTempDir); + if (!Files.exists(loTempDir)) { + Files.createDirectories(loTempDir); + log.info("Created LibreOffice temp directory: {}", loTempDir); + } + } + } catch (IOException e) { + log.error("Error creating temp directories", e); + } + } + + /** Scheduled task to clean up old temporary files. Runs at the configured interval. */ + @Scheduled( + fixedDelayString = + "#{applicationProperties.system.tempFileManagement.cleanupIntervalMinutes}", + timeUnit = TimeUnit.MINUTES) + public void scheduledCleanup() { + log.info("Running scheduled temporary file cleanup"); + long maxAgeMillis = tempFileManager.getMaxAgeMillis(); + + // Clean up registered temp files (managed by TempFileRegistry) + int registeredDeletedCount = tempFileManager.cleanupOldTempFiles(maxAgeMillis); + log.info("Cleaned up {} registered temporary files", registeredDeletedCount); + + // Clean up registered temp directories + int directoriesDeletedCount = 0; + for (Path directory : registry.getTempDirectories()) { + try { + if (Files.exists(directory)) { + GeneralUtils.deleteDirectory(directory); + directoriesDeletedCount++; + log.debug("Cleaned up temporary directory: {}", directory); + } + } catch (IOException e) { + log.warn("Failed to clean up temporary directory: {}", directory, e); + } + } + + // Clean up PDFBox cache file + cleanupPDFBoxCache(); + + // Clean up unregistered temp files based on our cleanup strategy + boolean containerMode = isContainerMode(); + int unregisteredDeletedCount = cleanupUnregisteredFiles(containerMode, true, maxAgeMillis); + + log.info( + "Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories", + registeredDeletedCount, + unregisteredDeletedCount, + directoriesDeletedCount); + } + + /** + * Perform startup cleanup of stale temporary files from previous runs. This is especially + * important in Docker environments where temp files persist between container restarts. + */ + private void runStartupCleanup() { + log.info("Running startup temporary file cleanup"); + boolean containerMode = isContainerMode(); + + log.info( + "Running in {} mode, using {} cleanup strategy", + machineType, + containerMode ? "aggressive" : "conservative"); + + // For startup cleanup, we use a longer timeout for non-container environments + long maxAgeMillis = containerMode ? 0 : 24 * 60 * 60 * 1000; // 0 or 24 hours + + int totalDeletedCount = cleanupUnregisteredFiles(containerMode, false, maxAgeMillis); + + log.info( + "Startup cleanup complete. Deleted {} temporary files/directories", + totalDeletedCount); + } + + /** + * Clean up unregistered temporary files across all configured temp directories. + * + * @param containerMode Whether we're in container mode (more aggressive cleanup) + * @param isScheduled Whether this is a scheduled cleanup or startup cleanup + * @param maxAgeMillis Maximum age of files to clean in milliseconds + * @return Number of files deleted + */ + private int cleanupUnregisteredFiles( + boolean containerMode, boolean isScheduled, long maxAgeMillis) { + AtomicInteger totalDeletedCount = new AtomicInteger(0); + + try { + ApplicationProperties.TempFileManagement tempFiles = + applicationProperties.getSystem().getTempFileManagement(); + Path[] dirsToScan; + if (tempFiles.isCleanupSystemTemp() + && tempFiles.getSystemTempDir() != null + && !tempFiles.getSystemTempDir().isEmpty()) { + Path systemTempPath = getSystemTempPath(); + dirsToScan = + new Path[] { + systemTempPath, + Path.of(tempFiles.getBaseTmpDir()), + Path.of(tempFiles.getLibreofficeDir()) + }; + } else { + dirsToScan = + new Path[] { + Path.of(tempFiles.getBaseTmpDir()), + Path.of(tempFiles.getLibreofficeDir()) + }; + } + + // Process each directory + Arrays.stream(dirsToScan) + .filter(Files::exists) + .forEach( + tempDir -> { + try { + String phase = isScheduled ? "scheduled" : "startup"; + log.info( + "Scanning directory for {} cleanup: {}", + phase, + tempDir); + + AtomicInteger dirDeletedCount = new AtomicInteger(0); + cleanupDirectoryStreaming( + tempDir, + containerMode, + 0, + maxAgeMillis, + isScheduled, + path -> { + dirDeletedCount.incrementAndGet(); + if (log.isDebugEnabled()) { + log.debug( + "Deleted temp file during {} cleanup: {}", + phase, + path); + } + }); + + int count = dirDeletedCount.get(); + totalDeletedCount.addAndGet(count); + if (count > 0) { + log.info( + "Cleaned up {} files/directories in {}", + count, + tempDir); + } + } catch (IOException e) { + log.error("Error during cleanup of directory: {}", tempDir, e); + } + }); + } catch (Exception e) { + log.error("Error during cleanup of unregistered files", e); + } + + return totalDeletedCount.get(); + } + + /** Get the system temp directory path based on configuration or system property. */ + private Path getSystemTempPath() { + String systemTempDir = + applicationProperties.getSystem().getTempFileManagement().getSystemTempDir(); + if (systemTempDir != null && !systemTempDir.isEmpty()) { + return Path.of(systemTempDir); + } else { + return Path.of(System.getProperty("java.io.tmpdir")); + } + } + + /** Determine if we're running in a container environment. */ + private boolean isContainerMode() { + return "Docker".equals(machineType) || "Kubernetes".equals(machineType); + } + + /** + * Recursively clean up a directory using a streaming approach to reduce memory usage. + * + * @param directory The directory to clean + * @param containerMode Whether we're in container mode (more aggressive cleanup) + * @param depth Current recursion depth + * @param maxAgeMillis Maximum age of files to delete + * @param isScheduled Whether this is a scheduled cleanup (vs startup) + * @param onDeleteCallback Callback function when a file is deleted + * @throws IOException If an I/O error occurs + */ + private void cleanupDirectoryStreaming( + Path directory, + boolean containerMode, + int depth, + long maxAgeMillis, + boolean isScheduled, + Consumer onDeleteCallback) + throws IOException { + + if (depth > MAX_RECURSION_DEPTH) { + log.debug("Maximum directory recursion depth reached for: {}", directory); + return; + } + + java.util.List subdirectories = new java.util.ArrayList<>(); + + try (Stream pathStream = Files.list(directory)) { + pathStream.forEach( + path -> { + try { + String fileName = path.getFileName().toString(); + + if (SHOULD_SKIP.test(fileName)) { + return; + } + + if (Files.isDirectory(path)) { + subdirectories.add(path); + return; + } + + if (registry.contains(path.toFile())) { + return; + } + + if (shouldDeleteFile(path, fileName, containerMode, maxAgeMillis)) { + try { + Files.deleteIfExists(path); + onDeleteCallback.accept(path); + } catch (IOException e) { + if (e.getMessage() != null + && e.getMessage() + .contains("being used by another process")) { + log.debug("File locked, skipping delete: {}", path); + } else { + log.warn("Failed to delete temp file: {}", path, e); + } + } + } + } catch (Exception e) { + log.warn("Error processing path: {}", path, e); + } + }); + } + + for (Path subdirectory : subdirectories) { + try { + cleanupDirectoryStreaming( + subdirectory, + containerMode, + depth + 1, + maxAgeMillis, + isScheduled, + onDeleteCallback); + } catch (IOException e) { + log.warn("Error processing subdirectory: {}", subdirectory, e); + } + } + } + + /** Determine if a file should be deleted based on its name, age, and other criteria. */ + private boolean shouldDeleteFile( + Path path, String fileName, boolean containerMode, long maxAgeMillis) { + // First check if it matches our known temp file patterns + boolean isOurTempFile = IS_OUR_TEMP_FILE.test(fileName); + boolean isSystemTempFile = IS_SYSTEM_TEMP_FILE.test(fileName); + + // Normal operation - check against temp file patterns + boolean shouldDelete = isOurTempFile || (containerMode && isSystemTempFile); + + // Get file info for age checks + long lastModified = 0; + long currentTime = System.currentTimeMillis(); + boolean isEmptyFile = false; + + try { + lastModified = Files.getLastModifiedTime(path).toMillis(); + // Special case for zero-byte files - these are often corrupted temp files + if (Files.size(path) == 0) { + isEmptyFile = true; + // For empty files, use a shorter timeout (5 minutes) + // Delete empty files older than 5 minutes + if ((currentTime - lastModified) > 5 * 60 * 1000) { + shouldDelete = true; + } + } + } catch (IOException e) { + log.debug("Could not check file info, skipping: {}", path); + } + + // Check file age against maxAgeMillis only if it's not an empty file that we've already + // decided to delete + if (!isEmptyFile && shouldDelete && maxAgeMillis > 0) { + // In normal mode, check age against maxAgeMillis + shouldDelete = (currentTime - lastModified) > maxAgeMillis; + } + + return shouldDelete; + } + + /** Clean up LibreOffice temporary files. This method is called after LibreOffice operations. */ + public void cleanupLibreOfficeTempFiles() { + // Cleanup known LibreOffice temp directories + try { + Set directories = registry.getTempDirectories(); + for (Path dir : directories) { + if (dir.getFileName().toString().contains("libreoffice") && Files.exists(dir)) { + // For directories containing "libreoffice", delete all contents + // but keep the directory itself for future use + cleanupDirectoryStreaming( + dir, + isContainerMode(), + 0, + 0, // age doesn't matter for LibreOffice cleanup + false, + path -> log.debug("Cleaned up LibreOffice temp file: {}", path)); + log.debug("Cleaned up LibreOffice temp directory contents: {}", dir); + } + } + } catch (IOException e) { + log.warn("Failed to clean up LibreOffice temp files", e); + } + } + + /** + * Clean up PDFBox cache file from user home directory. This cache file can grow large and + * should be periodically cleaned. + */ + private void cleanupPDFBoxCache() { + try { + Path userHome = Path.of(System.getProperty("user.home")); + Path pdfboxCache = userHome.resolve(".pdfbox.cache"); + + if (Files.exists(pdfboxCache)) { + Files.deleteIfExists(pdfboxCache); + log.debug("Cleaned up PDFBox cache file: {}", pdfboxCache); + } + } catch (IOException e) { + log.warn("Failed to clean up PDFBox cache file", e); + } + } +} diff --git a/common/src/main/java/stirling/software/common/util/ApplicationContextProvider.java b/common/src/main/java/stirling/software/common/util/ApplicationContextProvider.java new file mode 100644 index 000000000..505b21fab --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/ApplicationContextProvider.java @@ -0,0 +1,76 @@ +package stirling.software.common.util; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +/** + * Helper class that provides access to the ApplicationContext. Useful for getting beans in classes + * that are not managed by Spring. + */ +@Component +public class ApplicationContextProvider implements ApplicationContextAware { + + private static ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext context) throws BeansException { + applicationContext = context; + } + + /** + * Get a bean by class type. + * + * @param The type of the bean + * @param beanClass The class of the bean + * @return The bean instance, or null if not found + */ + public static T getBean(Class beanClass) { + if (applicationContext == null) { + return null; + } + try { + return applicationContext.getBean(beanClass); + } catch (BeansException e) { + return null; + } + } + + /** + * Get a bean by name and class type. + * + * @param The type of the bean + * @param name The name of the bean + * @param beanClass The class of the bean + * @return The bean instance, or null if not found + */ + public static T getBean(String name, Class beanClass) { + if (applicationContext == null) { + return null; + } + try { + return applicationContext.getBean(name, beanClass); + } catch (BeansException e) { + return null; + } + } + + /** + * Check if a bean of the specified type exists. + * + * @param beanClass The class of the bean + * @return true if the bean exists, false otherwise + */ + public static boolean containsBean(Class beanClass) { + if (applicationContext == null) { + return false; + } + try { + applicationContext.getBean(beanClass); + return true; + } catch (BeansException e) { + return false; + } + } +} diff --git a/common/src/main/java/stirling/software/common/util/EmlToPdf.java b/common/src/main/java/stirling/software/common/util/EmlToPdf.java index 6c0514822..05e9cec5c 100644 --- a/common/src/main/java/stirling/software/common/util/EmlToPdf.java +++ b/common/src/main/java/stirling/software/common/util/EmlToPdf.java @@ -134,7 +134,8 @@ public class EmlToPdf { byte[] emlBytes, String fileName, boolean disableSanitize, - stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory) + stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory, + TempFileManager tempFileManager) throws IOException, InterruptedException { validateEmlInput(emlBytes); @@ -153,7 +154,8 @@ public class EmlToPdf { // Convert HTML to PDF byte[] pdfBytes = - convertHtmlToPdf(weasyprintPath, request, htmlContent, disableSanitize); + convertHtmlToPdf( + weasyprintPath, request, htmlContent, disableSanitize, tempFileManager); // Attach files if available and requested if (shouldAttachFiles(emailContent, request)) { @@ -194,7 +196,8 @@ public class EmlToPdf { String weasyprintPath, EmlToPdfRequest request, String htmlContent, - boolean disableSanitize) + boolean disableSanitize, + TempFileManager tempFileManager) throws IOException, InterruptedException { HTMLToPdfRequest htmlRequest = createHtmlRequest(request); @@ -205,7 +208,8 @@ public class EmlToPdf { htmlRequest, htmlContent.getBytes(StandardCharsets.UTF_8), "email.html", - disableSanitize); + disableSanitize, + tempFileManager); } catch (IOException | InterruptedException e) { log.warn("Initial HTML to PDF conversion failed, trying with simplified HTML"); String simplifiedHtml = simplifyHtmlContent(htmlContent); @@ -214,7 +218,8 @@ public class EmlToPdf { htmlRequest, simplifiedHtml.getBytes(StandardCharsets.UTF_8), "email.html", - disableSanitize); + disableSanitize, + tempFileManager); } } diff --git a/common/src/main/java/stirling/software/common/util/FileToPdf.java b/common/src/main/java/stirling/software/common/util/FileToPdf.java index 8439b67a2..7b3765084 100644 --- a/common/src/main/java/stirling/software/common/util/FileToPdf.java +++ b/common/src/main/java/stirling/software/common/util/FileToPdf.java @@ -26,88 +26,92 @@ public class FileToPdf { HTMLToPdfRequest request, byte[] fileBytes, String fileName, - boolean disableSanitize) + boolean disableSanitize, + TempFileManager tempFileManager) throws IOException, InterruptedException { - Path tempOutputFile = Files.createTempFile("output_", ".pdf"); - Path tempInputFile = null; - byte[] pdfBytes; - try { - if (fileName.endsWith(".html")) { - tempInputFile = Files.createTempFile("input_", ".html"); - String sanitizedHtml = - sanitizeHtmlContent( - new String(fileBytes, StandardCharsets.UTF_8), disableSanitize); - Files.write(tempInputFile, sanitizedHtml.getBytes(StandardCharsets.UTF_8)); - } else if (fileName.endsWith(".zip")) { - tempInputFile = Files.createTempFile("input_", ".zip"); - Files.write(tempInputFile, fileBytes); - sanitizeHtmlFilesInZip(tempInputFile, disableSanitize); - } else { - throw new IllegalArgumentException("Unsupported file format: " + fileName); - } + try (TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) { + try (TempFile tempInputFile = + new TempFile(tempFileManager, fileName.endsWith(".html") ? ".html" : ".zip")) { - List command = new ArrayList<>(); - command.add(weasyprintPath); - command.add("-e"); - command.add("utf-8"); - command.add("-v"); - command.add("--pdf-forms"); - command.add(tempInputFile.toString()); - command.add(tempOutputFile.toString()); + if (fileName.endsWith(".html")) { + String sanitizedHtml = + sanitizeHtmlContent( + new String(fileBytes, StandardCharsets.UTF_8), disableSanitize); + Files.write( + tempInputFile.getPath(), + sanitizedHtml.getBytes(StandardCharsets.UTF_8)); + } else if (fileName.endsWith(".zip")) { + Files.write(tempInputFile.getPath(), fileBytes); + sanitizeHtmlFilesInZip( + tempInputFile.getPath(), disableSanitize, tempFileManager); + } else { + throw new IllegalArgumentException("Unsupported file format: " + fileName); + } - ProcessExecutorResult returnCode = - ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT) - .runCommandWithOutputHandling(command); + List command = new ArrayList<>(); + command.add(weasyprintPath); + command.add("-e"); + command.add("utf-8"); + command.add("-v"); + command.add("--pdf-forms"); + command.add(tempInputFile.getAbsolutePath()); + command.add(tempOutputFile.getAbsolutePath()); - pdfBytes = Files.readAllBytes(tempOutputFile); - } catch (IOException e) { - pdfBytes = Files.readAllBytes(tempOutputFile); - if (pdfBytes.length < 1) { - throw e; - } - } finally { - Files.deleteIfExists(tempOutputFile); - Files.deleteIfExists(tempInputFile); - } + ProcessExecutorResult returnCode = + ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT) + .runCommandWithOutputHandling(command); - return pdfBytes; + byte[] pdfBytes = Files.readAllBytes(tempOutputFile.getPath()); + try { + return pdfBytes; + } catch (Exception e) { + pdfBytes = Files.readAllBytes(tempOutputFile.getPath()); + if (pdfBytes.length < 1) { + throw e; + } + return pdfBytes; + } + } // tempInputFile auto-closed + } // tempOutputFile auto-closed } private static String sanitizeHtmlContent(String htmlContent, boolean disableSanitize) { return (!disableSanitize) ? CustomHtmlSanitizer.sanitize(htmlContent) : htmlContent; } - private static void sanitizeHtmlFilesInZip(Path zipFilePath, boolean disableSanitize) + private static void sanitizeHtmlFilesInZip( + Path zipFilePath, boolean disableSanitize, TempFileManager tempFileManager) throws IOException { - Path tempUnzippedDir = Files.createTempDirectory("unzipped_"); - try (ZipInputStream zipIn = - ZipSecurity.createHardenedInputStream( - new ByteArrayInputStream(Files.readAllBytes(zipFilePath)))) { - ZipEntry entry = zipIn.getNextEntry(); - while (entry != null) { - Path filePath = tempUnzippedDir.resolve(sanitizeZipFilename(entry.getName())); - if (!entry.isDirectory()) { - Files.createDirectories(filePath.getParent()); - if (entry.getName().toLowerCase().endsWith(".html") - || entry.getName().toLowerCase().endsWith(".htm")) { - String content = new String(zipIn.readAllBytes(), StandardCharsets.UTF_8); - String sanitizedContent = sanitizeHtmlContent(content, disableSanitize); - Files.write(filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8)); - } else { - Files.copy(zipIn, filePath); + try (TempDirectory tempUnzippedDir = new TempDirectory(tempFileManager)) { + try (ZipInputStream zipIn = + ZipSecurity.createHardenedInputStream( + new ByteArrayInputStream(Files.readAllBytes(zipFilePath)))) { + ZipEntry entry = zipIn.getNextEntry(); + while (entry != null) { + Path filePath = + tempUnzippedDir.getPath().resolve(sanitizeZipFilename(entry.getName())); + if (!entry.isDirectory()) { + Files.createDirectories(filePath.getParent()); + if (entry.getName().toLowerCase().endsWith(".html") + || entry.getName().toLowerCase().endsWith(".htm")) { + String content = + new String(zipIn.readAllBytes(), StandardCharsets.UTF_8); + String sanitizedContent = sanitizeHtmlContent(content, disableSanitize); + Files.write( + filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8)); + } else { + Files.copy(zipIn, filePath); + } } + zipIn.closeEntry(); + entry = zipIn.getNextEntry(); } - zipIn.closeEntry(); - entry = zipIn.getNextEntry(); } - } - // Repack the sanitized files - zipDirectory(tempUnzippedDir, zipFilePath); - - // Clean up - deleteDirectory(tempUnzippedDir); + // Repack the sanitized files + zipDirectory(tempUnzippedDir.getPath(), zipFilePath); + } // tempUnzippedDir auto-cleaned } private static void zipDirectory(Path sourceDir, Path zipFilePath) throws IOException { diff --git a/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/common/src/main/java/stirling/software/common/util/GeneralUtils.java index 87496294d..ddbec92e0 100644 --- a/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -34,7 +34,27 @@ import stirling.software.common.configuration.InstallationPathConfig; public class GeneralUtils { public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException { - File tempFile = Files.createTempFile("temp", null).toFile(); + String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY"); + if (customTempDir == null || customTempDir.isEmpty()) { + customTempDir = System.getProperty("stirling.tempfiles.directory"); + } + + File tempFile; + + if (customTempDir != null && !customTempDir.isEmpty()) { + Path tempDir = Path.of(customTempDir); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + } + tempFile = Files.createTempFile(tempDir, "stirling-pdf-", null).toFile(); + } else { + Path tempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf"); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + } + tempFile = Files.createTempFile(tempDir, "stirling-pdf-", null).toFile(); + } + try (InputStream inputStream = multipartFile.getInputStream(); FileOutputStream outputStream = new FileOutputStream(tempFile)) { diff --git a/common/src/main/java/stirling/software/common/util/TempDirectory.java b/common/src/main/java/stirling/software/common/util/TempDirectory.java new file mode 100644 index 000000000..cd7036d68 --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/TempDirectory.java @@ -0,0 +1,44 @@ +package stirling.software.common.util; + +import java.io.IOException; +import java.nio.file.Path; + +import lombok.extern.slf4j.Slf4j; + +/** + * A wrapper class for a temporary directory that implements AutoCloseable. Can be used with + * try-with-resources for automatic cleanup. + */ +@Slf4j +public class TempDirectory implements AutoCloseable { + + private final TempFileManager manager; + private final Path directory; + + public TempDirectory(TempFileManager manager) throws IOException { + this.manager = manager; + this.directory = manager.createTempDirectory(); + } + + public Path getPath() { + return directory; + } + + public String getAbsolutePath() { + return directory.toAbsolutePath().toString(); + } + + public boolean exists() { + return java.nio.file.Files.exists(directory); + } + + @Override + public void close() { + manager.deleteTempDirectory(directory); + } + + @Override + public String toString() { + return "TempDirectory{" + directory.toAbsolutePath() + "}"; + } +} diff --git a/common/src/main/java/stirling/software/common/util/TempFile.java b/common/src/main/java/stirling/software/common/util/TempFile.java new file mode 100644 index 000000000..db859c431 --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/TempFile.java @@ -0,0 +1,49 @@ +package stirling.software.common.util; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +import lombok.extern.slf4j.Slf4j; + +/** + * A wrapper class for a temporary file that implements AutoCloseable. Can be used with + * try-with-resources for automatic cleanup. + */ +@Slf4j +public class TempFile implements AutoCloseable { + + private final TempFileManager manager; + private final File file; + + public TempFile(TempFileManager manager, String suffix) throws IOException { + this.manager = manager; + this.file = manager.createTempFile(suffix); + } + + public File getFile() { + return file; + } + + public Path getPath() { + return file.toPath(); + } + + public String getAbsolutePath() { + return file.getAbsolutePath(); + } + + public boolean exists() { + return file.exists(); + } + + @Override + public void close() { + manager.deleteTempFile(file); + } + + @Override + public String toString() { + return "TempFile{" + file.getAbsolutePath() + "}"; + } +} diff --git a/common/src/main/java/stirling/software/common/util/TempFileManager.java b/common/src/main/java/stirling/software/common/util/TempFileManager.java new file mode 100644 index 000000000..867931f8b --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/TempFileManager.java @@ -0,0 +1,249 @@ +package stirling.software.common.util; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Set; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +/** + * Service for managing temporary files in Stirling-PDF. Provides methods for creating, tracking, + * and cleaning up temporary files. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TempFileManager { + + private final TempFileRegistry registry; + private final ApplicationProperties applicationProperties; + + /** + * Create a temporary file with the Stirling-PDF prefix. The file is automatically registered + * with the registry. + * + * @param suffix The suffix for the temporary file + * @return The created temporary file + * @throws IOException If an I/O error occurs + */ + public File createTempFile(String suffix) throws IOException { + ApplicationProperties.TempFileManagement tempFiles = + applicationProperties.getSystem().getTempFileManagement(); + Path tempFilePath; + String customTempDirectory = tempFiles.getBaseTmpDir(); + if (customTempDirectory != null && !customTempDirectory.isEmpty()) { + Path tempDir = Path.of(customTempDirectory); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + } + tempFilePath = Files.createTempFile(tempDir, tempFiles.getPrefix(), suffix); + } else { + tempFilePath = Files.createTempFile(tempFiles.getPrefix(), suffix); + } + File tempFile = tempFilePath.toFile(); + return registry.register(tempFile); + } + + /** + * Create a temporary directory with the Stirling-PDF prefix. The directory is automatically + * registered with the registry. + * + * @return The created temporary directory + * @throws IOException If an I/O error occurs + */ + public Path createTempDirectory() throws IOException { + ApplicationProperties.TempFileManagement tempFiles = + applicationProperties.getSystem().getTempFileManagement(); + Path tempDirPath; + String customTempDirectory = tempFiles.getBaseTmpDir(); + if (customTempDirectory != null && !customTempDirectory.isEmpty()) { + Path tempDir = Path.of(customTempDirectory); + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + } + tempDirPath = Files.createTempDirectory(tempDir, tempFiles.getPrefix()); + } else { + tempDirPath = Files.createTempDirectory(tempFiles.getPrefix()); + } + return registry.registerDirectory(tempDirPath); + } + + /** + * Convert a MultipartFile to a temporary File and register it. This is a wrapper around + * GeneralUtils.convertMultipartFileToFile that ensures the created temp file is registered. + * + * @param multipartFile The MultipartFile to convert + * @return The created temporary file + * @throws IOException If an I/O error occurs + */ + public File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException { + File tempFile = GeneralUtils.convertMultipartFileToFile(multipartFile); + return registry.register(tempFile); + } + + /** + * Delete a temporary file and unregister it from the registry. + * + * @param file The file to delete + * @return true if the file was deleted successfully, false otherwise + */ + public boolean deleteTempFile(File file) { + if (file != null && file.exists()) { + boolean deleted = file.delete(); + if (deleted) { + registry.unregister(file); + log.debug("Deleted temp file: {}", file.getAbsolutePath()); + } else { + log.warn("Failed to delete temp file: {}", file.getAbsolutePath()); + } + return deleted; + } + return false; + } + + /** + * Delete a temporary file and unregister it from the registry. + * + * @param path The path to delete + * @return true if the file was deleted successfully, false otherwise + */ + public boolean deleteTempFile(Path path) { + if (path != null) { + try { + boolean deleted = Files.deleteIfExists(path); + if (deleted) { + registry.unregister(path); + log.debug("Deleted temp file: {}", path.toString()); + } else { + log.debug("Temp file already deleted or does not exist: {}", path.toString()); + } + return deleted; + } catch (IOException e) { + log.warn("Failed to delete temp file: {}", path.toString(), e); + return false; + } + } + return false; + } + + /** + * Delete a temporary directory and all its contents. + * + * @param directory The directory to delete + */ + public void deleteTempDirectory(Path directory) { + if (directory != null && Files.isDirectory(directory)) { + try { + GeneralUtils.deleteDirectory(directory); + log.debug("Deleted temp directory: {}", directory.toString()); + } catch (IOException e) { + log.warn("Failed to delete temp directory: {}", directory.toString(), e); + } + } + } + + /** + * Register an existing file with the registry. + * + * @param file The file to register + * @return The same file for method chaining + */ + public File register(File file) { + if (file != null && file.exists()) { + return registry.register(file); + } + return file; + } + + /** + * Clean up old temporary files based on age. + * + * @param maxAgeMillis Maximum age in milliseconds for temp files + * @return Number of files deleted + */ + public int cleanupOldTempFiles(long maxAgeMillis) { + int deletedCount = 0; + + // Get files older than max age + Set oldFiles = registry.getFilesOlderThan(maxAgeMillis); + + // Delete each old file + for (Path file : oldFiles) { + if (deleteTempFile(file)) { + deletedCount++; + } + } + + log.info("Cleaned up {} old temporary files", deletedCount); + return deletedCount; + } + + /** + * Get the maximum age for temporary files in milliseconds. + * + * @return Maximum age in milliseconds + */ + public long getMaxAgeMillis() { + long maxAgeHours = + applicationProperties.getSystem().getTempFileManagement().getMaxAgeHours(); + return Duration.ofHours(maxAgeHours).toMillis(); + } + + /** + * Generate a unique temporary file name with the Stirling-PDF prefix. + * + * @param type Type identifier for the temp file + * @param extension File extension (without the dot) + * @return A unique temporary file name + */ + public String generateTempFileName(String type, String extension) { + String tempFilePrefix = + applicationProperties.getSystem().getTempFileManagement().getPrefix(); + String uuid = UUID.randomUUID().toString().substring(0, 8); + return tempFilePrefix + type + "-" + uuid + "." + extension; + } + + /** + * Register a known LibreOffice temporary directory. This is used when integrating with + * LibreOffice for file conversions. + * + * @return The LibreOffice temp directory + * @throws IOException If directory creation fails + */ + public Path registerLibreOfficeTempDir() throws IOException { + ApplicationProperties.TempFileManagement tempFiles = + applicationProperties.getSystem().getTempFileManagement(); + Path loTempDir; + String libreOfficeTempDir = tempFiles.getLibreofficeDir(); + String customTempDirectory = tempFiles.getBaseTmpDir(); + + // First check if explicitly configured + if (libreOfficeTempDir != null && !libreOfficeTempDir.isEmpty()) { + loTempDir = Path.of(libreOfficeTempDir); + } + // Next check if we have a custom temp directory + else if (customTempDirectory != null && !customTempDirectory.isEmpty()) { + loTempDir = Path.of(customTempDirectory, "libreoffice"); + } + // Fall back to system temp dir with our application prefix + else { + loTempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf-libreoffice"); + } + + if (!Files.exists(loTempDir)) { + Files.createDirectories(loTempDir); + } + + return registry.registerDirectory(loTempDir); + } +} diff --git a/common/src/main/java/stirling/software/common/util/TempFileRegistry.java b/common/src/main/java/stirling/software/common/util/TempFileRegistry.java new file mode 100644 index 000000000..1e55c6b15 --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/TempFileRegistry.java @@ -0,0 +1,176 @@ +package stirling.software.common.util; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +/** + * Central registry for tracking temporary files created by Stirling-PDF. Maintains a thread-safe + * collection of paths with their creation timestamps. + */ +@Slf4j +@Component +public class TempFileRegistry { + + private final ConcurrentMap registeredFiles = new ConcurrentHashMap<>(); + private final Set thirdPartyTempFiles = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + private final Set tempDirectories = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + + /** + * Register a temporary file with the registry. + * + * @param file The temporary file to track + * @return The same file for method chaining + */ + public File register(File file) { + if (file != null) { + registeredFiles.put(file.toPath(), Instant.now()); + log.debug("Registered temp file: {}", file.getAbsolutePath()); + } + return file; + } + + /** + * Register a temporary path with the registry. + * + * @param path The temporary path to track + * @return The same path for method chaining + */ + public Path register(Path path) { + if (path != null) { + registeredFiles.put(path, Instant.now()); + log.debug("Registered temp path: {}", path.toString()); + } + return path; + } + + /** + * Register a temporary directory to be cleaned up. + * + * @param directory Directory to register + * @return The same directory for method chaining + */ + public Path registerDirectory(Path directory) { + if (directory != null && Files.isDirectory(directory)) { + tempDirectories.add(directory); + log.debug("Registered temp directory: {}", directory.toString()); + } + return directory; + } + + /** + * Register a third-party temporary file that requires special handling. + * + * @param file The third-party temp file + * @return The same file for method chaining + */ + public File registerThirdParty(File file) { + if (file != null) { + thirdPartyTempFiles.add(file.toPath()); + log.debug("Registered third-party temp file: {}", file.getAbsolutePath()); + } + return file; + } + + /** + * Unregister a file from the registry. + * + * @param file The file to unregister + */ + public void unregister(File file) { + if (file != null) { + registeredFiles.remove(file.toPath()); + thirdPartyTempFiles.remove(file.toPath()); + log.debug("Unregistered temp file: {}", file.getAbsolutePath()); + } + } + + /** + * Unregister a path from the registry. + * + * @param path The path to unregister + */ + public void unregister(Path path) { + if (path != null) { + registeredFiles.remove(path); + thirdPartyTempFiles.remove(path); + log.debug("Unregistered temp path: {}", path.toString()); + } + } + + /** + * Get all registered temporary files. + * + * @return Set of registered file paths + */ + public Set getAllRegisteredFiles() { + return registeredFiles.keySet(); + } + + /** + * Get temporary files older than the specified duration in milliseconds. + * + * @param maxAgeMillis Maximum age in milliseconds + * @return Set of paths older than the specified age + */ + public Set getFilesOlderThan(long maxAgeMillis) { + Instant cutoffTime = Instant.now().minusMillis(maxAgeMillis); + return registeredFiles.entrySet().stream() + .filter(entry -> entry.getValue().isBefore(cutoffTime)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + /** + * Get all registered third-party temporary files. + * + * @return Set of third-party file paths + */ + public Set getThirdPartyTempFiles() { + return thirdPartyTempFiles; + } + + /** + * Get all registered temporary directories. + * + * @return Set of temporary directory paths + */ + public Set getTempDirectories() { + return tempDirectories; + } + + /** + * Check if a file is registered in the registry. + * + * @param file The file to check + * @return True if the file is registered, false otherwise + */ + public boolean contains(File file) { + if (file == null) { + return false; + } + Path path = file.toPath(); + return registeredFiles.containsKey(path) || thirdPartyTempFiles.contains(path); + } + + /** Clear all registry data. */ + public void clear() { + registeredFiles.clear(); + thirdPartyTempFiles.clear(); + tempDirectories.clear(); + } +} diff --git a/common/src/main/java/stirling/software/common/util/TempFileUtil.java b/common/src/main/java/stirling/software/common/util/TempFileUtil.java new file mode 100644 index 000000000..2588b9ebb --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/TempFileUtil.java @@ -0,0 +1,135 @@ +package stirling.software.common.util; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import lombok.extern.slf4j.Slf4j; + +/** + * Utility class for handling temporary files with proper cleanup. Provides helper methods and + * wrappers to ensure temp files are properly cleaned up. + */ +@Slf4j +public class TempFileUtil { + + /** + * A collection of temporary files that implements AutoCloseable. All files in the collection + * are cleaned up when close() is called. + */ + public static class TempFileCollection implements AutoCloseable { + private final TempFileManager manager; + private final List tempFiles = new ArrayList<>(); + + public TempFileCollection(TempFileManager manager) { + this.manager = manager; + } + + public File addTempFile(String suffix) throws IOException { + File file = manager.createTempFile(suffix); + tempFiles.add(file); + return file; + } + + public List getFiles() { + return new ArrayList<>(tempFiles); + } + + @Override + public void close() { + for (File file : tempFiles) { + manager.deleteTempFile(file); + } + } + } + + /** + * Execute a function with a temporary file, ensuring cleanup in a finally block. + * + * @param The return type of the function + * @param tempFileManager The temp file manager + * @param suffix File suffix (e.g., ".pdf") + * @param function The function to execute with the temp file + * @return The result of the function + * @throws IOException If an I/O error occurs + */ + public static R withTempFile( + TempFileManager tempFileManager, String suffix, Function function) + throws IOException { + File tempFile = tempFileManager.createTempFile(suffix); + try { + return function.apply(tempFile); + } finally { + tempFileManager.deleteTempFile(tempFile); + } + } + + /** + * Execute a function with multiple temporary files, ensuring cleanup in a finally block. + * + * @param The return type of the function + * @param tempFileManager The temp file manager + * @param count Number of temp files to create + * @param suffix File suffix (e.g., ".pdf") + * @param function The function to execute with the temp files + * @return The result of the function + * @throws IOException If an I/O error occurs + */ + public static R withMultipleTempFiles( + TempFileManager tempFileManager, + int count, + String suffix, + Function, R> function) + throws IOException { + List tempFiles = new ArrayList<>(count); + try { + for (int i = 0; i < count; i++) { + tempFiles.add(tempFileManager.createTempFile(suffix)); + } + return function.apply(tempFiles); + } finally { + for (File file : tempFiles) { + tempFileManager.deleteTempFile(file); + } + } + } + + /** + * Safely delete a list of temporary files, logging any errors. + * + * @param files The list of files to delete + */ + public static void safeDeleteFiles(List files) { + if (files == null) return; + + for (Path file : files) { + if (file == null) continue; + + try { + Files.deleteIfExists(file); + log.debug("Deleted temp file: {}", file); + } catch (IOException e) { + log.warn("Failed to delete temp file: {}", file, e); + } + } + } + + /** + * Register an already created temp file with the registry. Use this for files created outside + * of TempFileManager. + * + * @param tempFileManager The temp file manager + * @param file The file to register + * @return The registered file + */ + public static File registerExistingTempFile(TempFileManager tempFileManager, File file) { + if (tempFileManager != null && file != null && file.exists()) { + return tempFileManager.register(file); + } + return file; + } +} diff --git a/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java b/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java new file mode 100644 index 000000000..009c00860 --- /dev/null +++ b/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java @@ -0,0 +1,464 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** + * Tests for the TempFileCleanupService, focusing on its pattern-matching and cleanup logic. + */ +public class TempFileCleanupServiceTest { + + @TempDir + Path tempDir; + + @Mock + private TempFileRegistry registry; + + @Mock + private TempFileManager tempFileManager; + + @Mock + private ApplicationProperties applicationProperties; + + @Mock + private ApplicationProperties.System system; + + @Mock + private ApplicationProperties.TempFileManagement tempFileManagement; + + @InjectMocks + private TempFileCleanupService cleanupService; + + private Path systemTempDir; + private Path customTempDir; + private Path libreOfficeTempDir; + + @BeforeEach + public void setup() throws IOException { + MockitoAnnotations.openMocks(this); + + // Create test directories + systemTempDir = tempDir.resolve("systemTemp"); + customTempDir = tempDir.resolve("customTemp"); + libreOfficeTempDir = tempDir.resolve("libreOfficeTemp"); + + Files.createDirectories(systemTempDir); + Files.createDirectories(customTempDir); + Files.createDirectories(libreOfficeTempDir); + + // Configure ApplicationProperties mocks + when(applicationProperties.getSystem()).thenReturn(system); + when(system.getTempFileManagement()).thenReturn(tempFileManagement); + when(tempFileManagement.getBaseTmpDir()).thenReturn(customTempDir.toString()); + when(tempFileManagement.getLibreofficeDir()).thenReturn(libreOfficeTempDir.toString()); + when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString()); + when(tempFileManagement.isStartupCleanup()).thenReturn(false); + when(tempFileManagement.isCleanupSystemTemp()).thenReturn(false); + when(tempFileManagement.getCleanupIntervalMinutes()).thenReturn(30L); + + // Set machineType using reflection (still needed for this field) + ReflectionTestUtils.setField(cleanupService, "machineType", "Standard"); + + when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L); // 1 hour + } + + @Test + public void testScheduledCleanup_RegisteredFiles() { + // Arrange + when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(5); // 5 files deleted + Set registeredDirs = new HashSet<>(); + registeredDirs.add(tempDir.resolve("registeredDir")); + when(registry.getTempDirectories()).thenReturn(registeredDirs); + + // Act + cleanupService.scheduledCleanup(); + + // Assert + verify(tempFileManager).cleanupOldTempFiles(anyLong()); + verify(registry, times(1)).getTempDirectories(); + } + + @Test + public void testCleanupTempFilePatterns() throws IOException { + // Arrange - Create various temp files + Path ourTempFile1 = Files.createFile(systemTempDir.resolve("output_123.pdf")); + Path ourTempFile2 = Files.createFile(systemTempDir.resolve("compressedPDF456.pdf")); + Path ourTempFile3 = Files.createFile(customTempDir.resolve("stirling-pdf-789.tmp")); + Path ourTempFile4 = Files.createFile(customTempDir.resolve("pdf-save-123-456.tmp")); + Path ourTempFile5 = Files.createFile(libreOfficeTempDir.resolve("input_file.pdf")); + + // Old temporary files + Path oldTempFile = Files.createFile(systemTempDir.resolve("output_old.pdf")); + + // System temp files that should be cleaned in container mode + Path sysTempFile1 = Files.createFile(systemTempDir.resolve("lu123abc.tmp")); + Path sysTempFile2 = Files.createFile(customTempDir.resolve("ocr_process123")); + Path sysTempFile3 = Files.createFile(customTempDir.resolve("tmp_upload.tmp")); + + // Files that should be preserved + Path jettyFile1 = Files.createFile(systemTempDir.resolve("jetty-123.tmp")); + Path jettyFile2 = Files.createFile(systemTempDir.resolve("something-with-jetty-inside.tmp")); + Path regularFile = Files.createFile(systemTempDir.resolve("important.txt")); + + // Create a nested directory with temp files + Path nestedDir = Files.createDirectories(systemTempDir.resolve("nested")); + Path nestedTempFile = Files.createFile(nestedDir.resolve("output_nested.pdf")); + + // Empty file (special case) + Path emptyFile = Files.createFile(systemTempDir.resolve("empty.tmp")); + + // Configure mock registry to say these files aren't registered + when(registry.contains(any(File.class))).thenReturn(false); + + // The set of files that will be deleted in our test + Set deletedFiles = new HashSet<>(); + + // Use MockedStatic to mock Files operations + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + // Mock Files.list for each directory we'll process + mockedFiles.when(() -> Files.list(eq(systemTempDir))) + .thenReturn(Stream.of( + ourTempFile1, ourTempFile2, oldTempFile, sysTempFile1, + jettyFile1, jettyFile2, regularFile, emptyFile, nestedDir)); + + mockedFiles.when(() -> Files.list(eq(customTempDir))) + .thenReturn(Stream.of(ourTempFile3, ourTempFile4, sysTempFile2, sysTempFile3)); + + mockedFiles.when(() -> Files.list(eq(libreOfficeTempDir))) + .thenReturn(Stream.of(ourTempFile5)); + + mockedFiles.when(() -> Files.list(eq(nestedDir))) + .thenReturn(Stream.of(nestedTempFile)); + + // Configure Files.isDirectory for each path + mockedFiles.when(() -> Files.isDirectory(eq(nestedDir))).thenReturn(true); + mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); + + // Configure Files.exists to return true for all paths + mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true); + + // Configure Files.getLastModifiedTime to return different times based on file names + mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class))) + .thenAnswer(invocation -> { + Path path = invocation.getArgument(0); + String fileName = path.getFileName().toString(); + + // For files with "old" in the name, return a timestamp older than maxAgeMillis + if (fileName.contains("old")) { + return FileTime.fromMillis(System.currentTimeMillis() - 5000000); + } + // For empty.tmp file, return a timestamp older than 5 minutes (for empty file test) + else if (fileName.equals("empty.tmp")) { + return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000); + } + // For all other files, return a recent timestamp + else { + return FileTime.fromMillis(System.currentTimeMillis() - 60000); // 1 minute ago + } + }); + + // Configure Files.size to return different sizes based on file names + mockedFiles.when(() -> Files.size(any(Path.class))) + .thenAnswer(invocation -> { + Path path = invocation.getArgument(0); + String fileName = path.getFileName().toString(); + + // Return 0 bytes for the empty file + if (fileName.equals("empty.tmp")) { + return 0L; + } + // Return normal size for all other files + else { + return 1024L; // 1 KB + } + }); + + // For deleteIfExists, track which files would be deleted + mockedFiles.when(() -> Files.deleteIfExists(any(Path.class))) + .thenAnswer(invocation -> { + Path path = invocation.getArgument(0); + deletedFiles.add(path); + return true; + }); + + // Act - set containerMode to false for this test + invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000); + invokeCleanupDirectoryStreaming(customTempDir, false, 0, 3600000); + invokeCleanupDirectoryStreaming(libreOfficeTempDir, false, 0, 3600000); + + // Assert - Only old temp files and empty files should be deleted + assertTrue(deletedFiles.contains(oldTempFile), "Old temp file should be deleted"); + assertTrue(deletedFiles.contains(emptyFile), "Empty file should be deleted"); + + // Regular temp files should not be deleted because they're too new + assertFalse(deletedFiles.contains(ourTempFile1), "Recent temp file should be preserved"); + assertFalse(deletedFiles.contains(ourTempFile2), "Recent temp file should be preserved"); + assertFalse(deletedFiles.contains(ourTempFile3), "Recent temp file should be preserved"); + assertFalse(deletedFiles.contains(ourTempFile4), "Recent temp file should be preserved"); + assertFalse(deletedFiles.contains(ourTempFile5), "Recent temp file should be preserved"); + + // System temp files should not be deleted in non-container mode + assertFalse(deletedFiles.contains(sysTempFile1), "System temp file should be preserved in non-container mode"); + assertFalse(deletedFiles.contains(sysTempFile2), "System temp file should be preserved in non-container mode"); + assertFalse(deletedFiles.contains(sysTempFile3), "System temp file should be preserved in non-container mode"); + + // Jetty files and regular files should never be deleted + assertFalse(deletedFiles.contains(jettyFile1), "Jetty file should be preserved"); + assertFalse(deletedFiles.contains(jettyFile2), "File with jetty in name should be preserved"); + assertFalse(deletedFiles.contains(regularFile), "Regular file should be preserved"); + } + } + + @Test + public void testContainerModeCleanup() throws IOException { + // Arrange - Create various temp files + Path ourTempFile = Files.createFile(systemTempDir.resolve("output_123.pdf")); + Path sysTempFile = Files.createFile(systemTempDir.resolve("lu123abc.tmp")); + Path regularFile = Files.createFile(systemTempDir.resolve("important.txt")); + + // Configure mock registry to say these files aren't registered + when(registry.contains(any(File.class))).thenReturn(false); + + // The set of files that will be deleted in our test + Set deletedFiles = new HashSet<>(); + + // Use MockedStatic to mock Files operations + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + // Mock Files.list for systemTempDir + mockedFiles.when(() -> Files.list(eq(systemTempDir))) + .thenReturn(Stream.of(ourTempFile, sysTempFile, regularFile)); + + // Configure Files.isDirectory + mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); + + // Configure Files.exists + mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true); + + // Configure Files.getLastModifiedTime to return recent timestamps + mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class))) + .thenReturn(FileTime.fromMillis(System.currentTimeMillis() - 60000)); // 1 minute ago + + // Configure Files.size to return normal size + mockedFiles.when(() -> Files.size(any(Path.class))) + .thenReturn(1024L); // 1 KB + + // For deleteIfExists, track which files would be deleted + mockedFiles.when(() -> Files.deleteIfExists(any(Path.class))) + .thenAnswer(invocation -> { + Path path = invocation.getArgument(0); + deletedFiles.add(path); + return true; + }); + + // Act - set containerMode to true and maxAgeMillis to 0 for container startup cleanup + invokeCleanupDirectoryStreaming(systemTempDir, true, 0, 0); + + // Assert - In container mode, both our temp files and system temp files should be deleted + // regardless of age (when maxAgeMillis is 0) + assertTrue(deletedFiles.contains(ourTempFile), "Our temp file should be deleted in container mode"); + assertTrue(deletedFiles.contains(sysTempFile), "System temp file should be deleted in container mode"); + assertFalse(deletedFiles.contains(regularFile), "Regular file should be preserved"); + } + } + + @Test + public void testEmptyFileHandling() throws IOException { + // Arrange - Create an empty file + Path emptyFile = Files.createFile(systemTempDir.resolve("empty.tmp")); + Path recentEmptyFile = Files.createFile(systemTempDir.resolve("recent_empty.tmp")); + + // Configure mock registry to say these files aren't registered + when(registry.contains(any(File.class))).thenReturn(false); + + // The set of files that will be deleted in our test + Set deletedFiles = new HashSet<>(); + + // Use MockedStatic to mock Files operations + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + // Mock Files.list for systemTempDir + mockedFiles.when(() -> Files.list(eq(systemTempDir))) + .thenReturn(Stream.of(emptyFile, recentEmptyFile)); + + // Configure Files.isDirectory + mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); + + // Configure Files.exists + mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true); + + // Configure Files.getLastModifiedTime to return different times based on file names + mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class))) + .thenAnswer(invocation -> { + Path path = invocation.getArgument(0); + String fileName = path.getFileName().toString(); + + if (fileName.equals("empty.tmp")) { + // More than 5 minutes old + return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000); + } else { + // Less than 5 minutes old + return FileTime.fromMillis(System.currentTimeMillis() - 2 * 60 * 1000); + } + }); + + // Configure Files.size to return 0 for empty files + mockedFiles.when(() -> Files.size(any(Path.class))) + .thenReturn(0L); + + // For deleteIfExists, track which files would be deleted + mockedFiles.when(() -> Files.deleteIfExists(any(Path.class))) + .thenAnswer(invocation -> { + Path path = invocation.getArgument(0); + deletedFiles.add(path); + return true; + }); + + // Act + invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000); + + // Assert + assertTrue(deletedFiles.contains(emptyFile), + "Empty file older than 5 minutes should be deleted"); + assertFalse(deletedFiles.contains(recentEmptyFile), + "Empty file newer than 5 minutes should not be deleted"); + } + } + + @Test + public void testRecursiveDirectoryCleaning() throws IOException { + // Arrange - Create a nested directory structure with temp files + Path dir1 = Files.createDirectories(systemTempDir.resolve("dir1")); + Path dir2 = Files.createDirectories(dir1.resolve("dir2")); + Path dir3 = Files.createDirectories(dir2.resolve("dir3")); + + Path tempFile1 = Files.createFile(dir1.resolve("output_1.pdf")); + Path tempFile2 = Files.createFile(dir2.resolve("output_2.pdf")); + Path tempFile3 = Files.createFile(dir3.resolve("output_old_3.pdf")); + + // Configure mock registry to say these files aren't registered + when(registry.contains(any(File.class))).thenReturn(false); + + // The set of files that will be deleted in our test + Set deletedFiles = new HashSet<>(); + + // Use MockedStatic to mock Files operations + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + // Mock Files.list for each directory + mockedFiles.when(() -> Files.list(eq(systemTempDir))) + .thenReturn(Stream.of(dir1)); + + mockedFiles.when(() -> Files.list(eq(dir1))) + .thenReturn(Stream.of(tempFile1, dir2)); + + mockedFiles.when(() -> Files.list(eq(dir2))) + .thenReturn(Stream.of(tempFile2, dir3)); + + mockedFiles.when(() -> Files.list(eq(dir3))) + .thenReturn(Stream.of(tempFile3)); + + // Configure Files.isDirectory for each path + mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true); + mockedFiles.when(() -> Files.isDirectory(eq(dir2))).thenReturn(true); + mockedFiles.when(() -> Files.isDirectory(eq(dir3))).thenReturn(true); + mockedFiles.when(() -> Files.isDirectory(eq(tempFile1))).thenReturn(false); + mockedFiles.when(() -> Files.isDirectory(eq(tempFile2))).thenReturn(false); + mockedFiles.when(() -> Files.isDirectory(eq(tempFile3))).thenReturn(false); + + // Configure Files.exists to return true for all paths + mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true); + + // Configure Files.getLastModifiedTime to return different times based on file names + mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class))) + .thenAnswer(invocation -> { + Path path = invocation.getArgument(0); + String fileName = path.getFileName().toString(); + + if (fileName.contains("old")) { + // Old file + return FileTime.fromMillis(System.currentTimeMillis() - 5000000); + } else { + // Recent file + return FileTime.fromMillis(System.currentTimeMillis() - 60000); + } + }); + + // Configure Files.size to return normal size + mockedFiles.when(() -> Files.size(any(Path.class))) + .thenReturn(1024L); + + // For deleteIfExists, track which files would be deleted + mockedFiles.when(() -> Files.deleteIfExists(any(Path.class))) + .thenAnswer(invocation -> { + Path path = invocation.getArgument(0); + deletedFiles.add(path); + return true; + }); + + // Act + invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000); + + // Debug - print what was deleted + System.out.println("Deleted files: " + deletedFiles); + System.out.println("Looking for: " + tempFile3); + + // Assert + assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved"); + assertFalse(deletedFiles.contains(tempFile2), "Recent temp file should be preserved"); + assertTrue(deletedFiles.contains(tempFile3), "Old temp file in nested directory should be deleted"); + } + } + + /** + * Helper method to invoke the private cleanupDirectoryStreaming method using reflection + */ + private void invokeCleanupDirectoryStreaming(Path directory, boolean containerMode, int depth, long maxAgeMillis) + throws IOException { + try { + // Create a consumer that tracks deleted files + AtomicInteger deleteCount = new AtomicInteger(0); + Consumer deleteCallback = path -> deleteCount.incrementAndGet(); + + // Get the method with updated signature + var method = TempFileCleanupService.class.getDeclaredMethod( + "cleanupDirectoryStreaming", + Path.class, boolean.class, int.class, long.class, boolean.class, Consumer.class); + method.setAccessible(true); + + // Invoke the method with appropriate parameters + method.invoke(cleanupService, directory, containerMode, depth, maxAgeMillis, false, deleteCallback); + } catch (Exception e) { + throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e); + } + } + + // Matcher for exact path equality + private static Path eq(Path path) { + return argThat(arg -> arg != null && arg.equals(path)); + } +} \ No newline at end of file diff --git a/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java b/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java new file mode 100644 index 000000000..db7b0db7e --- /dev/null +++ b/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java @@ -0,0 +1,893 @@ +package stirling.software.common.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.api.converters.EmlToPdfRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; + +@DisplayName("EML to PDF Conversion tests") +class EmlToPdfTest { + + // Focus on testing EML to HTML conversion functionality since the PDF conversion relies on WeasyPrint + // But HTML to PDF conversion is also briefly tested at PdfConversionTests class. + private void testEmailConversion(String emlContent, String[] expectedContent, boolean includeAttachments) throws IOException { + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = includeAttachments ? createRequestWithAttachments() : createBasicRequest(); + + String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request); + + assertNotNull(htmlResult); + for (String expected : expectedContent) { + assertTrue(htmlResult.contains(expected), "HTML should contain: " + expected); + } + } + + @Nested + @DisplayName("Core EML Parsing") + class CoreParsingTests { + @Test + @DisplayName("Should parse simple text email correctly") + void parseSimpleTextEmail() throws IOException { + String emlContent = createSimpleTextEmail( + "sender@example.com", + "recipient@example.com", + "Simple Test Subject", + "This is a simple plain text email body."); + + testEmailConversion(emlContent, new String[] { + "Simple Test Subject", + "sender@example.com", + "recipient@example.com", + "This is a simple plain text email body", + "" + }, false); + } + + @Test + @DisplayName("Should parse email with missing Subject and To headers") + void parseEmailWithMissingHeaders() throws IOException { + String emlContent = createEmailWithCustomHeaders(); + + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createBasicRequest(); + + String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request); + + assertNotNull(htmlResult); + assertTrue(htmlResult.contains("sender@example.com")); + assertTrue(htmlResult.contains("This is an email body")); + assertTrue(htmlResult.contains("") || + htmlResult.contains("No Subject")); + } + + @Test + @DisplayName("Should parse HTML email with styling") + void parseHtmlEmailWithStyling() throws IOException { + String htmlBody = "" + + "

Important Notice
" + + "
This is HTML content with styling.
" + + "
Best regards
"; + + String emlContent = createHtmlEmail( + "html@example.com", "user@example.com", "HTML Email Test", htmlBody); + + testEmailConversion(emlContent, new String[] { + "HTML Email Test", + "Important Notice", + "HTML content", + "font-weight: bold" + }, false); + } + + @Test + @DisplayName("Should parse multipart email with attachments") + void parseMultipartEmailWithAttachments() throws IOException { + String boundary = "----=_Part_" + getTimestamp(); + String emlContent = createMultipartEmailWithAttachment( + "multipart@example.com", + "user@example.com", + "Multipart Email Test", + "This email has both text content and an attachment.", + boundary, + "document.txt", + "Sample attachment content"); + + testEmailConversion(emlContent, new String[] { + "Multipart Email Test", + "This email has both text content" + }, true); + } + } + + @Nested + @DisplayName("Email Encoding Support") + class EncodingTests { + + @Test + @DisplayName("Should handle international characters and UTF-8") + void handleInternationalCharacters() throws IOException { + String bodyWithIntlChars = "Hello! 你好 Привет مرحبا Hëllö Thañks! Önë Mörë"; + String emlContent = createSimpleTextEmail( + "intl@example.com", + "user@example.com", + "International Characters Test", + bodyWithIntlChars); + + testEmailConversion(emlContent, new String[] { + "你好", "Привет", "مرحبا", "Hëllö", "Önë", "Mörë" + }, false); + } + + @Test + @DisplayName("Should decode quoted-printable content correctly") + void decodeQuotedPrintableContent() throws IOException { + String content = createQuotedPrintableEmail( + ); + + testEmailConversion(content, new String[] { + "Quoted-Printable Test", + "This is quoted printable content with special chars: éàè." + }, false); + } + + @Test + @DisplayName("Should decode Base64 content") + void decodeBase64Content() throws IOException { + String originalText = "This is Base64 encoded content: éàü ñ"; + String content = createBase64Email( + originalText); + + testEmailConversion(content, new String[] { + "Base64 Test", "Base64 encoded content" + }, false); + } + + @Test + @DisplayName("Should correctly handle inline images with CID references") + void handleInlineImages() throws IOException { + String boundary = "----=_Part_CID_1234567890"; + String cid = "image123@example.com"; + String htmlBody = "

Here is an image:

"; + String imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + + String emlContent = createEmailWithInlineImage(htmlBody, boundary, cid, imageBase64); + + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createRequestWithAttachments(); + + String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request); + + assertNotNull(htmlResult); + assertTrue(htmlResult.contains("data:image/png;base64," + imageBase64)); + assertFalse(htmlResult.contains("cid:" + cid)); + } + } + + @Nested + @DisplayName("HTML Output Quality") + class HtmlOutputTests { + + @Test + @DisplayName("Should generate valid HTML structure") + void generateValidHtmlStructure() throws IOException { + String emlContent = createSimpleTextEmail( + "structure@test.com", + "user@test.com", + "HTML Structure Test", + "Testing HTML structure output"); + + testEmailConversion(emlContent, new String[] { + "", "", "HTML Structure Test" + }, false); + + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, createBasicRequest()); + assertTrue(htmlResult.length() > 100, "HTML should have substantial content"); + } + + @Test + @DisplayName("Should preserve safe CSS and remove problematic styles") + void handleCssStylesCorrectly() throws IOException { + String styledHtml = "" + + "
Safe styling
" + + "
Problematic styling
" + + "
Good styling
" + + ""; + + String emlContent = createHtmlEmail("css@test.com", "user@test.com", "CSS Test", styledHtml); + + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createBasicRequest(); + + String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request); + + assertNotNull(htmlResult); + assertTrue(htmlResult.contains("color: blue")); + assertTrue(htmlResult.contains("font-size: 14px")); + assertTrue(htmlResult.contains("margin: 10px")); + + assertFalse(htmlResult.contains("position: fixed")); + } + + @Test + @DisplayName("Should handle complex nested HTML structures") + void handleComplexNestedHtml() throws IOException { + String complexHtml = "Complex Email" + + "

Email Header

" + + "

Paragraph with link

    " + + "
  • List item 1
  • List item 2 with emphasis
" + + "" + + "
Cell 1Cell 2
Cell 3Cell 4
"; + + String emlContent = createHtmlEmail( + "complex@test.com", "user@test.com", "Complex HTML Test", complexHtml); + + testEmailConversion(emlContent, new String[] { + "Email Header", "List item 2", "Cell 3", "example.com" + }, false); + + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, createBasicRequest()); + assertTrue(htmlResult.length() > 300, "HTML should have substantial content"); + } + } + + @Nested + @DisplayName("Error Handling & Edge Cases") + class ErrorHandlingTests { + + @Test + @DisplayName("Should reject null input") + void rejectNullInput() { + EmlToPdfRequest request = createBasicRequest(); + + Exception exception = assertThrows( + IllegalArgumentException.class, + () -> EmlToPdf.convertEmlToHtml(null, request)); + assertTrue(exception.getMessage().contains("EML file is empty or null")); + } + + @Test + @DisplayName("Should reject empty input") + void rejectEmptyInput() { + EmlToPdfRequest request = createBasicRequest(); + + Exception exception = assertThrows( + IllegalArgumentException.class, + () -> EmlToPdf.convertEmlToHtml(new byte[0], request)); + assertTrue(exception.getMessage().contains("EML file is empty or null")); + } + + @Test + @DisplayName("Should handle malformed EML gracefully") + void handleMalformedEmlGracefully() { + String malformedEml = """ + From: sender@test.com + Subject: Malformed EML + This line breaks header format + Content-Type: text/plain + + Body content"""; + + byte[] emlBytes = malformedEml.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createBasicRequest(); + + try { + String result = EmlToPdf.convertEmlToHtml(emlBytes, request); + assertNotNull(result, "Result should not be null"); + assertFalse(result.isEmpty(), "Result should not be empty"); + } catch (Exception e) { + assertNotNull(e.getMessage(), "Exception message should not be null"); + assertFalse(e.getMessage().isEmpty(), "Exception message should not be empty"); + } + } + + @Test + @DisplayName("Should reject invalid EML format") + void rejectInvalidEmlFormat() { + byte[] invalidEml = "This is definitely not an EML file".getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createBasicRequest(); + + Exception exception = assertThrows( + IllegalArgumentException.class, + () -> EmlToPdf.convertEmlToHtml(invalidEml, request)); + assertTrue(exception.getMessage().contains("Invalid EML file format")); + } + } + + @Nested + @DisplayName("Advanced Parsing Tests (Jakarta Mail)") + class AdvancedParsingTests { + + @Test + @DisplayName("Should successfully parse email using advanced parser") + void initializeDependencyMailSession() { + assertDoesNotThrow(() -> { + String emlContent = createSimpleTextEmail( + "Dependency@test.com", + "user@test.com", + "Dependency Mail Test", + "Testing Dependency Mail integration."); + + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createBasicRequest(); + + String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request); + assertNotNull(htmlResult); + assertTrue(htmlResult.contains("Dependency Mail Test")); + }); + } + + @Test + @DisplayName("Should parse complex MIME structures and select HTML part") + void parseComplexMimeStructures() throws IOException { + String boundary = "----=_Advanced_1234567890"; + String textBody = "This is the plain text part."; + String htmlBody = "

This is the HTML part

"; + String emlContent = createMultipartAlternativeEmail(textBody, htmlBody, boundary); + + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createRequestWithAttachments(); + + String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request); + + assertNotNull(htmlResult); + assertTrue(htmlResult.contains("This is the HTML part")); + assertFalse(htmlResult.contains("This is the plain text part")); + } + } + + @Nested + @DisplayName("Additional Correctness Tests") + class AdditionalCorrectnessTests { + + @Test + @DisplayName("Should handle email with only an attachment and no body") + void handleAttachmentOnlyEmail() throws IOException { + String boundary = "----=_Part_AttachmentOnly_1234567890"; + String emlContent = createMultipartEmailWithAttachment( + "sender@example.com", + "recipient@example.com", + "Attachment Only Test", + "", + boundary, + "data.bin", + "binary data"); + + testEmailConversion(emlContent, new String[] { + "Attachment Only Test", "data.bin", "No content available" + }, true); + } + + @Test + @DisplayName("Should handle mixed inline and regular attachments") + void handleMixedAttachments() throws IOException { + String boundary = "----=_Part_MixedAttachments_1234567890"; + String cid = "inline_image@example.com"; + String htmlBody = ""; + String imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR42mNkAAIAAAoAAb6A/yoAAAAASUVORK5CYII="; + String attachmentText = "This is a text attachment."; + + String emlContent = createEmailWithMixedAttachments( + htmlBody, boundary, cid, imageBase64, attachmentText); + + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createRequestWithAttachments(); + + String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request); + + assertNotNull(htmlResult); + assertTrue(htmlResult.contains("data:image/png;base64," + imageBase64)); + assertTrue(htmlResult.contains("text.txt")); + } + + @Test + @DisplayName("Should handle non-standard but valid character sets like ISO-8859-1") + void handleIso88591Charset() throws IOException { + String subject = "Subject with special characters: ñ é ü"; + String body = "Body with special characters: ñ é ü"; + + String emlContent = createSimpleTextEmailWithCharset( + "sender@example.com", + "recipient@example.com", + subject, + body, + "ISO-8859-1"); + + byte[] emlBytes = emlContent.getBytes(StandardCharsets.ISO_8859_1); + EmlToPdfRequest request = createBasicRequest(); + + String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request); + + assertNotNull(htmlResult); + assertTrue(htmlResult.contains(subject)); + assertTrue(htmlResult.contains(body)); + } + + @Test + @DisplayName("Should handle emails with extremely long lines") + void handleLongLines() throws IOException { + StringBuilder longLine = new StringBuilder("This is a very long line: "); + for (int i = 0; i < 1000; i++) { + longLine.append("word").append(i).append(" "); + } + + String emlContent = createSimpleTextEmail( + "sender@example.com", + "recipient@example.com", + "Long Line Test", + longLine.toString()); + + testEmailConversion(emlContent, new String[] { + "Long Line Test", "This is a very long line", "word999" + }, false); + } + + @Test + @DisplayName("Should handle .eml files as attachments") + void handleEmlAttachment() throws IOException { + String boundary = "----=_Part_EmlAttachment_1234567890"; + String innerEmlContent = createSimpleTextEmail( + "inner@example.com", + "inner_recipient@example.com", + "Inner Email Subject", + "This is the body of the attached email."); + + String emlContent = createEmailWithEmlAttachment( + boundary, + innerEmlContent); + + testEmailConversion(emlContent, new String[] { + "Fwd: Inner Email Subject", + "Please see the attached email.", + "attached_email.eml" + }, true); + } + + @ParameterizedTest + @ValueSource(strings = {"windows-1252", "ISO-8859-2", "KOI8-R", "Shift_JIS"}) + @DisplayName("Should handle various character encodings") + void handleVariousEncodings(String charset) throws IOException { + String subject = "Encoding Test"; + String body = "Testing " + charset + " encoding"; + + String emlContent = createSimpleTextEmailWithCharset( + "sender@example.com", + "recipient@example.com", + subject, + body, + charset); + + testEmailConversion(emlContent, new String[] {subject, body}, false); + } + } + + @Nested + @ExtendWith(MockitoExtension.class) + @DisplayName("PDF Conversion Tests") + class PdfConversionTests { + + @Mock private CustomPDFDocumentFactory mockPdfDocumentFactory; + + @Mock private PDDocument mockPdDocument; + + @Mock private TempFileManager mockTempFileManager; + + @Test + @DisplayName("Should convert EML to PDF without attachments when not requested") + void convertEmlToPdfWithoutAttachments() throws Exception { + String emlContent = + createSimpleTextEmail("from@test.com", "to@test.com", "Subject", "Body"); + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createBasicRequest(); + + PDDocument testPdf = new PDDocument(); + testPdf.addPage(new PDPage()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + testPdf.save(baos); + testPdf.close(); + byte[] fakePdfBytes = baos.toByteArray(); + + when(mockPdfDocumentFactory.load(any(byte[].class))).thenReturn(mockPdDocument); + when(mockPdDocument.getNumberOfPages()).thenReturn(1); + + try (MockedStatic fileToPdf = mockStatic(FileToPdf.class)) { + fileToPdf + .when( + () -> + FileToPdf.convertHtmlToPdf( + anyString(), + any(), + any(byte[].class), + anyString(), + anyBoolean(), + any(TempFileManager.class))) + .thenReturn(fakePdfBytes); + + byte[] resultPdf = + EmlToPdf.convertEmlToPdf( + "weasyprint", + request, + emlBytes, + "test.eml", + false, + mockPdfDocumentFactory, + mockTempFileManager); + + assertArrayEquals(fakePdfBytes, resultPdf); + + try (PDDocument resultDoc = mockPdfDocumentFactory.load(resultPdf)) { + assertNotNull(resultDoc); + assertTrue(resultDoc.getNumberOfPages() > 0); + } + + fileToPdf.verify( + () -> + FileToPdf.convertHtmlToPdf( + anyString(), + any(), + any(byte[].class), + anyString(), + anyBoolean(), + any(TempFileManager.class))); + verify(mockPdfDocumentFactory).load(resultPdf); + } + } + + @Test + @DisplayName("Should convert EML to PDF with attachments when requested") + void convertEmlToPdfWithAttachments() throws Exception { + String boundary = "----=_Part_1234567890"; + String emlContent = createMultipartEmailWithAttachment( + "multipart@example.com", + "user@example.com", + "Multipart Email Test", + "This email has both text content and an attachment.", + boundary, + "document.txt", + "Sample attachment content"); + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createRequestWithAttachments(); + + PDDocument testPdf = new PDDocument(); + testPdf.addPage(new PDPage()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + testPdf.save(baos); + testPdf.close(); + byte[] fakePdfBytes = baos.toByteArray(); + + when(mockPdfDocumentFactory.load(any(byte[].class))).thenReturn(mockPdDocument); + when(mockPdDocument.getNumberOfPages()).thenReturn(1); + + try (MockedStatic fileToPdf = mockStatic(FileToPdf.class)) { + fileToPdf + .when( + () -> + FileToPdf.convertHtmlToPdf( + anyString(), + any(), + any(byte[].class), + anyString(), + anyBoolean(), + any(TempFileManager.class))) + .thenReturn(fakePdfBytes); + + try (MockedStatic ignored = + mockStatic( + EmlToPdf.class, + invocation -> { + String methodName = invocation.getMethod().getName(); + return switch (methodName) { + case "shouldAttachFiles" -> true; + case "attachFilesToPdf" -> fakePdfBytes; + default -> invocation.callRealMethod(); + }; + })) { + byte[] resultPdf = + EmlToPdf.convertEmlToPdf( + "weasyprint", + request, + emlBytes, + "test.eml", + false, + mockPdfDocumentFactory, + mockTempFileManager); + + assertArrayEquals(fakePdfBytes, resultPdf); + + try (PDDocument resultDoc = mockPdfDocumentFactory.load(resultPdf)) { + assertNotNull(resultDoc); + assertTrue(resultDoc.getNumberOfPages() > 0); + } + + fileToPdf.verify( + () -> + FileToPdf.convertHtmlToPdf( + anyString(), + any(), + any(byte[].class), + anyString(), + anyBoolean(), + any(TempFileManager.class))); + + verify(mockPdfDocumentFactory).load(resultPdf); + } + } + } + + @Test + @DisplayName("Should handle errors during EML to PDF conversion") + void handleErrorsDuringConversion() { + String emlContent = + createSimpleTextEmail("from@test.com", "to@test.com", "Subject", "Body"); + byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8); + EmlToPdfRequest request = createBasicRequest(); + String errorMessage = "Conversion failed"; + + try (MockedStatic fileToPdf = mockStatic(FileToPdf.class)) { + fileToPdf + .when( + () -> + FileToPdf.convertHtmlToPdf( + anyString(), + any(), + any(byte[].class), + anyString(), + anyBoolean(), + any(TempFileManager.class))) + .thenThrow(new IOException(errorMessage)); + + IOException exception = assertThrows( + IOException.class, + () -> EmlToPdf.convertEmlToPdf( + "weasyprint", + request, + emlBytes, + "test.eml", + false, + mockPdfDocumentFactory, + mockTempFileManager)); + + assertTrue(exception.getMessage().contains(errorMessage)); + } + } + } + + // Helper methods + private String getTimestamp() { + java.time.ZonedDateTime fixedDateTime = java.time.ZonedDateTime.of(2023, 1, 1, 12, 0, 0, 0, java.time.ZoneId.of("GMT")); + return java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME.format(fixedDateTime); + } + private String createSimpleTextEmail(String from, String to, String subject, String body) { + return createSimpleTextEmailWithCharset(from, to, subject, body, "UTF-8"); + } + + private String createSimpleTextEmailWithCharset(String from, String to, String subject, String body, String charset) { + return String.format( + "From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/plain; charset=%s\nContent-Transfer-Encoding: 8bit\n\n%s", + from, to, subject, getTimestamp(), charset, body); + } + + private String createEmailWithCustomHeaders() { + return String.format( + "From: sender@example.com\nDate: %s\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s", + getTimestamp(), "This is an email body with some headers missing."); + } + + private String createHtmlEmail(String from, String to, String subject, String htmlBody) { + return String.format( + "From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/html; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s", + from, to, subject, getTimestamp(), htmlBody); + } + + private String createMultipartEmailWithAttachment(String from, String to, String subject, String body, + String boundary, String filename, String attachmentContent) { + String encodedContent = Base64.getEncoder().encodeToString(attachmentContent.getBytes(StandardCharsets.UTF_8)); + return String.format( + """ + From: %s + To: %s + Subject: %s + Date: %s + Content-Type: multipart/mixed; boundary="%s" + + --%s + Content-Type: text/plain; charset=UTF-8 + Content-Transfer-Encoding: 8bit + + %s + + --%s + Content-Type: text/plain; charset=UTF-8 + Content-Disposition: attachment; filename="%s" + Content-Transfer-Encoding: base64 + + %s + + --%s--""", + from, to, subject, getTimestamp(), boundary, boundary, body, boundary, filename, encodedContent, boundary); + } + + private String createEmailWithEmlAttachment(String boundary, String attachmentEmlContent) { + String encodedContent = Base64.getEncoder().encodeToString(attachmentEmlContent.getBytes(StandardCharsets.UTF_8)); + return String.format( + """ + From: %s + To: %s + Subject: %s + Date: %s + Content-Type: multipart/mixed; boundary="%s" + + --%s + Content-Type: text/plain; charset=UTF-8 + Content-Transfer-Encoding: 8bit + + %s + + --%s + Content-Type: message/rfc822; name="%s" + Content-Disposition: attachment; filename="%s" + Content-Transfer-Encoding: base64 + + %s + + --%s--""", + "outer@example.com", "outer_recipient@example.com", "Fwd: Inner Email Subject", getTimestamp(), boundary, boundary, "Please see the attached email.", boundary, "attached_email.eml", "attached_email.eml", encodedContent, boundary); + } + + private String createMultipartAlternativeEmail(String textBody, String htmlBody, String boundary) { + return String.format( + """ + From: %s + To: %s + Subject: %s + Date: %s + MIME-Version: 1.0 + Content-Type: multipart/alternative; boundary="%s" + + --%s + Content-Type: text/plain; charset=UTF-8 + Content-Transfer-Encoding: 7bit + + %s + + --%s + Content-Type: text/html; charset=UTF-8 + Content-Transfer-Encoding: 7bit + + %s + + --%s--""", + "sender@example.com", "receiver@example.com", "Multipart/Alternative Test", getTimestamp(), + boundary, boundary, textBody, boundary, htmlBody, boundary); + } + + private String createQuotedPrintableEmail() { + return String.format( + "From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: quoted-printable\n\n%s", + "sender@example.com", "recipient@example.com", "Quoted-Printable Test", getTimestamp(), "This is quoted=20printable content with special chars: =C3=A9=C3=A0=C3=A8."); + } + + private String createBase64Email(String body) { + String encodedBody = Base64.getEncoder().encodeToString(body.getBytes(StandardCharsets.UTF_8)); + return String.format( + "From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: base64\n\n%s", + "sender@example.com", "recipient@example.com", "Base64 Test", getTimestamp(), encodedBody); + } + + private String createEmailWithInlineImage(String htmlBody, String boundary, String contentId, String base64Image) { + return String.format( + """ + From: %s + To: %s + Subject: %s + Date: %s + Content-Type: multipart/related; boundary="%s" + + --%s + Content-Type: text/html; charset=UTF-8 + Content-Transfer-Encoding: 8bit + + %s + + --%s + Content-Type: image/png + Content-Transfer-Encoding: base64 + Content-ID: <%s> + Content-Disposition: inline; filename="image.png" + + %s + + --%s--""", + "sender@example.com", "receiver@example.com", "Inline Image Test", getTimestamp(), + boundary, boundary, htmlBody, boundary, contentId, base64Image, boundary); + } + + private String createEmailWithMixedAttachments(String htmlBody, String boundary, String contentId, + String base64Image, String attachmentBody) { + String encodedAttachment = Base64.getEncoder().encodeToString(attachmentBody.getBytes(StandardCharsets.UTF_8)); + return String.format( + """ + From: %s + To: %s + Subject: %s + Date: %s + Content-Type: multipart/mixed; boundary="%s" + + --%s + Content-Type: multipart/related; boundary="related-%s" + + --related-%s + Content-Type: text/html; charset=UTF-8 + Content-Transfer-Encoding: 8bit + + %s + + --related-%s + Content-Type: image/png + Content-Transfer-Encoding: base64 + Content-ID: <%s> + Content-Disposition: inline; filename="image.png" + + %s + + --related-%s-- + + --%s + Content-Type: text/plain; charset=UTF-8 + Content-Disposition: attachment; filename="%s" + Content-Transfer-Encoding: base64 + + %s + + --%s--""", + "sender@example.com", "receiver@example.com", "Mixed Attachments Test", getTimestamp(), + boundary, boundary, boundary, boundary, htmlBody, boundary, contentId, base64Image, boundary, + boundary, "text.txt", encodedAttachment, boundary); + } + // Creates a basic EmlToPdfRequest with default settings + private EmlToPdfRequest createBasicRequest() { + EmlToPdfRequest request = new EmlToPdfRequest(); + request.setIncludeAttachments(false); + return request; + } + + private EmlToPdfRequest createRequestWithAttachments() { + EmlToPdfRequest request = new EmlToPdfRequest(); + request.setIncludeAttachments(true); + request.setMaxAttachmentSizeMB(10); + return request; + } +} diff --git a/common/src/test/java/stirling/software/common/util/FileToPdfTest.java b/common/src/test/java/stirling/software/common/util/FileToPdfTest.java index a897e887b..f1df1cf25 100644 --- a/common/src/test/java/stirling/software/common/util/FileToPdfTest.java +++ b/common/src/test/java/stirling/software/common/util/FileToPdfTest.java @@ -3,7 +3,11 @@ package stirling.software.common.util; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.anyString; +import java.io.File; import java.io.IOException; import org.junit.jupiter.api.Test; @@ -22,14 +26,24 @@ public class FileToPdfTest { byte[] fileBytes = new byte[0]; // Sample file bytes (empty input) String fileName = "test.html"; // Sample file name indicating an HTML file boolean disableSanitize = false; // Flag to control sanitization + TempFileManager tempFileManager = mock(TempFileManager.class); // Mock TempFileManager + + // Mock the temp file creation to return real temp files + try { + when(tempFileManager.createTempFile(anyString())) + .thenReturn(File.createTempFile("test", ".pdf")) + .thenReturn(File.createTempFile("test", ".html")); + } catch (IOException e) { + throw new RuntimeException(e); + } - // Expect an IOException to be thrown due to empty input + // Expect an IOException to be thrown due to empty input or invalid weasyprint path Throwable thrown = assertThrows( - IOException.class, + Exception.class, () -> FileToPdf.convertHtmlToPdf( - "/path/", request, fileBytes, fileName, disableSanitize)); + "/path/", request, fileBytes, fileName, disableSanitize, tempFileManager)); assertNotNull(thrown); } diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/HttpRequestAuditPublisher.java b/proprietary/src/main/java/stirling/software/proprietary/config/HttpRequestAuditPublisher.java deleted file mode 100644 index e69de29bb..000000000 diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java b/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java index 969385a33..8a4dd7d3f 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java @@ -65,6 +65,9 @@ public class KeygenLicenseVerifier { } public License verifyLicense(String licenseKeyOrCert) { + if (!applicationProperties.getPremium().isEnabled()) { + return License.NORMAL; + } License license; LicenseContext context = new LicenseContext(); diff --git a/scripts/ignore_translation.toml b/scripts/ignore_translation.toml index 42fdd9f61..f1359a6ae 100644 --- a/scripts/ignore_translation.toml +++ b/scripts/ignore_translation.toml @@ -859,8 +859,28 @@ ignore = [ [sr_LATN_RS] ignore = [ 'language.direction', - 'licenses.version', - 'poweredBy', + 'lang.div', + 'lang.epo', + 'lang.hin', + 'lang.iku', + 'lang.mar', + 'lang.san', + 'lang.snd', + 'lang.tel', + 'lang.tgl', + 'lang.urd', + 'font', + 'info', + 'pro', + 'team.status', + 'endpointStatistics.top10', + 'endpointStatistics.top20', + 'endpointStatistics.top', + 'showJS.tags', + 'validateSignature.status', + 'audit.dashboard.status', + 'audit.dashboard.table.id', + 'audit.dashboard.modal.id', ] [sv_SE] diff --git a/scripts/init-without-ocr.sh b/scripts/init-without-ocr.sh index 934c995a3..aade064bb 100644 --- a/scripts/init-without-ocr.sh +++ b/scripts/init-without-ocr.sh @@ -28,9 +28,11 @@ if [[ -n "$LANGS" ]]; then fi echo "Setting permissions and ownership for necessary directories..." +# Ensure temp directory exists and has correct permissions +mkdir -p /tmp/stirling-pdf || true # Attempt to change ownership of directories and files -if chown -R stirlingpdfuser:stirlingpdfgroup $HOME /logs /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /app.jar; then - chmod -R 755 /logs /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /app.jar || true +if chown -R stirlingpdfuser:stirlingpdfgroup $HOME /logs /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf /app.jar; then + chmod -R 755 /logs /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf /app.jar || true # If chown succeeds, execute the command as stirlingpdfuser exec su-exec stirlingpdfuser "$@" else diff --git a/scripts/init.sh b/scripts/init.sh index f839da2bd..4cde7db46 100644 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -28,4 +28,9 @@ if [[ -n "$TESSERACT_LANGS" ]]; then done fi +# Ensure temp directory exists with correct permissions before running main init +mkdir -p /tmp/stirling-pdf || true +chown -R stirlingpdfuser:stirlingpdfgroup /tmp/stirling-pdf || true +chmod -R 755 /tmp/stirling-pdf || true + /scripts/init-without-ocr.sh "$@" \ No newline at end of file diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java index 32aedf57c..33d51a2a1 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java @@ -24,6 +24,7 @@ import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.api.converters.EmlToPdfRequest; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.EmlToPdf; +import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; @RestController @@ -35,6 +36,7 @@ public class ConvertEmlToPDF { private final CustomPDFDocumentFactory pdfDocumentFactory; private final RuntimePathConfig runtimePathConfig; + private final TempFileManager tempFileManager; @PostMapping(consumes = "multipart/form-data", value = "/eml/pdf") @Operation( @@ -102,7 +104,8 @@ public class ConvertEmlToPDF { fileBytes, originalFilename, false, - pdfDocumentFactory); + pdfDocumentFactory, + tempFileManager); if (pdfBytes == null || pdfBytes.length == 0) { log.error("PDF conversion failed - empty output for {}", originalFilename); diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java index cdd9bc1a7..4eff3a872 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java @@ -18,6 +18,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.api.converters.HTMLToPdfRequest; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.FileToPdf; +import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; @RestController @@ -32,6 +33,8 @@ public class ConvertHtmlToPDF { private final RuntimePathConfig runtimePathConfig; + private final TempFileManager tempFileManager; + @PostMapping(consumes = "multipart/form-data", value = "/html/pdf") @Operation( summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF", @@ -62,7 +65,8 @@ public class ConvertHtmlToPDF { request, fileInput.getBytes(), originalFilename, - disableSanitize); + disableSanitize, + tempFileManager); pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes); diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java index 98f96fbdb..1bf2d94a8 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java @@ -28,6 +28,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.api.GeneralFile; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.FileToPdf; +import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; @RestController @@ -41,6 +42,8 @@ public class ConvertMarkdownToPdf { private final ApplicationProperties applicationProperties; private final RuntimePathConfig runtimePathConfig; + private final TempFileManager tempFileManager; + @PostMapping(consumes = "multipart/form-data", value = "/markdown/pdf") @Operation( summary = "Convert a Markdown file to PDF", @@ -82,7 +85,8 @@ public class ConvertMarkdownToPdf { null, htmlContent.getBytes(), "converted.html", - disableSanitize); + disableSanitize, + tempFileManager); pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes); String outputFilename = originalFilename.replaceFirst("[.][^.]+$", "") diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java index be6c4649c..93061b570 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java @@ -2,7 +2,6 @@ package stirling.software.SPDF.controller.api.misc; import java.awt.image.BufferedImage; import java.io.*; -import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.zip.ZipEntry; @@ -23,7 +22,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import io.github.pixee.security.BoundedLineReader; import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -34,6 +32,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFileManager; @RestController @RequestMapping("/api/v1/misc") @@ -43,8 +44,8 @@ import stirling.software.common.service.CustomPDFDocumentFactory; public class OCRController { private final ApplicationProperties applicationProperties; - private final CustomPDFDocumentFactory pdfDocumentFactory; + private final TempFileManager tempFileManager; /** Gets the list of available Tesseract languages from the tessdata directory */ public List getAvailableTesseractLanguages() { @@ -73,93 +74,117 @@ public class OCRController { MultipartFile inputFile = request.getFileInput(); List languages = request.getLanguages(); String ocrType = request.getOcrType(); - Path tempDir = Files.createTempDirectory("ocr_process"); - Path tempInputFile = tempDir.resolve("input.pdf"); - Path tempOutputDir = tempDir.resolve("output"); - Path tempImagesDir = tempDir.resolve("images"); - Path finalOutputFile = tempDir.resolve("final_output.pdf"); - Files.createDirectories(tempOutputDir); - Files.createDirectories(tempImagesDir); - Process process = null; + + // Create a temp directory using TempFileManager directly + Path tempDirPath = tempFileManager.createTempDirectory(); + File tempDir = tempDirPath.toFile(); + try { + File tempInputFile = new File(tempDir, "input.pdf"); + File tempOutputDir = new File(tempDir, "output"); + File tempImagesDir = new File(tempDir, "images"); + File finalOutputFile = new File(tempDir, "final_output.pdf"); + + // Create directories + tempOutputDir.mkdirs(); + tempImagesDir.mkdirs(); + // Save input file - inputFile.transferTo(tempInputFile.toFile()); + inputFile.transferTo(tempInputFile); + PDFMergerUtility merger = new PDFMergerUtility(); merger.setDestinationFileName(finalOutputFile.toString()); - try (PDDocument document = pdfDocumentFactory.load(tempInputFile.toFile())) { + + try (PDDocument document = pdfDocumentFactory.load(tempInputFile)) { PDFRenderer pdfRenderer = new PDFRenderer(document); int pageCount = document.getNumberOfPages(); + for (int pageNum = 0; pageNum < pageCount; pageNum++) { PDPage page = document.getPage(pageNum); boolean hasText = false; + // Check for existing text try (PDDocument tempDoc = new PDDocument()) { tempDoc.addPage(page); PDFTextStripper stripper = new PDFTextStripper(); hasText = !stripper.getText(tempDoc).trim().isEmpty(); } + boolean shouldOcr = switch (ocrType) { case "skip-text" -> !hasText; case "force-ocr" -> true; default -> true; }; - Path pageOutputPath = - tempOutputDir.resolve(String.format("page_%d.pdf", pageNum)); + + File pageOutputPath = + new File(tempOutputDir, String.format("page_%d.pdf", pageNum)); + if (shouldOcr) { // Convert page to image BufferedImage image = pdfRenderer.renderImageWithDPI(pageNum, 300); - Path imagePath = - tempImagesDir.resolve(String.format("page_%d.png", pageNum)); - ImageIO.write(image, "png", imagePath.toFile()); + File imagePath = + new File(tempImagesDir, String.format("page_%d.png", pageNum)); + ImageIO.write(image, "png", imagePath); + // Build OCR command List command = new ArrayList<>(); command.add("tesseract"); command.add(imagePath.toString()); command.add( - tempOutputDir - .resolve(String.format("page_%d", pageNum)) + new File(tempOutputDir, String.format("page_%d", pageNum)) .toString()); command.add("-l"); command.add(String.join("+", languages)); // Always output PDF command.add("pdf"); - ProcessBuilder pb = new ProcessBuilder(command); - process = pb.start(); - // Capture any error output - try (BufferedReader reader = - new BufferedReader( - new InputStreamReader(process.getErrorStream()))) { - String line; - while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) { - log.debug("Tesseract: {}", line); + + // Use ProcessExecutor to run tesseract command + try { + ProcessExecutorResult result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.TESSERACT) + .runCommandWithOutputHandling(command); + + log.debug( + "Tesseract OCR completed for page {} with exit code {}", + pageNum, + result.getRc()); + + // Add OCR'd PDF to merger + merger.addSource(pageOutputPath); + } catch (IOException | InterruptedException e) { + log.error( + "Error processing page {} with tesseract: {}", + pageNum, + e.getMessage()); + // If OCR fails, fall back to the original page + try (PDDocument pageDoc = new PDDocument()) { + pageDoc.addPage(page); + pageDoc.save(pageOutputPath); + merger.addSource(pageOutputPath); } } - int exitCode = process.waitFor(); - if (exitCode != 0) { - throw new RuntimeException( - "Tesseract failed with exit code: " + exitCode); - } - // Add OCR'd PDF to merger - merger.addSource(pageOutputPath.toFile()); } else { // Save original page without OCR try (PDDocument pageDoc = new PDDocument()) { pageDoc.addPage(page); - pageDoc.save(pageOutputPath.toFile()); - merger.addSource(pageOutputPath.toFile()); + pageDoc.save(pageOutputPath); + merger.addSource(pageOutputPath); } } } } + // Merge all pages into final PDF merger.mergeDocuments(null); + // Read the final PDF file - byte[] pdfContent = Files.readAllBytes(finalOutputFile); + byte[] pdfContent = java.nio.file.Files.readAllBytes(finalOutputFile.toPath()); String outputFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename()) .replaceFirst("[.][^.]+$", "") + "_OCR.pdf"; + return ResponseEntity.ok() .header( "Content-Disposition", @@ -167,11 +192,8 @@ public class OCRController { .contentType(MediaType.APPLICATION_PDF) .body(pdfContent); } finally { - if (process != null) { - process.destroy(); - } - // Clean up temporary files - deleteDirectory(tempDir); + // Clean up the temp directory and all its contents + tempFileManager.deleteTempDirectory(tempDirPath); } } @@ -192,21 +214,4 @@ public class OCRController { zipOut.closeEntry(); } } - - private void deleteDirectory(Path directory) { - try { - Files.walk(directory) - .sorted(Comparator.reverseOrder()) - .forEach( - path -> { - try { - Files.delete(path); - } catch (IOException e) { - log.error("Error deleting {}: {}", path, e.getMessage()); - } - }); - } catch (IOException e) { - log.error("Error walking directory {}: {}", directory, e.getMessage()); - } - } } diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java index 85340a163..b8c347ef1 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java @@ -1,8 +1,6 @@ package stirling.software.SPDF.controller.api.misc; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -23,6 +21,8 @@ import stirling.software.common.model.api.PDFFile; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; @RestController @@ -32,6 +32,7 @@ import stirling.software.common.util.WebResponseUtils; public class RepairController { private final CustomPDFDocumentFactory pdfDocumentFactory; + private final TempFileManager tempFileManager; @PostMapping(consumes = "multipart/form-data", value = "/repair") @Operation( @@ -43,25 +44,25 @@ public class RepairController { public ResponseEntity repairPdf(@ModelAttribute PDFFile file) throws IOException, InterruptedException { MultipartFile inputFile = file.getFileInput(); - // Save the uploaded file to a temporary location - Path tempInputFile = Files.createTempFile("input_", ".pdf"); - byte[] pdfBytes = null; - inputFile.transferTo(tempInputFile.toFile()); - try { + + // Use TempFile with try-with-resources for automatic cleanup + try (TempFile tempFile = new TempFile(tempFileManager, ".pdf")) { + // Save the uploaded file to the temporary location + inputFile.transferTo(tempFile.getFile()); List command = new ArrayList<>(); command.add("qpdf"); command.add("--replace-input"); // Automatically fixes problems it can command.add("--qdf"); // Linearizes and normalizes PDF structure command.add("--object-streams=disable"); // Can help with some corruptions - command.add(tempInputFile.toString()); + command.add(tempFile.getFile().getAbsolutePath()); ProcessExecutorResult returnCode = ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF) .runCommandWithOutputHandling(command); // Read the optimized PDF file - pdfBytes = pdfDocumentFactory.loadToBytes(tempInputFile.toFile()); + byte[] pdfBytes = pdfDocumentFactory.loadToBytes(tempFile.getFile()); // Return the optimized PDF as a response String outputFilename = @@ -69,9 +70,6 @@ public class RepairController { .replaceFirst("[.][^.]+$", "") + "_repaired.pdf"; return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename); - } finally { - // Clean up the temporary files - Files.deleteIfExists(tempInputFile); } } } diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java index 9c0ad2909..bdf27c519 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java @@ -6,7 +6,6 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.nio.file.Files; import java.util.List; import javax.imageio.ImageIO; @@ -40,6 +39,8 @@ import lombok.RequiredArgsConstructor; import stirling.software.SPDF.model.api.misc.AddStampRequest; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; @RestController @@ -49,6 +50,7 @@ import stirling.software.common.util.WebResponseUtils; public class StampController { private final CustomPDFDocumentFactory pdfDocumentFactory; + private final TempFileManager tempFileManager; @PostMapping(consumes = "multipart/form-data", value = "/add-stamp") @Operation( @@ -179,6 +181,9 @@ public class StampController { case "chinese": resourceDir = "static/fonts/SimSun.ttf"; break; + case "thai": + resourceDir = "static/fonts/NotoSansThai-Regular.ttf"; + break; case "roman": default: resourceDir = "static/fonts/NotoSans-Regular.ttf"; @@ -188,14 +193,14 @@ public class StampController { if (!"".equals(resourceDir)) { ClassPathResource classPathResource = new ClassPathResource(resourceDir); String fileExtension = resourceDir.substring(resourceDir.lastIndexOf(".")); - File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile(); - try (InputStream is = classPathResource.getInputStream(); - FileOutputStream os = new FileOutputStream(tempFile)) { - IOUtils.copy(is, os); - font = PDType0Font.load(document, tempFile); - } finally { - if (tempFile != null) { - Files.deleteIfExists(tempFile.toPath()); + + // Use TempFile with try-with-resources for automatic cleanup + try (TempFile tempFileWrapper = new TempFile(tempFileManager, fileExtension)) { + File tempFile = tempFileWrapper.getFile(); + try (InputStream is = classPathResource.getInputStream(); + FileOutputStream os = new FileOutputStream(tempFile)) { + IOUtils.copy(is, os); + font = PDType0Font.load(document, tempFile); } } } diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index fd8f1cf8a..47a53a4f9 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -170,6 +170,9 @@ public class WatermarkController { case "chinese": resourceDir = "static/fonts/SimSun.ttf"; break; + case "thai": + resourceDir = "static/fonts/NotoSansThai-Regular.ttf"; + break; case "roman": default: resourceDir = "static/fonts/NotoSans-Regular.ttf"; diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/service/ApiDocService.java b/stirling-pdf/src/main/java/stirling/software/SPDF/service/ApiDocService.java index d5cc76af8..0e46af08d 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/service/ApiDocService.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/service/ApiDocService.java @@ -120,8 +120,8 @@ public class ApiDocService { ObjectMapper mapper = new ObjectMapper(); apiDocsJsonRootNode = mapper.readTree(apiDocsJson); JsonNode paths = apiDocsJsonRootNode.path("paths"); - paths.fields() - .forEachRemaining( + paths.propertyStream() + .forEach( entry -> { String path = entry.getKey(); JsonNode pathNode = entry.getValue(); diff --git a/stirling-pdf/src/main/resources/application.properties b/stirling-pdf/src/main/resources/application.properties index 00a2e87e1..ea30bf78e 100644 --- a/stirling-pdf/src/main/resources/application.properties +++ b/stirling-pdf/src/main/resources/application.properties @@ -44,4 +44,7 @@ springdoc.swagger-ui.path=/index.html posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq posthog.host=https://eu.i.posthog.com -spring.main.allow-bean-definition-overriding=true \ No newline at end of file +spring.main.allow-bean-definition-overriding=true + +# Set up a consistent temporary directory location +java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} \ No newline at end of file diff --git a/stirling-pdf/src/main/resources/messages_sr_LATN_RS.properties b/stirling-pdf/src/main/resources/messages_sr_LATN_RS.properties index f64e8b279..bd2890e52 100644 --- a/stirling-pdf/src/main/resources/messages_sr_LATN_RS.properties +++ b/stirling-pdf/src/main/resources/messages_sr_LATN_RS.properties @@ -5,166 +5,166 @@ language.direction=ltr # Language names for reuse throughout the application -lang.afr=Afrikaans -lang.amh=Amharic -lang.ara=Arabic -lang.asm=Assamese -lang.aze=Azerbaijani -lang.aze_cyrl=Azerbaijani (Cyrillic) -lang.bel=Belarusian -lang.ben=Bengali -lang.bod=Tibetan -lang.bos=Bosnian -lang.bre=Breton -lang.bul=Bulgarian -lang.cat=Catalan -lang.ceb=Cebuano -lang.ces=Czech -lang.chi_sim=Chinese (Simplified) -lang.chi_sim_vert=Chinese (Simplified, Vertical) -lang.chi_tra=Chinese (Traditional) -lang.chi_tra_vert=Chinese (Traditional, Vertical) -lang.chr=Cherokee -lang.cos=Corsican -lang.cym=Welsh -lang.dan=Danish -lang.dan_frak=Danish (Fraktur) -lang.deu=German -lang.deu_frak=German (Fraktur) +lang.afr=Afrički +lang.amh=Amharski +lang.ara=Arapski +lang.asm=Asamski +lang.aze=Azerbejdžanski +lang.aze_cyrl=Azerbejdžanski (ćirilica) +lang.bel=Beloruski +lang.ben=Bengalski +lang.bod=Tibetanski +lang.bos=Bosanski +lang.bre=Bretonski +lang.bul=Bugarski +lang.cat=Katalonski +lang.ceb=Sebuano +lang.ces=Češki +lang.chi_sim=Kineski (pojednostavljeni) +lang.chi_sim_vert=Kineski (pojednostavljeni, vertikalno) +lang.chi_tra=Kineski (tradicionalni) +lang.chi_tra_vert=Kineski (tradicionalni, vertikalno) +lang.chr=Čiroki +lang.cos=Korzikanski +lang.cym=Velški +lang.dan=Danski +lang.dan_frak=Danski (Fraktur) +lang.deu=Nemački +lang.deu_frak=Nemački (Fraktur) lang.div=Divehi -lang.dzo=Dzongkha -lang.ell=Greek -lang.eng=English -lang.enm=English, Middle (1100-1500) +lang.dzo=Dzonka +lang.ell=Grčki +lang.eng=Engleski +lang.enm=Engleski, srednji (1100-1500) lang.epo=Esperanto -lang.equ=Math / equation detection module -lang.est=Estonian -lang.eus=Basque -lang.fao=Faroese -lang.fas=Persian -lang.fil=Filipino -lang.fin=Finnish -lang.fra=French -lang.frk=Frankish -lang.frm=French, Middle (ca.1400-1600) -lang.fry=Western Frisian -lang.gla=Scottish Gaelic -lang.gle=Irish -lang.glg=Galician -lang.grc=Ancient Greek -lang.guj=Gujarati -lang.hat=Haitian, Haitian Creole -lang.heb=Hebrew +lang.equ=Modul za prepoznavanje matematike/jednačina +lang.est=Estonski +lang.eus=Baskijski +lang.fao=Farski +lang.fas=Persijski +lang.fil=Filipinski +lang.fin=Finski +lang.fra=Francuski +lang.frk=Frankski +lang.frm=Francuski, srednji (oko 1400-1600) +lang.fry=Zapadnofriziski +lang.gla=Škotski galski +lang.gle=Irski +lang.glg=Galicijski +lang.grc=Starogrčki +lang.guj=Gužarati +lang.hat=Haićanski, Haićanski kreolski +lang.heb=Hebrejski lang.hin=Hindi -lang.hrv=Croatian -lang.hun=Hungarian -lang.hye=Armenian +lang.hrv=Hrvatski +lang.hun=Mađarski +lang.hye=Jermenski lang.iku=Inuktitut -lang.ind=Indonesian -lang.isl=Icelandic -lang.ita=Italian -lang.ita_old=Italian (Old) -lang.jav=Javanese -lang.jpn=Japanese -lang.jpn_vert=Japanese (Vertical) -lang.kan=Kannada -lang.kat=Georgian -lang.kat_old=Georgian (Old) -lang.kaz=Kazakh -lang.khm=Central Khmer -lang.kir=Kirghiz, Kyrgyz -lang.kmr=Northern Kurdish -lang.kor=Korean -lang.kor_vert=Korean (Vertical) -lang.lao=Lao -lang.lat=Latin -lang.lav=Latvian -lang.lit=Lithuanian -lang.ltz=Luxembourgish -lang.mal=Malayalam +lang.ind=Indonežanski +lang.isl=Islandski +lang.ita=Italijanski +lang.ita_old=Italijanski (stari) +lang.jav=Javanski +lang.jpn=Japanski +lang.jpn_vert=Japanski (vertikalno) +lang.kan=Kanada +lang.kat=Gruzijski +lang.kat_old=Gruzijski (stari) +lang.kaz=Kazaški +lang.khm=Kmerski +lang.kir=Kirgiski +lang.kmr=Servernokurdski +lang.kor=Korejski +lang.kor_vert=Korejski(vertikalno) +lang.lao=Laoški +lang.lat=Latinski +lang.lav=Latvijski +lang.lit=Litvanski +lang.ltz=Luksemburški +lang.mal=Malajam lang.mar=Marathi -lang.mkd=Macedonian -lang.mlt=Maltese -lang.mon=Mongolian -lang.mri=Maori -lang.msa=Malay -lang.mya=Burmese -lang.nep=Nepali -lang.nld=Dutch; Flemish -lang.nor=Norwegian -lang.oci=Occitan (post 1500) -lang.ori=Oriya -lang.osd=Orientation and script detection module -lang.pan=Panjabi, Punjabi -lang.pol=Polish -lang.por=Portuguese -lang.pus=Pushto, Pashto -lang.que=Quechua -lang.ron=Romanian, Moldavian, Moldovan -lang.rus=Russian +lang.mkd=Makedonski +lang.mlt=Malteški +lang.mon=Mongolski +lang.mri=Maorski +lang.msa=Malajski +lang.mya=Birmanski +lang.nep=Nepalski +lang.nld=Holandski; Flamanski +lang.nor=Norveški +lang.oci=Oksitanski (posle 1500) +lang.ori=Orija +lang.osd=Modul za detekciju i orijentaciju pisma +lang.pan=Pandžapski +lang.pol=Poljski +lang.por=Portugalski +lang.pus=Puštu +lang.que=Kečua +lang.ron=Rumunski, Moldavski +lang.rus=Ruski lang.san=Sanskrit -lang.sin=Sinhala, Sinhalese -lang.slk=Slovak -lang.slk_frak=Slovak (Fraktur) -lang.slv=Slovenian +lang.sin=Singalski +lang.slk=Slovački +lang.slk_frak=Slovački (Fraktur) +lang.slv=Slovenački lang.snd=Sindhi -lang.spa=Spanish -lang.spa_old=Spanish (Old) -lang.sqi=Albanian -lang.srp=Serbian -lang.srp_latn=Serbian (Latin) -lang.sun=Sundanese -lang.swa=Swahili -lang.swe=Swedish -lang.syr=Syriac -lang.tam=Tamil -lang.tat=Tatar +lang.spa=Španski +lang.spa_old=Španski (stari) +lang.sqi=Albanski +lang.srp=Srpski +lang.srp_latn=Srpski (latinica) +lang.sun=Sundanski +lang.swa=Svahili +lang.swe=Švedski +lang.syr=Sirijski +lang.tam=Tamilski +lang.tat=Tatarski lang.tel=Telugu -lang.tgk=Tajik +lang.tgk=Tadžički lang.tgl=Tagalog -lang.tha=Thai -lang.tir=Tigrinya -lang.ton=Tonga (Tonga Islands) -lang.tur=Turkish -lang.uig=Uighur, Uyghur -lang.ukr=Ukrainian +lang.tha=Tajlandski +lang.tir=Tigrinja +lang.ton=Tonga +lang.tur=Turski +lang.uig=Ujgurski +lang.ukr=Ukrajinski lang.urd=Urdu -lang.uzb=Uzbek -lang.uzb_cyrl=Uzbek (Cyrillic) -lang.vie=Vietnamese -lang.yid=Yiddish -lang.yor=Yoruba +lang.uzb=Uzbekski +lang.uzb_cyrl=Uzbekski (ćirilica) +lang.vie=Vijetnamski +lang.yid=Jidiš +lang.yor=Joruba -addPageNumbers.fontSize=Font Size -addPageNumbers.fontName=Font Name +addPageNumbers.fontSize=Veličina fonta +addPageNumbers.fontName=Naziv fonta pdfPrompt=Odaberi PDF(ove) multiPdfPrompt=Odaberi PDF-ove (2+) -multiPdfDropPrompt=Odaberi (prevuci i pusti ) sve PDF-ove koji su vam potrebni +multiPdfDropPrompt=Odaberi (ili prevuci i pusti) sve PDF-ove koji su ti potrebni imgPrompt=Odaberi sliku (slike) -genericSubmit=Prihvatiti -uploadLimit=Maximum file size: -uploadLimitExceededSingular=is too large. Maximum allowed size is -uploadLimitExceededPlural=are too large. Maximum allowed size is -processTimeWarning=Warning:Upozorenje: Ovaj proces može trajati i do minut, u zavisnosti od veličine dokumenta -pageOrderPrompt=Prilagođeni redosled stranica (unesi listu brojeva stranica ili funkcija, kao što su 2n+1, razdvojene zarezima) : -pageSelectionPrompt=Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1) : +genericSubmit=Potvrdi +uploadLimit=Maksimalna veličina datoteke: +uploadLimitExceededSingular=je prevelik. Maksimalna dozvoljena veličina je +uploadLimitExceededPlural=su preveliki. Maksimalna dozvoljena veličina je +processTimeWarning=Upozorenje: Ovaj proces može trajati i do minut, u zavisnosti od veličine datoteke +pageOrderPrompt=Prilagođeni redosled stranica (unesi listu brojeva stranica ili funkcija, kao što je 2n+1, razdvojenih zarezima) : +pageSelectionPrompt=Prilagođeni redosled stranica (unesi listu brojeva stranica 1,5,6 ili funkcija, kao što je 2n+1, razdvojenih zarezima) : goToPage=Idi true=Tačno false=Netačno unknown=Nepoznato save=Sačuvaj -saveToBrowser=Save to Browser +saveToBrowser=Sačuvaj u pregledaču close=Zatvori filesSelected=odabrani fajlovi noFavourites=Nema dodatih favorita -downloadComplete=Download Complete +downloadComplete=Preuzimanje završeno bored=Da li ti je dosadno dok čekaš? -alphabet=Alfabet -downloadPdf=Skini PDF +alphabet=Abeceda +downloadPdf=Preuzmi PDF text=Tekst font=Font selectFillter=-- Izaberi -- -pageNum=Broj Strane +pageNum=Broj strane sizes.small=Malo sizes.medium=Srednje sizes.large=Veliko @@ -172,7 +172,7 @@ sizes.x-large=X-Veliko error.pdfPassword=PDF dokument je šifrovan i lozinka nije data ili je netačna delete=Obriši username=Korisničko ime -password=Šifra +password=Lozinka welcome=Dobrodošli property=Svojstvo black=Crno @@ -181,150 +181,150 @@ red=Crveno green=Zeleno blue=Plavo custom=Prilagođeno... -WorkInProgess=Radovi u toku, možda neće raditi ili će biti grešaka, molimo prijavite sve probleme ! -poweredBy=Powered by -yes=Yes -no=No +WorkInProgess=Radovi u toku, možda neće raditi ili će biti grešaka, molimo prijavite sve probleme! +poweredBy=Omogućeno od strane +yes=Da +no=Ne changedCredsMessage=Podaci za prijavu uspešno promenjeni! notAuthenticatedMessage=Korisnik nije autentifikovan. userNotFoundMessage=Korisnik nije pronađen. -incorrectPasswordMessage=Trenutna šifra je netačna. +incorrectPasswordMessage=Trenutna lozinka je netačna. usernameExistsMessage=Novi korisnik već postoji -invalidUsernameMessage=Invalid username, username can only contain letters, numbers and the following special characters @._+- or must be a valid email address. -invalidPasswordMessage=The password must not be empty and must not have spaces at the beginning or end. -confirmPasswordErrorMessage=New Password and Confirm New Password must match. -deleteCurrentUserMessage=Cannot delete currently logged in user. -deleteUsernameExistsMessage=The username does not exist and cannot be deleted. +invalidUsernameMessage=Pogrešno korisničko ime, korisničko ime može sadržati samo slova, brojeve i specijalne karaktere @._+- ili mora biti validna email adresa. +invalidPasswordMessage=Lozinka ne sme biti prazna i ne sme imati razmake na početku ili kraju. +confirmPasswordErrorMessage=Nova lozinka i potvrda lozinke se moraju slagati. +deleteCurrentUserMessage=Ne mogu obrisati trenutno prijavljenog korisnika. +deleteUsernameExistsMessage=Korisničko ime ne postoji i ne može biti obrisano. downgradeCurrentUserMessage=Nije moguće degradirati ulogu trenutnog korisnika -disabledCurrentUserMessage=The current user cannot be disabled -downgradeCurrentUserLongMessage=Nije moguće unazaditi ulogu trenutnog korisnika. Dakle, trenutni korisnik neće biti prikazan. -userAlreadyExistsOAuthMessage=The user already exists as an OAuth2 user. -userAlreadyExistsWebMessage=The user already exists as an web user. -invalidRoleMessage=Invalid role. -error=Error -oops=Oops! -help=Help -goHomepage=Go to Homepage -joinDiscord=Join our Discord server -seeDockerHub=See Docker Hub -visitGithub=Visit Github Repository -donate=Donate -color=Color -sponsor=Sponsor +disabledCurrentUserMessage=Trenutno korisnik ne može biti onemogućen +downgradeCurrentUserLongMessage=Nije moguće degradirati ulogu trenutnog korisnika. Dakle, trenutni korisnik neće biti prikazan. +userAlreadyExistsOAuthMessage=Korisnik već postoji kao OAuth2 korisnik. +userAlreadyExistsWebMessage=Korisnik već postoji kao web korisnik. +invalidRoleMessage=Nevažeća uloga. +error=Greška +oops=Opa! +help=Pomoć +goHomepage=Idi na početnu stranu +joinDiscord=Pridružite se našem Discord serveru +seeDockerHub=Pogledaj Docker Hub +visitGithub=Poseti Github repozitorijum +donate=Doniraj +color=Boja +sponsor=Sponzor info=Info pro=Pro -proFeatures=Pro Features -page=Page -pages=Pages -loading=Loading... -addToDoc=Add to Document -reset=Reset -apply=Apply -noFileSelected=No file selected. Please upload one. -view=View -cancel=Cancel +proFeatures=Napredne funkcije +page=Strana +pages=Strane +loading=Učitavam... +addToDoc=Dodaj u dokument +reset=Resetuj +apply=Primeni +noFileSelected=Datoteka nije izabrana. Otpremi jednu. +view=Pogledaj +cancel=Otkaži -back.toSettings=Back to Settings -back.toHome=Back to Home -back.toAdmin=Back to Admin +back.toSettings=Povratak na podešavanja +back.toHome=Povratak na početak +back.toAdmin=Povratak u admin sekciju -legal.privacy=Privacy Policy -legal.terms=Terms and Conditions -legal.accessibility=Accessibility -legal.cookie=Cookie Policy -legal.impressum=Impressum -legal.showCookieBanner=Cookie Preferences +legal.privacy=Politika privatnosti +legal.terms=Uslovi i odredbe +legal.accessibility=Pristupačnost +legal.cookie=Politika kolačića +legal.impressum=Impresum +legal.showCookieBanner=Podešavanje kolačića ############### # Pipeline # ############### -pipeline.header=Meni za Pipeline (Alfa verzija) -pipeline.uploadButton=Postavi prilagođeno +pipeline.header=Tok rada (Beta) +pipeline.uploadButton=Otpremi sopstveno pipeline.configureButton=Konfiguriši pipeline.defaultOption=Prilagođeno pipeline.submitButton=Pošalji -pipeline.help=Pipeline Help -pipeline.scanHelp=Folder Scanning Help -pipeline.deletePrompt=Are you sure you want to delete pipeline +pipeline.help=Pomoć za tok rada (pipeline) +pipeline.scanHelp=Pomoć za pretragu foldera +pipeline.deletePrompt=Jesi li siguran da želiš da obrišeš tok rada ###################### # Pipeline Options # ###################### -pipelineOptions.header=Konfiguracija Pipeline-a -pipelineOptions.pipelineNameLabel=Ime Pipeline-a +pipelineOptions.header=Konfiguracija toka rada +pipelineOptions.pipelineNameLabel=Ime toka rada: pipelineOptions.saveSettings=Sačuvaj podešavanja -pipelineOptions.pipelineNamePrompt=Unesite ime pipeline-a ovde -pipelineOptions.selectOperation=Select Operation +pipelineOptions.pipelineNamePrompt=Unesi ime toka rada ovde +pipelineOptions.selectOperation=Izaberi operaciju: pipelineOptions.addOperationButton=Dodaj operaciju -pipelineOptions.pipelineHeader=Pipeline: +pipelineOptions.pipelineHeader=Tok rada: pipelineOptions.saveButton=Preuzmi pipelineOptions.validateButton=Proveri ######################## # ENTERPRISE EDITION # ######################## -enterpriseEdition.button=Upgrade to Pro -enterpriseEdition.warning=This feature is only available to Pro users. -enterpriseEdition.yamlAdvert=Stirling PDF Pro supports YAML configuration files and other SSO features. -enterpriseEdition.ssoAdvert=Looking for more user management features? Check out Stirling PDF Pro -enterpriseEdition.proTeamFeatureDisabled=Team management features require a Pro licence or higher +enterpriseEdition.button=Nadogradi na Pro verziju +enterpriseEdition.warning=Ova funkcija je dostupna samo Pro korisnicima. +enterpriseEdition.yamlAdvert=Stirling PDF Pro podržava YAML konfiguracione datoteke i druge SSO funkcionalnosti. +enterpriseEdition.ssoAdvert=Tražiš još funkcija za upravljanje korisnicima? Razmotri Stirling PDF Pro +enterpriseEdition.proTeamFeatureDisabled=Funkcije upravljanja timom zahtevaju Pro ili višu licencu ################# # Analytics # ################# -analytics.title=Do you want make Stirling PDF better? -analytics.paragraph1=Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents. -analytics.paragraph2=Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better. -analytics.enable=Enable analytics -analytics.disable=Disable analytics -analytics.settings=You can change the settings for analytics in the config/settings.yml file +analytics.title=Želiš li da učiniš Stirling PDF boljim? +analytics.paragraph1=Stirling PDF ima opcioni sistem analitike koji nam pomaže da unapredimo proizvod. Ne pratimo nikakve lične podatke niti sadržaj fajlova. +analytics.paragraph2=Molimo te da razmotriš uključivanje analitike kako bi pomogao Stirling PDF-u da raste i omogućio nam bolje razumevanje naših korisnika. +analytics.enable=Omogući analitiku +analytics.disable=Onemogući analitiku +analytics.settings=Možeš da promeniš podešavanja za analitiku u config/settings.yml datoteci ############# # NAVBAR # ############# -navbar.favorite=Favorites -navbar.recent=New and recently updated +navbar.favorite=Omiljeno +navbar.recent=Novo i nedavno ažurirano navbar.darkmode=Tamni režim -navbar.language=Languages +navbar.language=Jezici navbar.settings=Podešavanja -navbar.allTools=Tools -navbar.multiTool=Multi Tools -navbar.search=Search -navbar.sections.organize=Organize -navbar.sections.convertTo=Convert to PDF -navbar.sections.convertFrom=Convert from PDF -navbar.sections.security=Sign & Security -navbar.sections.advance=Advanced -navbar.sections.edit=View & Edit -navbar.sections.popular=Popular +navbar.allTools=Alati +navbar.multiTool=Višefunkcijski alati +navbar.search=Pretraga +navbar.sections.organize=Organizacija +navbar.sections.convertTo=Konvertuj u PDF +navbar.sections.convertFrom=Konvertuj iz PDF +navbar.sections.security=Potpis i bezbednost +navbar.sections.advance=Napredno +navbar.sections.edit=Pregled i uređivanje +navbar.sections.popular=Popularno ############# # SETTINGS # ############# settings.title=Podešavanja settings.update=Dostupno ažuriranje -settings.updateAvailable={0} is the current installed version. A new version ({1}) is available. +settings.updateAvailable={0} je trenutno instalirana verzija. Nova verzija ({1}) je dostupna. settings.appVersion=Verzija aplikacije: -settings.downloadOption.title=Odaberite opciju preuzimanja (Za preuzimanje pojedinačnih fajlova bez zip formata): +settings.downloadOption.title=Odaberi opciju preuzimanja (Za preuzimanje pojedinačnih fajlova bez zip formata): settings.downloadOption.1=Otvori u istom prozoru settings.downloadOption.2=Otvori u novom prozoru -settings.downloadOption.3=Preuzmi fajl -settings.zipThreshold=Zipuj fajlove kada pređe broj preuzetih fajlova +settings.downloadOption.3=Preuzmi datoteku +settings.zipThreshold=Zipuj datoteke kada je broj datoteka za preuzimanje veći od settings.signOut=Odjava settings.accountSettings=Podešavanja naloga -settings.bored.help=Enables easter egg game -settings.cacheInputs.name=Save form inputs -settings.cacheInputs.help=Enable to store previously used inputs for future runs +settings.bored.help=Omogućava skrivenu igru +settings.cacheInputs.name=Sačuvaj unete podatke +settings.cacheInputs.help=Omogući prethodno unete podatke za buduće korišćenje changeCreds.title=Promeni pristupne podatke -changeCreds.header=Ažurirajte detalje svog naloga -changeCreds.changePassword=You are using default login credentials. Please enter a new password +changeCreds.header=Ažuriraj detalje svog naloga +changeCreds.changePassword=Koristiš podrazumevane pristupne podatke. Molim te unesi novu lozinku changeCreds.newUsername=Novo korisničko ime changeCreds.oldPassword=Trenutna lozinka changeCreds.newPassword=Nova lozinka -changeCreds.confirmNewPassword=Potvrdite novu lozinku +changeCreds.confirmNewPassword=Potvrdi novu lozinku changeCreds.submit=Potvrdi promene @@ -333,23 +333,23 @@ account.title=Podešavanja naloga account.accountSettings=Podešavanja naloga account.adminSettings=Admin podešavanja - Pregled i dodavanje korisnika account.userControlSettings=Podešavanja kontrole korisnika -account.changeUsername=Pormeni korisničko ime +account.changeUsername=Promeni korisničko ime account.newUsername=Novo korisničko ime account.password=Potvrda lozinke account.oldPassword=Stara lozinka account.newPassword=Nova lozinka -account.changePassword=Pormeni lozinku +account.changePassword=Promeni lozinku account.confirmNewPassword=Potvrdi novu lozinku account.signOut=Odjava account.yourApiKey=Tvoj API ključ account.syncTitle=Sinhronizacija podešavanja pregledača sa nalogom account.settingsCompare=Upoređivanje podešavanja: account.property=Svojstvo -account.webBrowserSettings=Podešavanja veb pregledača +account.webBrowserSettings=Podešavanja web pregledača account.syncToBrowser=Sinhronizacija naloga -> pregledač account.syncToAccount=Sinhronizacija naloga <- pregledač -account.adminTitle=Administrator Tools -account.adminNotif=You have admin privileges. Access system settings and user management. +account.adminTitle=Administratorski alati +account.adminNotif=Imaš administratorske privilegije. Pristupi sistemskim podešavanjima i upravljanju korisnicima. adminUserSettings.title=Podešavanja kontrole korisnika @@ -357,314 +357,318 @@ adminUserSettings.header=Podešavanja kontrole korisnika za administratora adminUserSettings.admin=Administrator adminUserSettings.user=Korisnik adminUserSettings.addUser=Dodaj novog korisnika -adminUserSettings.deleteUser=Delete User -adminUserSettings.confirmDeleteUser=Should the user be deleted? -adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled? -adminUserSettings.usernameInfo=Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address. +adminUserSettings.deleteUser=Izbriši korisnika +adminUserSettings.confirmDeleteUser=Da obrišem korisnika? +adminUserSettings.confirmChangeUserStatus=Da onemogućim/omogućim korisnika? +adminUserSettings.usernameInfo=Korisničko ime može sadržati samo slova, brojeve i specijalne karaktere @._+- ili mora biti validna email adresa. adminUserSettings.role=Uloga adminUserSettings.actions=Akcije adminUserSettings.apiUser=Korisnik s ograničenim API pristupom -adminUserSettings.extraApiUser=Additional Limited API User +adminUserSettings.extraApiUser=Dodatni ograničeni API korisnik adminUserSettings.webOnlyUser=Korisnik samo za web adminUserSettings.demoUser=Demo korisnik (Bez prilagođenih podešavanja) -adminUserSettings.internalApiUser=Internal API User +adminUserSettings.internalApiUser=Interni API korisnik adminUserSettings.forceChange=Prisili korisnika da promeni korisničko ime/lozinku pri prijavi adminUserSettings.submit=Sačuvaj korisnika adminUserSettings.changeUserRole=Promenite ulogu korisnika -adminUserSettings.authenticated=Authenticated -adminUserSettings.editOwnProfil=Edit own profile -adminUserSettings.enabledUser=enabled user -adminUserSettings.disabledUser=disabled user -adminUserSettings.activeUsers=Active Users: -adminUserSettings.disabledUsers=Disabled Users: -adminUserSettings.totalUsers=Total Users: -adminUserSettings.lastRequest=Last Request -adminUserSettings.usage=View Usage -adminUserSettings.teams=View/Edit Teams -adminUserSettings.team=Team -adminUserSettings.manageTeams=Manage Teams -adminUserSettings.createTeam=Create Team -adminUserSettings.viewTeam=View Team -adminUserSettings.deleteTeam=Delete Team -adminUserSettings.teamName=Team Name -adminUserSettings.teamExists=Team already exists -adminUserSettings.teamCreated=Team created successfully -adminUserSettings.teamChanged=User's team was updated -adminUserSettings.teamHidden=Hidden -adminUserSettings.totalMembers=Total Members -adminUserSettings.confirmDeleteTeam=Are you sure you want to delete this team? +adminUserSettings.authenticated=Prijavljen +adminUserSettings.editOwnProfil=Izmeni sopstveni profil +adminUserSettings.enabledUser=omogućen korisnik +adminUserSettings.disabledUser=onemogućen korisnik +adminUserSettings.activeUsers=Aktivni korisnici: +adminUserSettings.disabledUsers=Onemogućeni korisnici: +adminUserSettings.totalUsers=Totalno korisnika: +adminUserSettings.lastRequest=Poslednji zahtev +adminUserSettings.usage=Prikaži upotrebu +adminUserSettings.teams=Prikaži/izmeni timove +adminUserSettings.team=Tim +adminUserSettings.manageTeams=Upravljaj timovima +adminUserSettings.createTeam=Kreiraj tim +adminUserSettings.viewTeam=Pogledaj tim +adminUserSettings.deleteTeam=Obriši tim +adminUserSettings.teamName=Naziv tima +adminUserSettings.teamExists=Tim već postoji +adminUserSettings.teamCreated=Tim uspešno kreiran +adminUserSettings.teamChanged=Korisnički tim uspešno ažuriran +adminUserSettings.teamHidden=Skriven +adminUserSettings.totalMembers=Ukupno članova +adminUserSettings.confirmDeleteTeam=Jesi li siguran da želiš da obrišeš ovaj tim? -teamCreated=Team created successfully -teamExists=A team with that name already exists -teamNameExists=Another team with that name already exists -teamNotFound=Team not found -teamDeleted=Team deleted -teamHasUsers=Cannot delete a team with users assigned -teamRenamed=Team renamed successfully +teamCreated=Tim uspešno kreiran +teamExists=Tim sa tim imenom već postoji +teamNameExists=Drugi tim sa tim imenom već postoji +teamNotFound=Tim nije pronađen +teamDeleted=Tim obrisan +teamHasUsers=Nije moguće obrisati tim kom su dodeljni korisnici +teamRenamed=Tim uspešno preimenovan # Team user management -team.addUser=Add User to Team -team.selectUser=Select User -team.warning.moveUser=Warning: This will move the user from "{0}" team to "{1}" team. Are you sure? -team.confirm.moveUser=Are you sure you want to move this user from "{0}" team to "{1}" team? -team.userAdded=User successfully added to team -team.back=Back to Teams -team.internal=Internal Team -team.internalTeamNotAccessible=The Internal team is a system team and cannot be accessed -team.cannotMoveInternalUsers=Users in the Internal team cannot be moved to other teams -team.hidden=Hidden -team.name=Team Name -team.totalMembers=Total Members -team.members=Members -team.username=Username -team.role=Role +team.addUser=Dodaj korisnika timu +team.selectUser=Izaberi korisnika +team.warning.moveUser=Upozorenje: Ovo će prebaciti korisnika iz "{0}" tima u "{1}" tim. Jesi li siguran? +team.confirm.moveUser=Jesi li siguran da želiš da prebaciš ovog korisnika iz "{0}" tima u "{1}" tim? +team.userAdded=Korisnik uspešno pridružen timu +team.back=Povratak na timove +team.internal=Interni tim +team.internalTeamNotAccessible=Interni tim je sistemski tim i ne može mu se pristupiti +team.cannotMoveInternalUsers=Korisnici iz internog tima ne mogu biti premešteni u drugi tim +team.hidden=Skriven +team.name=Naziv tima +team.totalMembers=Ukupno članova +team.members=Članovi +team.username=Korisničko ime +team.role=Uloga team.status=Status -team.enabled=Enabled -team.disabled=Disabled -team.noMembers=This team has no members yet. +team.enabled=Omogućen +team.disabled=Onemogućen +team.noMembers=Ovaj tim još uvek nema članova -endpointStatistics.title=Endpoint Statistics -endpointStatistics.header=Endpoint Statistics +endpointStatistics.title=Statistika krajnjih tačaka +endpointStatistics.header=Statistika krajnjih tačaka endpointStatistics.top10=Top 10 endpointStatistics.top20=Top 20 -endpointStatistics.all=All -endpointStatistics.refresh=Refresh -endpointStatistics.includeHomepage=Include Homepage ('/') -endpointStatistics.includeLoginPage=Include Login Page ('/login') -endpointStatistics.totalEndpoints=Total Endpoints -endpointStatistics.totalVisits=Total Visits -endpointStatistics.showing=Showing -endpointStatistics.selectedVisits=Selected Visits -endpointStatistics.endpoint=Endpoint -endpointStatistics.visits=Visits -endpointStatistics.percentage=Percentage -endpointStatistics.loading=Loading... -endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing. -endpointStatistics.home=Home -endpointStatistics.login=Login +endpointStatistics.all=Svi +endpointStatistics.refresh=Osveži +endpointStatistics.includeHomepage=Uključi početnu stranu ('/') +endpointStatistics.includeLoginPage=Uključi stranu za prijavu ('/login') +endpointStatistics.totalEndpoints=Ukupno krajnjih tačaka +endpointStatistics.totalVisits=Ukupno poseta +endpointStatistics.showing=Prikaz +endpointStatistics.selectedVisits=Izabrane posete +endpointStatistics.endpoint=Krajnja tačka +endpointStatistics.visits=Poseta +endpointStatistics.percentage=Procenat +endpointStatistics.loading=Učitavam... +endpointStatistics.failedToLoad=Neuspešno učitavanje podataka o krajnjoj tačci. Pokušaj da osvežiš. +endpointStatistics.home=Početna strana +endpointStatistics.login=Prijava endpointStatistics.top=Top -endpointStatistics.numberOfVisits=Number of Visits -endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total) -endpointStatistics.retry=Retry +endpointStatistics.numberOfVisits=Broj poseta +endpointStatistics.visitsTooltip=Poseta: {0} ({1}% od ukupno) +endpointStatistics.retry=Pokušaj ponovo -database.title=Database Import/Export -database.header=Database Import/Export -database.fileName=File Name -database.creationDate=Creation Date -database.fileSize=File Size -database.deleteBackupFile=Delete Backup File -database.importBackupFile=Import Backup File -database.createBackupFile=Create Backup File -database.downloadBackupFile=Download Backup File -database.info_1=When importing data, it is crucial to ensure the correct structure. If you are unsure of what you are doing, seek advice and support from a professional. An error in the structure can cause application malfunctions, up to and including the complete inability to run the application. -database.info_2=The file name does not matter when uploading. It will be renamed afterward to follow the format backup_user_yyyyMMddHHmm.sql, ensuring a consistent naming convention. -database.submit=Import Backup -database.importIntoDatabaseSuccessed=Import into database successed -database.backupCreated=Database backup successful -database.fileNotFound=File not Found -database.fileNullOrEmpty=File must not be null or empty -database.failedImportFile=Failed Import File -database.notSupported=This function is not available for your database connection. +database.title=Uvoz/izvoz baze +database.header=Uvoz/izvoz baze +database.fileName=Ime datoteke +database.creationDate=Datum kreiranja +database.fileSize=Veličina datoteke +database.deleteBackupFile=Obriši rezervnu kopiju +database.importBackupFile=Uvezi rezervnu kopiju +database.createBackupFile=Kreiraj rezervnu kopiju +database.downloadBackupFile=Preuzmi rezervnu kopiju +database.info_1=Prilikom uvoza podataka, od suštinskog je značaja obezbediti ispravnu strukturu. Ako nisi siguran u ono što radiš, potraži savet i podršku stručnog lica. Greška u strukturi može izazvati neispravno funkcionisanje aplikacije, pa čak i potpunu nemogućnost njenog pokretanja. +database.info_2=Naziv fajla prilikom otpremanja nije bitan. Fajl će kasnije biti preimenovan u format backup_user_yyyyMMddHHmm.sql, kako bi se obezbedila dosledna konvencija imenovanja. +database.submit=Uvezi rezervnu kopiju +database.importIntoDatabaseSuccessed=Uvoz u bazu uspešan +database.backupCreated=Rezervna kopija baze podataka je uspešna napravljena +database.fileNotFound=Datoteka nije pronađena +database.fileNullOrEmpty=Datoteka ne sme biti null ili prazna +database.failedImportFile=Neuspešan uvoz datoteke +database.notSupported=Ova funkcija nije dostupna za tvoju vezu sa bazom podataka. -session.expired=Your session has expired. Please refresh the page and try again. -session.refreshPage=Refresh Page +session.expired=Istekla ti je sesija. Osveži stranicu i pokušaj ponovo. +session.refreshPage=Osveži stranicu ############# # HOME-PAGE # ############# -home.desc=Vaš lokalno hostovan jedinstveni alat za sve vaše potrebe vezane za PDF. +home.desc=Lokalno hostovano rešenje koje za sve tvoje PDF potrebe na jednom mestu. home.searchBar=Pretraži funkcije... -home.viewPdf.title=View/Edit PDF -home.viewPdf.desc=Pregledaj, anotiraj, dodaj tekst ili slike -viewPdf.tags=pregled,čitanje,anotiranje,tekst,slika +home.viewPdf.title=Pogledaj/izmeni PDF +home.viewPdf.desc=Pregled, komentarisanje, crtanje, dodavanje teksta ili slika +viewPdf.tags=pregledaj,čitaj,komentariši,tekst,slika,istakni,izmeni -home.setFavorites=Set Favourites -home.hideFavorites=Hide Favourites -home.showFavorites=Show Favourites -home.legacyHomepage=Old homepage -home.newHomePage=Try our new homepage! -home.alphabetical=Alphabetical -home.globalPopularity=Global Popularity -home.sortBy=Sort by: +home.setFavorites=Podesi omiljene +home.hideFavorites=Sakrij omiljene +home.showFavorites=Prikaži omiljene +home.legacyHomepage=Stara početna strana +home.newHomePage=Isprobajte našu novu početnu stranu! +home.alphabetical=Abecedno +home.globalPopularity=Globalna popularnost +home.sortBy=Sortiranje: -home.multiTool.title=PDF Multi Alat -home.multiTool.desc=Spajanje, rotacija, premeštanje i uklanjanje stranica -multiTool.tags=Multi Alat,Multi operacija,Korisnički interfejs,klik i povuci,front end,klijentska strana,interaktivno,pomera +home.multiTool.title=PDF višenamenski alat +home.multiTool.desc=Spajanje, rotiranje, premeštanje, deljenje i uklanjanje stranica +multiTool.tags=Višenamenski alat,Višestruke operacije,korisnički interfejs,prevuci i pusti,frontend,klijentska strana,interaktivno,pomeri,obriši,migiraj,podeli home.merge.title=Spajanje -home.merge.desc=Lako spojite više PDF-ova u jedan. -merge.tags=spajanje,Operacije sa stranicama,Backend,server strana +home.merge.desc=Lako spajanje više PDF-ova u jedan +merge.tags=spajanje,Operacije sa stranicama,Bekend,serverska strana home.split.title=Razdvajanje -home.split.desc=Razdvojite PDF-ove u više dokumenata -split.tags=Operacije sa stranicama,podela,Višestruke stranice,sečenje,server strana +home.split.desc=Razdvajanje PDF-ove u više dokumenata +split.tags=Operacije sa stranicama,podela,Višestruke stranice,sečenje,serverska strana home.rotate.title=Rotacija -home.rotate.desc=Lako rotirajte vaše PDF-ove. -rotate.tags=server strana +home.rotate.desc=Lako rotiranje PDF-ova +rotate.tags=serverska strana home.imageToPdf.title=Slika u PDF -home.imageToPdf.desc=Konvertujte sliku (PNG, JPEG, GIF) u PDF. +home.imageToPdf.desc=Konvertovanje slika (PNG, JPEG, GIF) u PDF imageToPdf.tags=konverzija,img,jpg,slika,foto home.pdfToImage.title=PDF u Sliku -home.pdfToImage.desc=Konvertujte PDF u sliku. (PNG, JPEG, GIF) +home.pdfToImage.desc=Konvertovanje PDF u sliku (PNG, JPEG, GIF) pdfToImage.tags=konverzija,img,jpg,slika,foto home.pdfOrganiser.title=Organizacija -home.pdfOrganiser.desc=Uklonite/Premeštajte stranice u bilo kom redosledu -pdfOrganiser.tags=duplex,even,odd,sort,move +home.pdfOrganiser.desc=Uklanjanje/premeštanje stranica po bilo kom redosledu +pdfOrganiser.tags=dupleks,parne,neparne,sortiranje,pomeranje home.addImage.title=Dodaj sliku -home.addImage.desc=Dodaje sliku na određeno mesto u PDF-u +home.addImage.desc=Dodavanje slike na željeno mesto u PDF-u addImage.tags=img,jpg,slika,foto -home.watermark.title=Dodaj vodeni žig -home.watermark.desc=Dodajte prilagođeni vodeni žig na vaš PDF dokument. -watermark.tags=Tekst,ponavljanje,etiketa,vlastiti,autorsko pravo,zaštita, img,jpg,slika,foto +home.attachments.title=Dodaj priloge +home.attachments.desc=Dodavanje ili uklanjanje uključenih datoteka (priloga) u/iz PDF-a +attachments.tags=uključi,dodaj,datoteka,prilog,prilozi -home.permissions.title=Promeni dozvole -home.permissions.desc=Promenite dozvole vašeg PDF dokumenta +home.watermark.title=Dodaj vodeni žig +home.watermark.desc=Dodavanje prilagođenog vodenog žiga u PDF dokument +watermark.tags=Tekst,ponavljanje,etiketa,vlastiti,autorsko pravo,kopirajt,logo,img,jpg,slika,foto + +home.permissions.title=Podešavanje dozvola +home.permissions.desc=Izmena dozvola PDF dokumenta permissions.tags=čitanje,pisanje,izmena,štampa home.removePages.title=Ukloni -home.removePages.desc=Obrišite nepotrebne stranice iz vašeg PDF dokumenta. +home.removePages.desc=Brisanje nepotrebnih stranice iz PDF dokumenta removePages.tags=Ukloni stranice,obriši stranice -home.addPassword.title=Dodaj lozinku -home.addPassword.desc=Enkriptujte vaš PDF dokument lozinkom. +home.addPassword.title=Dodaj šifru +home.addPassword.desc=Zaštita PDF dokumenata šifrom addPassword.tags=bezbedno,zaštita home.removePassword.title=Ukloni lozinku -home.removePassword.desc=Uklonite zaštitu lozinkom sa vašeg PDF dokumenta. +home.removePassword.desc=Uklanjanje lozinke iz PDF dokumenta removePassword.tags=bezbedno,Dešifruj,zaštita,ukloni lozinku home.compressPdfs.title=Kompresuj -home.compressPdfs.desc=Kompresujte PDF-ove kako bi smanjili veličinu fajla. +home.compressPdfs.desc=Kompresovanje PDF-ova radi smanjenja veličine datoteke compressPdfs.tags=smanji,mali,minijaturni -home.unlockPDFForms.title=Unlock PDF Forms -home.unlockPDFForms.desc=Remove read-only property of form fields in a PDF document. -unlockPDFForms.tags=remove,delete,form,field,readonly +home.unlockPDFForms.title=Otključaj PDF obrazac +home.unlockPDFForms.desc=Uklanjanje oznake samo-za-čitanje sa svih polja PDF obrasca +unlockPDFForms.tags=ukloni,obriši,obrazac,polje,samo za čitanje -home.changeMetadata.title=Promena metapodataka -home.changeMetadata.desc=Promenite/Uklonite/Dodajte metapodatke u PDF dokumentu +home.changeMetadata.title=Izmena metapodataka +home.changeMetadata.desc=Izmena/uklanjanje/dodavanje metapodataka u PDF dokumentu changeMetadata.tags=Naslov,autor,datum,kreacije,vreme,izdavač,proizvođač,statistike -home.fileToPDF.title=Konvertuj fajl u PDF -home.fileToPDF.desc=Konvertujte gotovo bilo koji fajl u PDF (DOCX, PNG, XLS, PPT, TXT i više) +home.fileToPDF.title=Konvertuj datoteku u PDF +home.fileToPDF.desc=Konvertovanje gotovo bilo kojih datoteka u PDF (DOCX, PNG, XLS, PPT, TXT i drugih) fileToPDF.tags=transformacija,format,dokument,slika,slajd,tekst,konverzija,office,docs,word,excel,powerpoint home.ocr.title=OCR / Čišćenje skenova -home.ocr.desc=Čišćenje skenova i detektovanje teksta sa slika unutar PDF-a i ponovno dodavanje kao teksta. +home.ocr.desc=Čišćenje skenova i detektovanje teksta na slikama unutar PDF-a i ponovno dodavanje kao teksta ocr.tags=prepoznavanje,tekst,slika,sken,čitanje,identifikacija,detekcija,uređivanje home.extractImages.title=Izvuci slike -home.extractImages.desc=Izvlači sve slike iz PDF-a i čuva ih u zip formatu +home.extractImages.desc=Izvlačenje svih slika iz PDF-a i kompresovanje u zip format extractImages.tags=slika,foto,sačuvaj,arhiva,zip,zahvati,uhvati home.pdfToPDFA.title=PDF u PDF/A -home.pdfToPDFA.desc=Konvertujte PDF u PDF/A za dugoročno čuvanje +home.pdfToPDFA.desc=Konvertovanje PDF u PDF/A za dugoročno čuvanje pdfToPDFA.tags=arhiva,dugoročno,standard,konverzija,čuvanje,čuvanje home.PDFToWord.title=PDF u Word -home.PDFToWord.desc=Konvertujte PDF u Word formate (DOC, DOCX i ODT) +home.PDFToWord.desc=Konvertovanje PDF u Word formate (DOC, DOCX i ODT) PDFToWord.tags=doc,docx,odt,word,transformacija,format,konverzija,office,microsoft,docfile -home.PDFToPresentation.title=PDF u Prezentaciju -home.PDFToPresentation.desc=Konvertujte PDF u formate za prezentaciju (PPT, PPTX i ODP) +home.PDFToPresentation.title=PDF u prezentaciju +home.PDFToPresentation.desc=Konvertovanje PDF u formate za prezentaciju (PPT, PPTX i ODP) PDFToPresentation.tags=slajdovi,prikaz,office,microsoft -home.PDFToText.title=PDF u RTF (Tekst) -home.PDFToText.desc=Konvertujte PDF u tekst ili RTF format -PDFToText.tags=richformat,richtextformat,rich text format +home.PDFToText.title=PDF u RTF (tekst) +home.PDFToText.desc=Konvertovanje PDF u tekst ili RTF format +PDFToText.tags=richformat,richtextformat,rich tekst format home.PDFToHTML.title=PDF u HTML -home.PDFToHTML.desc=Konvertujte PDF u HTML format -PDFToHTML.tags=web sadržaj,prijateljski za pretraživače +home.PDFToHTML.desc=Konvertovanje PDF u HTML format +PDFToHTML.tags=web sadržaj,pogodno za pretraživače home.PDFToXML.title=PDF u XML -home.PDFToXML.desc=Konvertujte PDF u XML format +home.PDFToXML.desc=Konvertovanje PDF u XML format PDFToXML.tags=izdvajanje-podataka,strukturirani-sadržaj,interop,transformacija,konvertovanje -home.ScannerImageSplit.title=Detekcija/Razdvajanje skeniranih fotografija -home.ScannerImageSplit.desc=Razdvaja više fotografija unutar slike/PDF-a +home.ScannerImageSplit.title=Detekcija/razdvajanje skeniranih fotografija +home.ScannerImageSplit.desc=Razdvajanje više fotografija unutar slike/PDF-a ScannerImageSplit.tags=razdvoji,auto-detekcija,skeniranja,višestruke fotografije,organizacija home.sign.title=Potpis -home.sign.desc=Dodaje potpis u PDF crtežom, tekstom ili slikom +home.sign.desc=Dodavanje potpisa u PDF crtežom, tekstom ili slikom sign.tags=autorizacija,inicijali,crtani-potpis,tekstualni-potpis,slikovni-potpis home.flatten.title=Ravnanje -home.flatten.desc=Uklanja sve interaktivne elemente i forme iz PDF-a +home.flatten.desc=Uklanjanje svih interaktivnih elemenata i formi iz PDF-a flatten.tags=statično,deaktivirati,neinteraktivno,usmeriti home.repair.title=Popravi -home.repair.desc=Pokušava popraviti oštećeni/izgubljeni PDF +home.repair.desc=Popravljanje oštećenih/izgubljenih PDF-ova repair.tags=popravi,vrati,korekcija,obnovi home.removeBlanks.title=Ukloni prazne stranice -home.removeBlanks.desc=Detektuje i uklanja prazne stranice iz dokumenta +home.removeBlanks.desc=Detekcija i uklanjanje praznih stranica iz dokumenta removeBlanks.tags=čišćenje,usmeriti,ne-sadržaj,organizacija -home.removeAnnotations.title=Ukloni Anotacije -home.removeAnnotations.desc=Uklanja sve komentare/anotacije iz PDF-a +home.removeAnnotations.title=Ukloni beleške +home.removeAnnotations.desc=Uklanjanje svih beleški/anotacije iz PDF dokumenta removeAnnotations.tags=komentari,isticanje,beleške,oznake,ukloni home.compare.title=Uporedi -home.compare.desc=Upoređuje i prikazuje razlike između 2 PDF dokumenata +home.compare.desc=Upoređivanje i prikazivanje razlika između dva PDF dokumenata compare.tags=razlikovati,kontrast,izmene,analiza -home.certSign.title=Potpis sa sertifikatom -home.certSign.desc=Potpisuje PDF sa sertifikatom/ključem (PEM/P12) +home.certSign.title=Potpis sertifikatom +home.certSign.desc=Potpisivanje PDF dokumenta sertifikatom/ključem (PEM/P12) certSign.tags=autentifikacija,PEM,P12,zvanično,šifrovanje -home.removeCertSign.title=Remove Certificate Sign -home.removeCertSign.desc=Remove certificate signature from PDF -removeCertSign.tags=authenticate,PEM,P12,official,decrypt +home.removeCertSign.title=Uklanjanje digitalnog potpisa +home.removeCertSign.desc=Uklanjanje digitalnog potpisa sa sertifikatom iz PDF-a +removeCertSign.tags=autentifikacija,PEM,P12,zvanični,dekripcija home.pageLayout.title=Višestruki prikaz stranica -home.pageLayout.desc=Spaja više stranica PDF dokumenta u jednu stranicu +home.pageLayout.desc=Spajanje više stranica PDF dokumenta u jednu stranicu pageLayout.tags=spajanje,kompozit,pojedinačan-prikaz,organizacija -home.scalePages.title=Podesi veličinu/skalu stranice -home.scalePages.desc=Podesi veličinu/skalu stranice i/ili njenog sadržaja. +home.scalePages.title=Podesi veličinu/razmeru stranice +home.scalePages.desc=Promena veličine/rezmere stranice i/ili njenog sadržaja scalePages.tags=izmena,modifikacija,dimenzija,adaptacija -home.pipeline.title=Pipeline (Napredno) -home.pipeline.desc=Pokreće više akcija na PDF-ovima definisanjem skripti u pipelinu +home.pipeline.title=Tok rada +home.pipeline.desc=Izvršavanje više radnji nad PDF dokumentima pomoću definisanih skripti za tok rada pipeline.tags=automatizacija,sekvenciranje,skriptirano,batch-process home.add-page-numbers.title=Dodaj brojeve stranica -home.add-page-numbers.desc=Dodaje brojeve stranica u dokumentu na određeno mesto +home.add-page-numbers.desc=Dodavanje brojeva stranica u dokumentu na željeno mesto add-page-numbers.tags=paginacija,oznaka,organizacija,indeks -home.auto-rename.title=Automatsko preimenovanje PDF fajla -home.auto-rename.desc=Automatski menja ime PDF fajla na osnovu detektovanog zaglavlja +home.auto-rename.title=Automatska promena imena PDF datoteke +home.auto-rename.desc=Automatsko menjanje imena PDF datoteke na osnovu detektovanog zaglavlja auto-rename.tags=auto-detekcija,zaglavlje-bazirano,organizacija,preimenovanje home.adjust-contrast.title=Podesi boje/kontrast -home.adjust-contrast.desc=Podesi kontrast, zasićenost i osvetljenost PDF-a +home.adjust-contrast.desc=Podešavanje kontrasta, zasićenosti i osvetljenost PDF-a adjust-contrast.tags=korekcija-boja,podešavanje,modifikacija,unapredi home.crop.title=Skraćivanje PDF-a -home.crop.desc=Skraćuje PDF radi smanjenja veličine (zadržava tekst!) +home.crop.desc=Skraćivanje PDF dokumenta radi smanjenja veličine (zadržava tekst!) crop.tags=trimovanje,skupljanje,uređivanje,oblikovanje -home.autoSplitPDF.title=Automatsko razdvajanje stranica -home.autoSplitPDF.desc=Automatski deli skenirane PDF-ove pomoću fizičkog skenera QR koda +home.autoSplitPDF.title=Automatski razdvoj PDF +home.autoSplitPDF.desc=Automatska podela skeniranog PDF-a korišćenjem fizičkog QR koda za razdvajanje stranica autoSplitPDF.tags=QR-bazirano,razdvoji,segment-skeniranja,organizacija home.sanitizePdf.title=Sanitizacija -home.sanitizePdf.desc=Uklanja skripte i druge elemente iz PDF fajlova +home.sanitizePdf.desc=Uklanjanje skripti i drugih elemente iz PDF dokumenata sanitizePdf.tags=čišćenje,bezbednost,bezbedno,ukloni-pretnje home.URLToPDF.title=URL/Website u PDF @@ -672,115 +676,115 @@ home.URLToPDF.desc=Konvertuje bilo koji http(s) URL u PDF URLToPDF.tags=uhvati-web,sačuvaj-stranicu,web-u-doc,arhiva home.HTMLToPDF.title=HTML u PDF -home.HTMLToPDF.desc=Konvertuje bilo koji HTML fajl ili zip u PDF +home.HTMLToPDF.desc=Konvertovanje bilo koje HTML datoteke ili zip u PDF HTMLToPDF.tags=oznake,web-sadržaj,transformacija,konvertovanje #eml-to-pdf -home.EMLToPDF.title=Email to PDF -home.EMLToPDF.desc=Converts email (EML) files to PDF format including headers, body, and inline images -EMLToPDF.tags=email,conversion,eml,message,transformation,convert,mail +home.EMLToPDF.title=Email u PDF +home.EMLToPDF.desc=Konvertovanje email (EML) datoteka u PDF format uključujući zaglavlje, telo poruke i ugrađene slike +EMLToPDF.tags=email,konverzija,eml,poruka,transformacija,konvertovanje,elektronska poruka -EMLToPDF.title=Email To PDF -EMLToPDF.header=Email To PDF -EMLToPDF.submit=Convert -EMLToPDF.downloadHtml=Download HTML intermediate file instead of PDF -EMLToPDF.downloadHtmlHelp=This allows you to see the HTML version before PDF conversion and can help debug formatting issues -EMLToPDF.includeAttachments=Include attachments in PDF -EMLToPDF.maxAttachmentSize=Maximum attachment size (MB) -EMLToPDF.help=Converts email (EML) files to PDF format including headers, body, and inline images -EMLToPDF.troubleshootingTip1=Email to HTML is a more reliable process, so with batch-processing it is recommended to save both -EMLToPDF.troubleshootingTip2=With a small number of Emails, if the PDF is malformed, you can download HTML and override some of the problematic HTML/CSS code. -EMLToPDF.troubleshootingTip3=Embeddings, however, do not work with HTMLs +EMLToPDF.title=Email u PDF +EMLToPDF.header=Email u PDF +EMLToPDF.submit=Konvertuj +EMLToPDF.downloadHtml=Preuzmi HTML međufajl umesto PDF-a +EMLToPDF.downloadHtmlHelp=Ovo omogućava da se vidi HTML verzija pre konverzije u PDF i može pomoći u otklanjanju problema sa formatiranjem. +EMLToPDF.includeAttachments=Uključi dodatke u PDF +EMLToPDF.maxAttachmentSize=Maksimalna veličina dodatka (MB) +EMLToPDF.help=Konvertovanje email (EML) datoteka u PDF format uključujući zaglavlje, telo poruke i ugrađene slike +EMLToPDF.troubleshootingTip1=Konverzija emaila u HTML je pouzdaniji proces, te se kod serijske obrade preporučuje čuvanje oba formata. +EMLToPDF.troubleshootingTip2=Kod malog broja emailova, ako je PDF neispravan, moguće je preuzeti HTML i ispraviti deo problematičnog HTML/CSS koda. +EMLToPDF.troubleshootingTip3=Ugradnja sadržaja, međutim, ne funkcioniše sa HTML fajlovima. home.MarkdownToPDF.title=Markdown u PDF -home.MarkdownToPDF.desc=Konvertuje bilo koji Markdown fajl u PDF +home.MarkdownToPDF.desc=Konvertovanje Markdown datoteka u PDF MarkdownToPDF.tags=oznake,web-sadržaj,transformacija,konvertovanje -home.PDFToMarkdown.title=PDF to Markdown -home.PDFToMarkdown.desc=Converts any PDF to Markdown -PDFToMarkdown.tags=markup,web-content,transformation,convert,md +home.PDFToMarkdown.title=PDF u Markdown +home.PDFToMarkdown.desc=Konvertovanje PDF datoteka u Markdown +PDFToMarkdown.tags=markup,web-sadržaj,transformacija,konvertovanje,md -home.getPdfInfo.title=Dohvati SVE informacije o PDF-u -home.getPdfInfo.desc=Dobavlja sve moguće informacije o PDF-ovima +home.getPdfInfo.title=Prikazi sve informacije o PDF-u +home.getPdfInfo.desc=Prikazivanje svih mogućih informacija o PDF datotekama getPdfInfo.tags=informacije,podaci,statistike home.extractPage.title=Izdvajanje stranica -home.extractPage.desc=Izdvaja odabrane stranice iz PDF-a +home.extractPage.desc=Izdvajanje odabranih stranice iz PDF dokumenta extractPage.tags=izdvajanje -home.PdfToSinglePage.title=PDF u Jednu Veliku Stranicu -home.PdfToSinglePage.desc=Spaja sve stranice PDF-a u jednu veliku stranicu +home.PdfToSinglePage.title=PDF u jednu veliku stranicu +home.PdfToSinglePage.desc=Spajanje svih stranica u PDF-u u jednu veliku stranicu PdfToSinglePage.tags=jedna-stranica home.showJS.title=Prikaži JavaScript -home.showJS.desc=Pretražuje i prikazuje bilo koji JavaScript ubačen u PDF +home.showJS.desc=Pretraživanje i prikaz JavaScriptova ubačenih u PDF showJS.tags=JS -home.autoRedact.title=Automatsko Cenzurisanje +home.autoRedact.title=Automatsko cenzurisanje home.autoRedact.desc=Automatsko cenzurisanje teksta u PDF-u na osnovu unetog teksta -autoRedact.tags=Cenzura,Sakrij,prekrivanje,crna,marker,skriveno +autoRedact.tags=Redakcija,Sakrij,prekrivanje,crna,marker,skriveno -home.redact.title=Manual Redaction -home.redact.desc=Redacts a PDF based on selected text, drawn shapes and/or selected page(s) -redact.tags=Redact,Hide,black out,black,marker,hidden,manual +home.redact.title=Ručna redakcija +home.redact.desc=Redakcija PDF dokumenta na osnovu izabranog teksta, nacrtanih oblika i/ili izabranih strana +redact.tags=Redakcija,Sakrij,zacrnjivanje,crno,marker,skrfiveno,ručno home.tableExtraxt.title=PDF u CSV -home.tableExtraxt.desc=Izdvaja tabele iz PDF-a pretvarajući ih u CSV +home.tableExtraxt.desc=Izdvajanje tabele iz PDF-a pretvarajući ih u CSV tableExtraxt.tags=CSV,Izdvajanje tabela,izdvajanje,konvertovanje -home.autoSizeSplitPDF.title=Automatsko Deljenje po Veličini/Broju -home.autoSizeSplitPDF.desc=Deljenje jednog PDF-a na više dokumenata na osnovu veličine, broja stranica ili broja dokumenata -autoSizeSplitPDF.tags=pdf,delenje,dokumenti,organizacija +home.autoSizeSplitPDF.title=Automatsko deljenje po veličini/broju +home.autoSizeSplitPDF.desc=Deljenje jednog PDF-a na više na osnovu veličine, broja stranica ili broja dokumenata +autoSizeSplitPDF.tags=pdf,deljenje,dokumenti,organizacija home.overlay-pdfs.title=Preklapanje PDF-ova -home.overlay-pdfs.desc=Preklapa PDF-ove jedan preko drugog +home.overlay-pdfs.desc=Preklapanje PDF dokumenata jedan preko drugog overlay-pdfs.tags=Preklapanje -home.split-by-sections.title=Deljenje PDF-a po Odeljcima -home.split-by-sections.desc=Deljenje svake stranice PDF-a na manje horizontalne i vertikalne odeljke -split-by-sections.tags=Deljenje odeljaka,Deljenje,Podešavanje +home.split-by-sections.title=Deljenje PDF-a po sekcijama +home.split-by-sections.desc=Deljenje svake stranice PDF-a na manje horizontalne i vertikalne sekcije +split-by-sections.tags=Deljenje sekcija,Deljenje,Podešavanje -home.AddStampRequest.title=Add Stamp to PDF -home.AddStampRequest.desc=Add text or add image stamps at set locations -AddStampRequest.tags=Stamp, Add image, center image, Watermark, PDF, Embed, Customize +home.AddStampRequest.title=Dodaj pečat u PDF +home.AddStampRequest.desc=Dodavanje teksta ili slike pečeta na željenim lokacijama +AddStampRequest.tags=Pečat, Dodaj sliku, centriraj sliku, Vodeni žig, PDF, Uključi, Prilagodi -home.removeImagePdf.title=Remove image -home.removeImagePdf.desc=Remove image from PDF to reduce file size -removeImagePdf.tags=Remove Image,Page operations,Back end,server side +home.removeImagePdf.title=Ukloni sliku +home.removeImagePdf.desc=Uklanjanje slike iz PDF-a u cilju smanjenja veličine datoteke +removeImagePdf.tags=Ukloni sliku, Zahvati na stranici, Bekend,serverska strana -home.splitPdfByChapters.title=Split PDF by Chapters -home.splitPdfByChapters.desc=Split a PDF into multiple files based on its chapter structure. -splitPdfByChapters.tags=split,chapters,bookmarks,organize +home.splitPdfByChapters.title=Podeli PDF po poglavljima +home.splitPdfByChapters.desc=Deljenje PDF-a na više datoteka na osnovu strukture poglavlja +splitPdfByChapters.tags=podeli,poglavlja,zabeleške,organizacija -home.validateSignature.title=Validate PDF Signature -home.validateSignature.desc=Verify digital signatures and certificates in PDF documents -validateSignature.tags=signature,verify,validate,pdf,certificate,digital signature,Validate Signature,Validate certificate +home.validateSignature.title=Proveri PDF potpis +home.validateSignature.desc=Verifikacija digitalnog potpisa i sertifikata u PDF dokumentu +validateSignature.tags=potpis,verifikacija,valdiacija,pdf,sertifikat,digitalni potpis,Validacija potpisa,Validacija sertifikata #replace-invert-color -replace-color.title=Replace-Invert-Color -replace-color.header=Replace-Invert Color PDF -home.replaceColorPdf.title=Replace and Invert Color -home.replaceColorPdf.desc=Replace color for text and background in PDF and invert full color of pdf to reduce file size -replaceColorPdf.tags=Replace Color,Page operations,Back end,server side -replace-color.selectText.1=Replace or Invert color Options -replace-color.selectText.2=Default(Default high contrast colors) -replace-color.selectText.3=Custom(Customized colors) -replace-color.selectText.4=Full-Invert(Invert all colors) -replace-color.selectText.5=High contrast color options -replace-color.selectText.6=white text on black background -replace-color.selectText.7=Black text on white background -replace-color.selectText.8=Yellow text on black background -replace-color.selectText.9=Green text on black background -replace-color.selectText.10=Choose text Color -replace-color.selectText.11=Choose background Color -replace-color.submit=Replace +replace-color.title=Napredna podešavanja boja +replace-color.header=Zameni/invertuj boju PDF-a +home.replaceColorPdf.title=Napredna podešavanja boja +home.replaceColorPdf.desc=Zameni boju teksta i pozadine u PDF i invertuj celokupnu boju PDF-a u cilju smanjenja veličine datoteke +replaceColorPdf.tags=Zameni boju,Operacije na stranici,Bekend,Serverska strana +replace-color.selectText.1=Opcije zamene ili inverzije boja: +replace-color.selectText.2=Podrazumevano (Podrazumevane boje visokog kontrasta) +replace-color.selectText.3=Prilagođeno (Prilagođene boje) +replace-color.selectText.4=Puna inverzija (Invertuj sve boje) +replace-color.selectText.5=Opcije boja visokog kontrasta: +replace-color.selectText.6=Beli tekst na crnoj pozadini +replace-color.selectText.7=Crni tekst na beloj pozadini +replace-color.selectText.8=Žuti tekst na crnoj pozadini +replace-color.selectText.9=Zeleni tekst na crnoj pozadini +replace-color.selectText.10=Izaberi boju teksta: +replace-color.selectText.11=Izaberi boju pozadine: +replace-color.submit=Zameni @@ -790,67 +794,67 @@ replace-color.submit=Replace # # ########################### #login -login.title=Prijavite se -login.header=Prijavite se -login.signin=Prijavite se +login.title=Prijavi se +login.header=Prijavi se +login.signin=Prijavi se login.rememberme=Zapamti me login.invalid=Neispravno korisničko ime ili lozinka. -login.locked=Vaš nalog je zaključan. -login.signinTitle=Molimo vas da se prijavite +login.locked=Nalog je zaključan. +login.signinTitle=Prijavite se login.ssoSignIn=Prijavite se putem jedinstvene prijave login.oAuth2AutoCreateDisabled=OAUTH2 automatsko kreiranje korisnika je onemogućeno -login.oAuth2AdminBlockedUser=Registration or logging in of non-registered users is currently blocked. Please contact the administrator. -login.oauth2RequestNotFound=Authorization request not found -login.oauth2InvalidUserInfoResponse=Invalid User Info Response -login.oauth2invalidRequest=Invalid Request -login.oauth2AccessDenied=Access Denied -login.oauth2InvalidTokenResponse=Invalid Token Response -login.oauth2InvalidIdToken=Invalid Id Token -login.relyingPartyRegistrationNotFound=No relying party registration found -login.userIsDisabled=User is deactivated, login is currently blocked with this username. Please contact the administrator. -login.alreadyLoggedIn=You are already logged in to -login.alreadyLoggedIn2=devices. Please log out of the devices and try again. -login.toManySessions=You have too many active sessions -login.logoutMessage=You have been logged out. +login.oAuth2AdminBlockedUser=Registracija ili prijava neregistrovanog korisnika je trenutno onemogućeno. Kontaktirajte administratora. +login.oauth2RequestNotFound=Zahtev za autorizaciju nije pronađen +login.oauth2InvalidUserInfoResponse=Neispravan odgovor sa korisničkim informacijama +login.oauth2invalidRequest=Neispravan zahtev +login.oauth2AccessDenied=Pristup odbijen +login.oauth2InvalidTokenResponse=Neispravan odgovor tokena +login.oauth2InvalidIdToken=Neispravan ID tokena +login.relyingPartyRegistrationNotFound=Nije pronađena registracija partnerske strane +login.userIsDisabled=Korisnik deaktiviran, prijava sa ovim korisničkim imenom je trenutno blokirana. Kontaktiraj administratora. +login.alreadyLoggedIn=Već si prijavljen na +login.alreadyLoggedIn2=uređaja. Odjavi se sa uređaja i pokušaj ponovo. +login.toManySessions=Imaš previše aktivnih sesija +login.logoutMessage=Odjavljen si. #auto-redact -autoRedact.title=Auto Cenzura -autoRedact.header=Auto Cenzura -autoRedact.colorLabel=Boja -autoRedact.textsToRedactLabel=Tekst za cenzurisanje (razdvojeni linijama) -autoRedact.textsToRedactPlaceholder=npr. \nPoverljivo \nVrhunski Tajno -autoRedact.useRegexLabel=Koristi Regex +autoRedact.title=Automatsko cenzurisanje +autoRedact.header=Automatko cenzurisanje +autoRedact.colorLabel=Boja: +autoRedact.textsToRedactLabel=Pojmovi za centurisanje (svaki u novi red ): +autoRedact.textsToRedactPlaceholder=npr. \nPoverljivo \nVrhunski tajno +autoRedact.useRegexLabel=Koristi regex autoRedact.wholeWordSearchLabel=Pretraga celih reči -autoRedact.customPaddingLabel=Dodatni prazan prostor -autoRedact.convertPDFToImageLabel=Konvertuj PDF u PDF-Image (koristi se za uklanjanje teksta iza okvira) +autoRedact.customPaddingLabel=Dodatni prazan prostor: +autoRedact.convertPDFToImageLabel=Konvertuj PDF u PDF-Image (koristi se za uklanjanje teksta iza zatamnjenja) autoRedact.submitButton=Potvrdi #redact -redact.title=Manual Redaction -redact.header=Manual Redaction -redact.submit=Redact -redact.textBasedRedaction=Text based Redaction -redact.pageBasedRedaction=Page-based Redaction -redact.convertPDFToImageLabel=Convert PDF to PDF-Image (Used to remove text behind the box) -redact.pageRedactionNumbers.title=Pages -redact.pageRedactionNumbers.placeholder=(e.g. 1,2,8 or 4,7,12-16 or 2n-1) -redact.redactionColor.title=Redaction Color -redact.export=Export -redact.upload=Upload -redact.boxRedaction=Box draw redaction -redact.zoom=Zoom -redact.zoomIn=Zoom in -redact.zoomOut=Zoom out -redact.nextPage=Next Page -redact.previousPage=Previous Page -redact.toggleSidebar=Toggle Sidebar -redact.showThumbnails=Show Thumbnails -redact.showDocumentOutline=Show Document Outline (double-click to expand/collapse all items) -redact.showAttatchments=Show Attachments -redact.showLayers=Show Layers (double-click to reset all layers to the default state) -redact.colourPicker=Colour Picker -redact.findCurrentOutlineItem=Find current outline item -redact.applyChanges=Apply Changes +redact.title=Ručna cenzura +redact.header=Ručna cenzura +redact.submit=Redaktuj +redact.textBasedRedaction=Redakcija zasnovana na tekstu +redact.pageBasedRedaction=Redakcija zasnovana na stranici +redact.convertPDFToImageLabel=Konvertuj PDF u PDF-Image (koristi se za uklanjanje teksta iza okvira) +redact.pageRedactionNumbers.title=Strane +redact.pageRedactionNumbers.placeholder=(npr. 1,2,8 ili 4,7,12-16 ili 2n-1) +redact.redactionColor.title=Boja redakcije +redact.export=Izvezi +redact.upload=Otpremi +redact.boxRedaction=Redakcija iscrtavanjem okvira +redact.zoom=Zumiranje +redact.zoomIn=Uvećaj +redact.zoomOut=Umanji +redact.nextPage=Sledeća strana +redact.previousPage=Prethodna strana +redact.toggleSidebar=Uključi/isključi bočnu traku +redact.showThumbnails=Prikaži sličice +redact.showDocumentOutline=Prikaži strukturu dokumenta (dvostruki klik za otvaranje/zatvaranje svih stavki) +redact.showAttatchments=Prikaži priloge +redact.showLayers=Prikaži slojeve (dvostruki klik za vraćanje svih slojeva na podrazumevano stanje) +redact.colourPicker=Izbor boje +redact.findCurrentOutlineItem=Pronađi aktuelnu stavku pregleda +redact.applyChanges=Primeni izmene #showJS showJS.title=Prikaži Javascript @@ -860,45 +864,45 @@ showJS.submit=Prikaži #pdfToSinglePage -pdfToSinglePage.title=PDF u Jednu Stranicu -pdfToSinglePage.header=PDF u Jednu Stranicu -pdfToSinglePage.submit=Konvertuj u Jednu Stranicu +pdfToSinglePage.title=PDF u jednu stranicu +pdfToSinglePage.header=PDF u jednu stranicu +pdfToSinglePage.submit=Konvertuj u jednu stranicu #pageExtracter pageExtracter.title=Izdvajanje stranica pageExtracter.header=Izdvajanje stranica -pageExtracter.submit=Izdvoji -pageExtracter.placeholder=(e.g. 1,2,8 or 4,7,12-16 or 2n-1) +pageExtracter.submit=Izdvoj +pageExtracter.placeholder=(npr. 1,2,8 ili 4,7,12-16 ili 2n-1) #getPdfInfo getPdfInfo.title=Informacije o PDF-u getPdfInfo.header=Informacije o PDF-u -getPdfInfo.submit=Informacije +getPdfInfo.submit=Prikaži getPdfInfo.downloadJson=Preuzmi JSON -getPdfInfo.summary=PDF Summary -getPdfInfo.summary.encrypted=This PDF is encrypted so may face issues with some applications -getPdfInfo.summary.permissions=This PDF has {0} restricted permissions which may limit what you can do with it -getPdfInfo.summary.compliance=This PDF complies with the {0} standard -getPdfInfo.summary.basicInfo=Basic Information -getPdfInfo.summary.docInfo=Document Information -getPdfInfo.summary.encrypted.alert=Encrypted PDF - This document is password protected -getPdfInfo.summary.not.encrypted.alert=Unencrypted PDF - No password protection -getPdfInfo.summary.permissions.alert=Restricted Permissions - {0} actions are not allowed -getPdfInfo.summary.all.permissions.alert=All Permissions Allowed -getPdfInfo.summary.compliance.alert={0} Compliant -getPdfInfo.summary.no.compliance.alert=No Compliance Standards -getPdfInfo.summary.security.section=Security Status -getPdfInfo.section.BasicInfo=Basic Information about the PDF document including file size, page count, and language -getPdfInfo.section.Metadata=Document metadata including title, author, creation date and other document properties -getPdfInfo.section.DocumentInfo=Technical details about the PDF document structure and version -getPdfInfo.section.Compliancy=PDF standards compliance information (PDF/A, PDF/X, etc.) -getPdfInfo.section.Encryption=Security and encryption details of the document -getPdfInfo.section.Permissions=Document permission settings that control what actions can be performed -getPdfInfo.section.Other=Additional document components like bookmarks, layers, and embedded files -getPdfInfo.section.FormFields=Interactive form fields present in the document -getPdfInfo.section.PerPageInfo=Detailed information about each page in the document +getPdfInfo.summary=PDF sažetak +getPdfInfo.summary.encrypted=Ovaj PDF je šifrovan, pa može doći do problema sa nekim aplikacijama +getPdfInfo.summary.permissions=Ovaj PDF ima {0} ograničenih dozvola koje mogu ograničiti mogućnosti rada sa njim +getPdfInfo.summary.compliance=Ovaj PDF zadovoljava specifikacije standarda {0} +getPdfInfo.summary.basicInfo=Osnovne informacije +getPdfInfo.summary.docInfo=Informacije o dokumentu +getPdfInfo.summary.encrypted.alert=Šifrovan PDF - Ovaj dokument je zaštićen lozinkom +getPdfInfo.summary.not.encrypted.alert=Nešifrovan PDF - Nije zaštićen lozinkom +getPdfInfo.summary.permissions.alert=Ograničene dozvole - {0} radnji nije dozvoljeno +getPdfInfo.summary.all.permissions.alert=Sve dozvole omogućene +getPdfInfo.summary.compliance.alert={0} u skladu +getPdfInfo.summary.no.compliance.alert=Bez usklađenih standarda +getPdfInfo.summary.security.section=Sigurnosni status +getPdfInfo.section.BasicInfo=Osnovne informacije o PDF dokumentu uključujući veličinu, broj strane i jezik +getPdfInfo.section.Metadata=Metapodaci dokumenta uključujući naslov, autora, datum kreiranja i druga svojstva dokumenta +getPdfInfo.section.DocumentInfo=Tehnički detalji o strukturi i verziji PDF dokumenta +getPdfInfo.section.Compliancy=Informacije o usklađenosti sa PDF standardima (PDF/A, PDF/X, itd.) +getPdfInfo.section.Encryption=Informacije o bezbednosti i šifrovanju dokumenta +getPdfInfo.section.Permissions=Postavke dozvola dokumenta koje određuju koje radnje su dozvoljene +getPdfInfo.section.Other=Dodatne komponente dokumenta kao što su obeleživači, slojevi i ugrađene datoteke +getPdfInfo.section.FormFields=Interaktivna polja formulara u dokumentu +getPdfInfo.section.PerPageInfo=Dodatne informacije o svakoj pojedinačnoj stranici u dokumentu #markdown-to-pdf @@ -910,9 +914,9 @@ MarkdownToPDF.credit=Koristi WeasyPrint #pdf-to-markdown -PDFToMarkdown.title=PDF To Markdown -PDFToMarkdown.header=PDF To Markdown -PDFToMarkdown.submit=Convert +PDFToMarkdown.title=PDF u Markdown +PDFToMarkdown.header=PDF u Markdown +PDFToMarkdown.submit=Konvertuj #url-to-pdf @@ -925,40 +929,40 @@ URLToPDF.credit=Koristi WeasyPrint #html-to-pdf HTMLToPDF.title=HTML u PDF HTMLToPDF.header=HTML u PDF -HTMLToPDF.help=Prihvata HTML fajlove i ZIP-ove koji sadrže html/css/slike itd. potrebno +HTMLToPDF.help=Podržava HTML datoteke i ZIP-ove koji sadrže html/css/slike itd. HTMLToPDF.submit=Konvertuj HTMLToPDF.credit=Koristi WeasyPrint -HTMLToPDF.zoom=Zoom level for displaying the website. -HTMLToPDF.pageWidth=Width of the page in centimeters. (Blank to default) -HTMLToPDF.pageHeight=Height of the page in centimeters. (Blank to default) -HTMLToPDF.marginTop=Top margin of the page in millimeters. (Blank to default) -HTMLToPDF.marginBottom=Bottom margin of the page in millimeters. (Blank to default) -HTMLToPDF.marginLeft=Left margin of the page in millimeters. (Blank to default) -HTMLToPDF.marginRight=Right margin of the page in millimeters. (Blank to default) -HTMLToPDF.printBackground=Render the background of websites. -HTMLToPDF.defaultHeader=Enable Default Header (Name and page number) -HTMLToPDF.cssMediaType=Change the CSS media type of the page. -HTMLToPDF.none=None -HTMLToPDF.print=Print -HTMLToPDF.screen=Screen +HTMLToPDF.zoom=Nivo zumiranja za prikaz web sajta: +HTMLToPDF.pageWidth=Širina stranice u centimetrima. (Prazno za podrazumevanu vrednost) +HTMLToPDF.pageHeight=Visina stranice u centimetrima. (Prazno za podrazumevanu vrednost) +HTMLToPDF.marginTop=Gornja margina stranice u milimetrima. (Prazno za podrazumevanu vrednost) +HTMLToPDF.marginBottom=Donja margina stranice u milimetrima. (Prazno za podrazumevanu vrednost) +HTMLToPDF.marginLeft=Leva margina stranice u milimetrima. (Prazno za podrazumevanu vrednost) +HTMLToPDF.marginRight=Desna margina stranice u milimetrima. (Prazno za podrazumevanu vrednost) +HTMLToPDF.printBackground=Prikaži pozadinu web sajta. +HTMLToPDF.defaultHeader=Omogući podrazumevano zaglavlje (Naziv i broj stranice) +HTMLToPDF.cssMediaType=Promeni tip medija za CSS na stranici. +HTMLToPDF.none=Nijedno +HTMLToPDF.print=Štampaj +HTMLToPDF.screen=Ekran #AddStampRequest -AddStampRequest.header=Stamp PDF -AddStampRequest.title=Stamp PDF -AddStampRequest.stampType=Stamp Type -AddStampRequest.stampText=Stamp Text -AddStampRequest.stampImage=Stamp Image -AddStampRequest.alphabet=Alphabet -AddStampRequest.fontSize=Font/Image Size -AddStampRequest.rotation=Rotation -AddStampRequest.opacity=Opacity -AddStampRequest.position=Position -AddStampRequest.overrideX=Override X Coordinate -AddStampRequest.overrideY=Override Y Coordinate -AddStampRequest.customMargin=Custom Margin -AddStampRequest.customColor=Custom Text Color -AddStampRequest.submit=Submit +AddStampRequest.header=Pečatiraj PDF +AddStampRequest.title=Dodavanje pečata u PDF +AddStampRequest.stampType=Tip pečeta: +AddStampRequest.stampText=Tekst pečata: +AddStampRequest.stampImage=Slika pečeta: +AddStampRequest.alphabet=Pismo: +AddStampRequest.fontSize=Veličina fonta/slike: +AddStampRequest.rotation=Rotacija: +AddStampRequest.opacity=Providnost: +AddStampRequest.position=Pozicija: +AddStampRequest.overrideX=Zameni X koordinatu: +AddStampRequest.overrideY=Zameni Y koordinatu: +AddStampRequest.customMargin=Podešavanje margina: +AddStampRequest.customColor=Željena boja teksta: +AddStampRequest.submit=Pošalji #sanitizePDF @@ -966,26 +970,26 @@ sanitizePDF.title=Sanitizacija PDF-a sanitizePDF.header=Sanitizacija PDF fajla sanitizePDF.selectText.1=Ukloni JavaScript akcije sanitizePDF.selectText.2=Ukloni ugrađene fajlove -sanitizePDF.selectText.3=Remove XMP metadata +sanitizePDF.selectText.3=Ukloni XMP metapodatke sanitizePDF.selectText.4=Ukloni linkove sanitizePDF.selectText.5=Ukloni fontove -sanitizePDF.selectText.6=Remove Document Info Metadata +sanitizePDF.selectText.6=Ukloni metapodatke informacija o dokumentu sanitizePDF.submit=Sanitizuj PDF #addPageNumbers -addPageNumbers.title=Dodavanje brojeva stranica +addPageNumbers.title=Numerisanje stranica addPageNumbers.header=Dodavanje brojeva stranica addPageNumbers.selectText.1=Izaberi PDF fajl: -addPageNumbers.selectText.2=Veličina margine -addPageNumbers.selectText.3=Pozicija -addPageNumbers.selectText.4=Početni broj -addPageNumbers.selectText.5=Brojane stranice -addPageNumbers.selectText.6=Prilagođeni tekst +addPageNumbers.selectText.2=Veličina margine: +addPageNumbers.selectText.3=Pozicija: +addPageNumbers.selectText.4=Početni broj: +addPageNumbers.selectText.5=Stranice za numerisanje: +addPageNumbers.selectText.6=Prilagođeni tekst: addPageNumbers.customTextDesc=Prilagođeni tekst addPageNumbers.numberPagesDesc=Koje stranice brojati, podrazumevano 'sve', takođe prihvata 1-5 ili 2,5,9 itd. addPageNumbers.customNumberDesc=Podrazumevano je {n}, takođe prihvata 'Stranica {n} od {ukupno}', 'Tekst-{n}', '{ime_fajla}-{n}' -addPageNumbers.submit=Dodaj brojeve stranica +addPageNumbers.submit=Numeriši #auto-rename @@ -995,8 +999,8 @@ auto-rename.submit=Automatsko preimenovanje #adjustContrast -adjustContrast.title=Podesi Kontrast -adjustContrast.header=Podesi Kontrast +adjustContrast.title=Podesi kontrast +adjustContrast.header=Podesi kontrast adjustContrast.contrast=Kontrast: adjustContrast.brightness=Osvetljenje: adjustContrast.saturation=Zasićenje: @@ -1005,22 +1009,22 @@ adjustContrast.download=Preuzmi #crop crop.title=Iseci -crop.header=Skraćivanje PDF-a +crop.header=Iseci PDF crop.submit=Potvrdi #autoSplitPDF -autoSplitPDF.title=Automatsko Deljenje PDF-a -autoSplitPDF.header=Automatsko Deljenje PDF-a -autoSplitPDF.description=Štampajte, umetnite, skenirajte, učitajte i dozvolite nam da automatski razdvojimo vaše dokumente. Nije potrebno ručno sortiranje. -autoSplitPDF.selectText.1=Odštampajte nekoliko listova razdeljivača ispod (Crno-belo je u redu). -autoSplitPDF.selectText.2=Skenirajte sve vaše dokumente odjednom, ubacivanjem lista razdeljivača između njih. -autoSplitPDF.selectText.3=Učitajte jedan veliki skenirani PDF fajl i dozvolite Stirling PDF-u da obavi ostalo. -autoSplitPDF.selectText.4=Listovi razdeljivača se automatski detektuju i uklanjaju, obezbeđujući uredan konačni dokument. -autoSplitPDF.formPrompt=Potvrdite PDF koji sadrži Stirling-PDF listove razdeljivača: +autoSplitPDF.title=Automatsko razdvajanje PDF-a +autoSplitPDF.header=Automatsko razdvajanje PDF-a +autoSplitPDF.description=Odštampaj, ubaci, skeniraj, otpremi i prepusti nama automatsko razdvajanje dokumenata. Nije potrebno ručno sortiranje. +autoSplitPDF.selectText.1=Odštampaj neki od razdelnika sa liste ispod (Crno-belo je u redu). +autoSplitPDF.selectText.2=Skeniraj sve dokumente odjednom, ubacivanjem lista razdelnika između njih. +autoSplitPDF.selectText.3=Otpremi jedan veliki skenirani PDF fajl i dozvoli Stirling PDF-u da obavi ostalo. +autoSplitPDF.selectText.4=Listovi razdelnici se automatski detektuju i uklanjaju, obezbeđujući uredan konačni dokument. +autoSplitPDF.formPrompt=Pošalji PDF koji sadrži Stirling-PDF listove razdelnike stranica: autoSplitPDF.duplexMode=Dupleks režim (skeniranje prednje i zadnje strane) -autoSplitPDF.dividerDownload2=Preuzmi 'Auto Splitter Divider (sa uputstvima).pdf' -autoSplitPDF.submit=Potvrdi +autoSplitPDF.dividerDownload2=Preuzmi 'Auto Splitter Divider (with instructions).pdf' +autoSplitPDF.submit=Razdvoj #pipeline @@ -1028,295 +1032,301 @@ pipeline.title=Tok rada #pageLayout -pageLayout.title=Višestruki Raspored Stranica -pageLayout.header=Višestruki Raspored Stranica +pageLayout.title=Višestranični raspored +pageLayout.header=Višestranični raspored pageLayout.pagesPerSheet=Stranica po listu: pageLayout.addBorder=Dodaj ivice pageLayout.submit=Potvrdi #scalePages -scalePages.title=Podesi razmeru stranica -scalePages.header=Podesi razmeru stranica -scalePages.pageSize=Veličina stranice dokumenta. -scalePages.keepPageSize=Original Size -scalePages.scaleFactor=Nivo zumiranja (rezanje) stranice. +scalePages.title=Podesi razmeru stranice +scalePages.header=Podesi razmeru stranice +scalePages.pageSize=Veličina stranice dokumenta: +scalePages.keepPageSize=Originalna veličina +scalePages.scaleFactor=Nivo zumiranja (isečak) stranice: scalePages.submit=Potvrdi #certSign -certSign.title=Potpisivanje Sertifikatom -certSign.header=Potpiši PDF sa svojim sertifikatom (Rad u toku) -certSign.selectPDF=Izaberite PDF fajl za potpisivanje: -certSign.jksNote=Note: If your certificate type is not listed below, please convert it to a Java Keystore (.jks) file using the keytool command line tool. Then, choose the .jks file option below. -certSign.selectKey=Izaberite svoj privatni ključ (PKCS#8 format, može biti .pem ili .der): -certSign.selectCert=Izaberite svoj sertifikat (X.509 format, može biti .pem ili .der): -certSign.selectP12=Izaberite svoj PKCS#12 keystore fajl (.p12 ili .pfx) (Opciono, ako je dostupan, trebalo bi da sadrži vaš privatni ključ i sertifikat): -certSign.selectJKS=Select Your Java Keystore File (.jks or .keystore): -certSign.certType=Tip sertifikata -certSign.password=Unesite lozinku vašeg keystore-a ili privatnog ključa (ako je ima): +certSign.title=Potpisivanje sertifikatom +certSign.header=Potpiši PDF svojim sertifikatom (Rad u toku) +certSign.selectPDF=Izaberi PDF dokument za potpisivanje: +certSign.jksNote=Napomena: Ako tvoj tip sertifikata nije naveden ispod, konvertuj ga u Java Keystore (.jks) format koristeći komandni alat keytool. Zatim izaberi opciju .jks ispod. +certSign.selectKey=Izaberi svoj privatni ključ (PKCS#8 format, može biti .pem ili .der): +certSign.selectCert=Izaberi svoj sertifikat (X.509 format, može biti .pem ili .der): +certSign.selectP12=Izaberi svoju PKCS#12 keystore datoteku (.p12 ili .pfx) (Opciono, ako je dostupan, trebalo bi da sadrži tvoj privatni ključ i sertifikat): +certSign.selectJKS=Izaberi svoju Java keystore datoteku (.jks or .keystore): +certSign.certType=Tip sertifikata: +certSign.password=Unesi lozinku keystore datoteke ili privatnog ključa (ako postoji): certSign.showSig=Prikaži potpis certSign.reason=Razlog certSign.location=Lokacija certSign.name=Ime -certSign.showLogo=Show Logo +certSign.showLogo=Prikaži logo certSign.submit=Potpiši PDF #removeCertSign -removeCertSign.title=Remove Certificate Signature -removeCertSign.header=Remove the digital certificate from the PDF -removeCertSign.selectPDF=Select a PDF file: -removeCertSign.submit=Remove Signature +removeCertSign.title=Ukloni potpis sertifikata +removeCertSign.header=Ukloni digitalni sertifikat iz PDF-a +removeCertSign.selectPDF=Izaberi PDF dokument: +removeCertSign.submit=Ukloni potpis #removeBlanks removeBlanks.title=Ukloni prazne stranice removeBlanks.header=Ukloni prazne stranice removeBlanks.threshold=Prag beline piksela: -removeBlanks.thresholdDesc=Prag za određivanje koliko beli piksel mora biti 'beli'. 0 = Crno, 255 čisto belo. +removeBlanks.thresholdDesc=Prag za određivanje koliko piksel mora biti beo da bi se smatrao 'belim'. 0 = Crno, 255 čisto belo. removeBlanks.whitePercent=Procenat bele boje (%): -removeBlanks.whitePercentDesc=Procenat stranice koji mora biti 'beli' pikseli da bi se uklonili +removeBlanks.whitePercentDesc=Procenat stranice koji mora biti 'beo' da bi se uklonila. removeBlanks.submit=Ukloni prazne #removeAnnotations -removeAnnotations.title=Ukloni Anotacije -removeAnnotations.header=Ukloni Anotacije +removeAnnotations.title=Ukloni anotacije/beleške +removeAnnotations.header=Ukloni beleške removeAnnotations.submit=Ukloni #compare compare.title=Uporedi -compare.header=Uporedi PDF fajlove -compare.highlightColor.1=Highlight Color 1: -compare.highlightColor.2=Highlight Color 2: +compare.header=Uporedi PDF-ove +compare.highlightColor.1=Boja isticanja 1: +compare.highlightColor.2=Boja isticanja 2: compare.document.1=Dokument 1 compare.document.2=Dokument 2 compare.submit=Uporedi -compare.complex.message=One or both of the provided documents are large files, accuracy of comparison may be reduced -compare.large.file.message=One or Both of the provided documents are too large to process -compare.no.text.message=One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison. +compare.complex.message=Jedan ili oba dostavljena dokumenta su veliki pa tačnost poređenja može biti smanjena. +compare.large.file.message=Jedan ili oba dostavljena dokumenta su preveliki za obradu +compare.no.text.message=Jedan ili oba izabrana PDF-a nemaju tekstualni sadržaj. Izaberi PDF-ove sa tekstom za poređenje. #sign sign.title=Potpiši -sign.header=Potpiši PDF fajlove +sign.header=Potpiši PDF-ove sign.upload=Učitaj sliku sign.draw=Nacrtaj potpis sign.text=Tekstualni unos sign.clear=Obriši sign.add=Dodaj -sign.saved=Saved Signatures -sign.save=Save Signature -sign.personalSigs=Personal Signatures -sign.sharedSigs=Shared Signatures -sign.noSavedSigs=No saved signatures found -sign.addToAll=Add to all pages -sign.delete=Delete -sign.first=First page -sign.last=Last page -sign.next=Next page -sign.previous=Previous page -sign.maintainRatio=Toggle maintain aspect ratio -sign.undo=Undo -sign.redo=Redo +sign.saved=Snimljeni potpisi +sign.save=Snimi potpis +sign.personalSigs=Lični potpisi +sign.sharedSigs=Deljeni potpisi +sign.noSavedSigs=Nema snimljenih potpisa +sign.addToAll=Dodaj na sve stranice +sign.delete=Obriši +sign.first=Prva strana +sign.last=Poslednja strana +sign.next=Sledeća strana +sign.previous=Prethodna strana +sign.maintainRatio=Uključi/isključi zadržavanje proporcija +sign.undo=Poništi +sign.redo=Ponovi #repair repair.title=Popravi -repair.header=Popravi PDF fajlove +repair.header=Popravi PDF-ove repair.submit=Popravi #flatten flatten.title=Ravnanje -flatten.header=Ravnanje PDF fajlova -flatten.flattenOnlyForms=Flatten only forms -flatten.submit=Ravnanje +flatten.header=Ravnanje PDF-ova +flatten.flattenOnlyForms=Izravnaj samo forme +flatten.submit=Poravnaj #ScannerImageSplit -ScannerImageSplit.selectText.1=Ugao praga: +ScannerImageSplit.selectText.1=Prag ugla: ScannerImageSplit.selectText.2=Postavlja minimalni apsolutni ugao potreban za rotiranje slike (podrazumevano: 10). ScannerImageSplit.selectText.3=Tolerancija: ScannerImageSplit.selectText.4=Određuje opseg varijacije boja oko procenjene boje pozadine (podrazumevano: 30). ScannerImageSplit.selectText.5=Minimalna površina: ScannerImageSplit.selectText.6=Postavlja minimalni prag površine za fotografiju (podrazumevano: 10000). ScannerImageSplit.selectText.7=Minimalna površina konture: -ScannerImageSplit.selectText.8=Postavlja minimalni prag površine konture za fotografiju +ScannerImageSplit.selectText.8=Postavlja minimalni prag površine konture za fotografiju. ScannerImageSplit.selectText.9=Veličina ivice: -ScannerImageSplit.selectText.10=Postavlja veličinu ivice dodate i uklonjene kako bi se sprečile bele ivice u izlazu (podrazumevano: 1). -ScannerImageSplit.info=Python is not installed. It is required to run. +ScannerImageSplit.selectText.10=Postavlja veličinu ivice koja se dodaje i uklanja kako bi se sprečile bele ivice u izlazu (podrazumevano: 1). +ScannerImageSplit.info=Python nije instaliran. Neophodan je za rad. #OCR -ocr.title=OCR / Čišćenje skeniranja -ocr.header=Čišćenje skeniranja / OCR (Optičko prepoznavanje znakova) -ocr.selectText.1=Odaberite jezike koji će biti detektovani unutar PDF-a (Navedeni su trenutno detektovani): -ocr.selectText.2=Proizvedi tekstualni fajl koji sadrži OCR tekst uz OCR-ovani PDF -ocr.selectText.3=Ispravite stranice koje su skenirane pod uglom rotirajući ih na svoje mesto -ocr.selectText.4=Očistite stranicu tako da je manje verovatno da će OCR pronaći tekst u pozadinskom šumu. (Bez promene izlaza) -ocr.selectText.5=Očistite stranicu tako da je manje verovatno da će OCR pronaći tekst u pozadinskom šumu, zadržavajući čišćenje u izlazu. -ocr.selectText.6=Ignoriše stranice koje imaju interaktivni tekst, samo OCR-uje stranice koje su slike -ocr.selectText.7=Prinudni OCR, OCR-uje svaku stranicu uklanjajući sve originalne tekstualne elemente +ocr.title=OCR / Čišćenje skeniranih dokumenata +ocr.header=Čišćenje skeniranja / OCR (Optičko prepoznavanje karaktera) +ocr.selectText.1=Izaberi jezike koji treba da budu detektovani u PDF-u (navedeni su trenutno detektovani jezici): +ocr.selectText.2=Napravi tekstualni fajl koji sadrži OCR tekst zajedno sa OCR PDF-om +ocr.selectText.3=Ispravi stranice koje su skenirane pod uglom rotirajući ih na svoje mesto +ocr.selectText.4=Očisti stranicu kako bi se smanjila mogućnost da OCR prepozna tekst u pozadinskoj buci. (Bez promene izlaz) +ocr.selectText.5=Očisti stranicu kako bi se smanjila mogućnost da OCR prepozna tekst u pozadinskoj buci, zadržavajući čišćenje u izlazu. +ocr.selectText.6=Ignoriše stranice koje sadrže interaktivni tekst, radi OCR samo na stranicama koje su slike +ocr.selectText.7=Forsiraj OCR, OCR će se izvršiti na svakoj stranici uklanjajući sve originalne tekstualne elemente ocr.selectText.8=Normalno (Prikaže grešku ako PDF sadrži tekst) ocr.selectText.9=Dodatne postavke -ocr.selectText.10=Režim OCR-a -ocr.selectText.11=Ukloni slike nakon OCR-a (Uklanja SVE slike, korisno samo ako je deo koraka konverzije) -ocr.selectText.12=Tip rendiranja (Napredno) -ocr.help=Molimo vas da pročitate ovu dokumentaciju o tome kako koristiti ovo za druge jezike i/ili korišćenje van docker-a +ocr.selectText.10=Režim OCR-a: +ocr.selectText.11=Ukloni slike nakon OCR-a (Uklanja SVE slike, korisno samo ako je deo procesa konverzije) +ocr.selectText.12=Tip renderovanja (napredno): +ocr.help=Molimo pročitajte sledeću dokumentaciju o tome kako koristiti ovu opciju za druge jezike i/ili kako koristiti van Dockera ocr.credit=Ova usluga koristi qpdf i Tesseract za OCR. ocr.submit=Obradi PDF sa OCR-om #extractImages -extractImages.title=Izdvajanje slika -extractImages.header=Izdvajanje slika -extractImages.selectText=Odaberite format slike za konvertovanje izdvojenih slika -extractImages.allowDuplicates=Save duplicate images -extractImages.submit=Izdvajanje +extractImages.title=Izvuci slike +extractImages.header=Izvuci slike +extractImages.selectText=Izaberi format u koji će se konvertovati izvučene slike: +extractImages.allowDuplicates=Sačuvaj duplirane slike +extractImages.submit=Izvuci #File to PDF -fileToPDF.title=Fajl u PDF -fileToPDF.header=Konvertuj bilo koji fajl u PDF -fileToPDF.credit=Ova usluga koristi LibreOffice i Unoconv za konverziju fajla. -fileToPDF.supportedFileTypesInfo=Supported File types -fileToPDF.supportedFileTypes=Podržani tipovi fajlova bi trebali uključivati navedeno, ali za punu ažuriranu listu podržanih formata, molimo pogledajte LibreOffice dokumentaciju +fileToPDF.title=Datoteka u PDF +fileToPDF.header=Konvertuj bilo koju datoteku u PDF +fileToPDF.credit=Ova usluga koristi LibreOffice i Unoconv za konverziju datoteka. +fileToPDF.supportedFileTypesInfo=Podržani tipovi datoteka +fileToPDF.supportedFileTypes=Podržani tipovi datoteka trebalo bi da uključuju navedene ispod, ali za kompletnu i ažuriranu listu podržanih formata, pogledajte LibreOffice dokumentaciju: fileToPDF.submit=Konvertuj u PDF #compress -compress.title=Kompresija +compress.title=Kompresuj compress.header=Kompresuj PDF -compress.credit=Ova usluga koristi qpdf za kompresiju / optimizaciju PDF-a. -compress.grayscale.label=Primeni sivinu za kompresiju -compress.selectText.1=Compression Settings -compress.selectText.1.1=1-3 PDF compression,
4-6 lite image compression,
7-9 intense image compression Will dramatically reduce image quality +compress.credit=Ova usluga koristi qpdf za kompresiju/optimizaciju PDF-a. +compress.grayscale.label=Primeni monohromatski režim za kompresiju +compress.selectText.1=Podešavanje kompresije +compress.selectText.1.1=1-3 PDF kompresija,
4-6 blaga kompresija slika,
7-9 intenzivna kompresija slika koja značajno smanjuje kvalitet slika compress.selectText.2=Nivo optimizacije: -compress.selectText.4=Automatski režim - Automatski prilagođava kvalitet kako bi PDF bio tačne veličine -compress.selectText.5=Očekivana veličina PDF-a (npr. 25MB, 10.8MB, 25KB) +compress.selectText.4=Automatski režim – Automatski podešava kvalitet kako bi PDF imao tačno određenu veličinu +compress.selectText.5=Željena veličina PDF-a (npr. 25MB, 10.8MB, 25KB) compress.submit=Kompresuj #Add image addImage.title=Dodaj sliku addImage.header=Dodaj sliku u PDF -addImage.everyPage=Na svakoj stranici? +addImage.everyPage=Na svaku stranicu? addImage.upload=Dodaj sliku addImage.submit=Dodaj sliku +#attachments +attachments.title=Dodaj priloge +attachments.header=Dodaj priloge +attachments.description=Omogućava dodavanje priloga u PDF +attachments.descriptionPlaceholder=Upiši opis za priloge... +attachments.addButton=Dodaj priloge #merge merge.title=Spajanje -merge.header=Spajanje više PDF fajlova (2+) +merge.header=Spoji više PDF-ova (2+) merge.sortByName=Sortiraj po imenu merge.sortByDate=Sortiraj po datumu -merge.removeCertSign=Remove digital signature in the merged file? -merge.generateToc=Generate table of contents in the merged file? -merge.submit=Spajanje +merge.removeCertSign=Ukloni digitalni potpis iz spojenog dokumenta? +merge.generateToc=Generiši listu sadržaja u spojenom dokumentu? +merge.submit=Spoj #pdfOrganiser pdfOrganiser.title=Organizator stranica pdfOrganiser.header=Organizator stranica u PDF-u pdfOrganiser.submit=Preuredi stranice -pdfOrganiser.mode=Mode -pdfOrganiser.mode.1=Custom Page Order -pdfOrganiser.mode.2=Reverse Order -pdfOrganiser.mode.3=Duplex Sort -pdfOrganiser.mode.4=Booklet Sort -pdfOrganiser.mode.5=Side Stitch Booklet Sort -pdfOrganiser.mode.6=Odd-Even Split -pdfOrganiser.mode.7=Remove First -pdfOrganiser.mode.8=Remove Last -pdfOrganiser.mode.9=Remove First and Last -pdfOrganiser.mode.10=Odd-Even Merge -pdfOrganiser.mode.11=Duplicate all pages -pdfOrganiser.placeholder=(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1) +pdfOrganiser.mode=Mod +pdfOrganiser.mode.1=Prilagođeni redosled stranica +pdfOrganiser.mode.2=Obrnuti redosled +pdfOrganiser.mode.3=Redosled za obostranu štampu +pdfOrganiser.mode.4=Redosled za brošuru +pdfOrganiser.mode.5=Redosled za brošuru sa spajanjem sa strane +pdfOrganiser.mode.6=Razdvoji neparne i parne stranice +pdfOrganiser.mode.7=Ukloni prvu +pdfOrganiser.mode.8=Ukloni poslednju +pdfOrganiser.mode.9=Ukloni prvu i poslednju +pdfOrganiser.mode.10=Spoji neparne i parne stranice +pdfOrganiser.mode.11=Dupliraj sve stranice +pdfOrganiser.placeholder=(npr. 1,3,2 ili 4-8,2,10-12 ili 2n-1) #multiTool -multiTool.title=PDF Multi Alatka -multiTool.header=PDF Multi Alatka -multiTool.uploadPrompts=File Name -multiTool.selectAll=Select All -multiTool.deselectAll=Deselect All -multiTool.selectPages=Page Select -multiTool.selectedPages=Selected Pages -multiTool.page=Page -multiTool.deleteSelected=Delete Selected -multiTool.downloadAll=Export -multiTool.downloadSelected=Export Selected +multiTool.title=Višefunkcionalni PDF alat +multiTool.header=Višefunkcionalni PDF alat +multiTool.uploadPrompts=Naziv datoteke +multiTool.selectAll=Izaberi sve +multiTool.deselectAll=Poništi sve +multiTool.selectPages=Izbor stranica +multiTool.selectedPages=Izabrane stranice +multiTool.page=Stranica +multiTool.deleteSelected=Izaberi izabrano +multiTool.downloadAll=Izvoz +multiTool.downloadSelected=Izvezi izabrano -multiTool.insertPageBreak=Insert Page Break -multiTool.addFile=Add File -multiTool.rotateLeft=Rotate Left -multiTool.rotateRight=Rotate Right -multiTool.split=Split -multiTool.moveLeft=Move Left -multiTool.moveRight=Move Right -multiTool.delete=Delete -multiTool.dragDropMessage=Page(s) Selected -multiTool.undo=Undo -multiTool.redo=Redo +multiTool.insertPageBreak=Ubaci prelom stranice +multiTool.addFile=Dodaj datoteku +multiTool.rotateLeft=Rotiraj levo +multiTool.rotateRight=Rotiraj desno +multiTool.split=Podeli +multiTool.moveLeft=Pomeri levo +multiTool.moveRight=Pomeri desno +multiTool.delete=Obriši +multiTool.dragDropMessage=Izabrane stranica/e +multiTool.undo=Poništi (CTRL + Z) +multiTool.redo=Ponovi (CTRL + Y) #decrypt -decrypt.passwordPrompt=This file is password-protected. Please enter the password: -decrypt.cancelled=Operation cancelled for PDF: {0} -decrypt.noPassword=No password provided for encrypted PDF: {0} -decrypt.invalidPassword=Please try again with the correct password. -decrypt.invalidPasswordHeader=Incorrect password or unsupported encryption for PDF: {0} -decrypt.unexpectedError=There was an error processing the file. Please try again. -decrypt.serverError=Server error while decrypting: {0} -decrypt.success=File decrypted successfully. +decrypt.passwordPrompt=Ova datoteka je zaštićena lozinkom. Unesi lozinku: +decrypt.cancelled=Operacija otkazana za PDF: {0} +decrypt.noPassword=Nije uneta lozinka za šifrovani PDF: {0} +decrypt.invalidPassword=Pokušaj ponovo sa ispravnom lozinkom. +decrypt.invalidPasswordHeader=Neispravna lozinka ili nepodržana enkripcija za PDF: {0} +decrypt.unexpectedError=Došle je do greške prilikom obrade datoteke. Pokušaj ponovo. +decrypt.serverError=Greška na serveru prilikom dekriptovanja: {0} +decrypt.success=Datoteka uspešno dekriptovana. #multiTool-advert -multiTool-advert.message=This feature is also available in our multi-tool page. Check it out for enhanced page-by-page UI and additional features! +multiTool-advert.message=Ova funkcija je dostupna i na našoj stranici sa višenamenskim alatom. Potraži unapređeni interfejs po stranici i dodatne funkcije! #view pdf -viewPdf.title=View/Edit PDF +viewPdf.title=Pogledaj/Izmeni PDF viewPdf.header=Prikaz PDF-a #pageRemover pageRemover.title=Uklanjanje stranica pageRemover.header=Uklanjanje stranica iz PDF-a -pageRemover.pagesToDelete=Stranice za brisanje (Unesite listu brojeva stranica odvojenih zarezima) : +pageRemover.pagesToDelete=Stranice za brisanje (unesi listu brojeva stranica odvojenih zarezima) : pageRemover.submit=Obriši stranice -pageRemover.placeholder=(e.g. 1,2,6 or 1-10,15-30) +pageRemover.placeholder=(npr. 1,2,6 ili 1-10,15-30) #rotate rotate.title=Rotiranje PDF-a rotate.header=Rotiranje PDF-a -rotate.selectAngle=Izaberite ugao rotacije (u višestrukim od 90 stepeni): +rotate.selectAngle=Izaberi ugao rotacije (u množiocima od 90 stepeni): rotate.submit=Rotiraj #split-pdfs split.title=Razdvajanje PDF-a split.header=Razdvajanje PDF-a -split.desc.1=Brojevi koje izaberete predstavljaju brojeve stranica na kojima želite napraviti razdvajanje -split.desc.2=Na primer, izbor 1,3,7-9 bi razdvojio dokument od 10 stranica u 6 odvojenih PDF-a sa: +split.desc.1=Izabrani brojevi predstavljaju stranice na kojima će se napraviti razdvajanje. +split.desc.2=Na primer, izbor 1,3,7-9 bi razdvojio dokument od 10 stranica u 6 odvojenih PDF-ova sa: split.desc.3=Dokument #1: Stranica 1 split.desc.4=Dokument #2: Stranice 2 i 3 split.desc.5=Dokument #3: Stranice 4, 5, 6 i 7 split.desc.6=Dokument #4: Stranica 8 split.desc.7=Dokument #5: Stranica 9 split.desc.8=Dokument #6: Stranice 10 -split.splitPages=Unesite stranice za razdvajanje: -split.submit=Razdvoji +split.splitPages=Upiši brojeve stranica za razdvajanje: +split.submit=Razdvoj #merge imageToPDF.title=Slika u PDF imageToPDF.header=Slika u PDF imageToPDF.submit=Konvertuj -imageToPDF.selectLabel=Opcije prilagođavanja slike +imageToPDF.selectLabel=Opcije prilagođavanja slike: imageToPDF.fillPage=Popuni stranicu imageToPDF.fitDocumentToImage=Prilagodi stranicu slici -imageToPDF.maintainAspectRatio=Očuvaj proporcije +imageToPDF.maintainAspectRatio=Zadrži proporcije imageToPDF.selectText.2=Automatsko rotiranje PDF-a -imageToPDF.selectText.3=Logika za više fajlova (Omogućeno samo ako radite sa više slika) +imageToPDF.selectText.3=Logika za više datoteka (omogućeno samo ako se radi sa više slika): imageToPDF.selectText.4=Spoji u jedan PDF imageToPDF.selectText.5=Konvertuj u odvojene PDF-ove @@ -1324,72 +1334,72 @@ imageToPDF.selectText.5=Konvertuj u odvojene PDF-ove #pdfToImage pdfToImage.title=PDF u sliku pdfToImage.header=PDF u sliku -pdfToImage.selectText=Format slike -pdfToImage.singleOrMultiple=Tip rezultata slike po stranici +pdfToImage.selectText=Format slike: +pdfToImage.singleOrMultiple=Izlazni format stranica kao slike: pdfToImage.single=Jedna velika slika koja sadrži sve stranice -pdfToImage.multi=Više slika, po jedna slika po stranici -pdfToImage.colorType=Tip boje -pdfToImage.color=Boja -pdfToImage.grey=Nijanse sive -pdfToImage.blackwhite=Crno-belo (Može izgubiti podatke!) +pdfToImage.multi=Više slika, jedna slika po stranici +pdfToImage.colorType=Režim boja: +pdfToImage.color=Kolor +pdfToImage.grey=Monohromatski +pdfToImage.blackwhite=Crno-belo (Može izgubiti detalje!) pdfToImage.submit=Konvertuj -pdfToImage.info=Python is not installed. Required for WebP conversion. -pdfToImage.placeholder=(e.g. 1,2,8 or 4,7,12-16 or 2n-1) +pdfToImage.info=Python nije instaliran. Neophodan je za WebP konverziju. +pdfToImage.placeholder=(npr. 1,2,8 ili 4,7,12-16 ili 2n-1) #addPassword addPassword.title=Dodaj šifru -addPassword.header=Dodaj šifru (Enkripcija) -addPassword.selectText.1=Izaberite PDF za enkripciju -addPassword.selectText.2=Korisnička šifra -addPassword.selectText.3=Dužina enkripcijskog ključa +addPassword.header=Dodaj šifru (enkripcija) +addPassword.selectText.1=Izaberi PDF koji želiš da zaštitiš: +addPassword.selectText.2=Korisnička šifra: +addPassword.selectText.3=Dužina enkripcijskog ključa: addPassword.selectText.4=Veće vrednosti su jače, ali manje vrednosti imaju bolju kompatibilnost. -addPassword.selectText.5=Postavke dozvola (Preporučuje se korišćenje sa šifrom vlasnika) +addPassword.selectText.5=Postavke dozvola (preporučuje se korišćenje sa šifrom vlasnika): addPassword.selectText.6=Onemogući sastavljanje dokumenta addPassword.selectText.7=Onemogući ekstrakciju sadržaja -addPassword.selectText.8=Onemogući ekstrakciju za pristupačnost +addPassword.selectText.8=Onemogući pristup alatima za pristupačnost addPassword.selectText.9=Onemogući popunjavanje formulara addPassword.selectText.10=Onemogući modifikaciju -addPassword.selectText.11=Onemogući modifikaciju anotacija +addPassword.selectText.11=Onemogući modifikaciju beleški addPassword.selectText.12=Onemogući štampanje addPassword.selectText.13=Onemogući štampanje u različitim formatima -addPassword.selectText.14=Šifra vlasnika -addPassword.selectText.15=Ograničava šta se može raditi sa dokumentom nakon otvaranja (Nije podržano od svih čitača) -addPassword.selectText.16=Ograničava otvaranje samog dokumenta +addPassword.selectText.14=Šifra vlasnika: +addPassword.selectText.15=Ograničava šta se može raditi sa dokumentom nakon otvaranja. (Nije podržano od strane svih čitača) +addPassword.selectText.16=Ograničava otvaranje samog dokumenta. addPassword.submit=Enkriptuj #watermark watermark.title=Dodaj vodeni žig watermark.header=Dodaj vodeni žig -watermark.customColor=Custom Text Color -watermark.selectText.1=Izaberite PDF za dodavanje vodenog žiga: +watermark.customColor=Prilagođena boja teksta +watermark.selectText.1=Izaberi PDF za dodavanje vodenog žiga: watermark.selectText.2=Tekst vodenog žiga: watermark.selectText.3=Veličina fonta: watermark.selectText.4=Rotacija (0-360): -watermark.selectText.5=Širina razmaka (Razmak između svakog vodenog žiga horizontalno): -watermark.selectText.6=Visina razmaka (Razmak između svakog vodenog žiga vertikalno): -watermark.selectText.7=Opačitost (0% - 100%): +watermark.selectText.5=Širina razmaka (razmak između svakog vodenog žiga horizontalno): +watermark.selectText.6=Visina razmaka (razmak između svakog vodenog žiga vertikalno): +watermark.selectText.7=Providnost (0% - 100%): watermark.selectText.8=Tip vodenog žiga: watermark.selectText.9=Slika vodenog žiga: -watermark.selectText.10=Convert PDF to PDF-Image +watermark.selectText.10=Konvertuj PDF u PDF-sliku watermark.submit=Dodaj vodeni žig -watermark.type.1=Text -watermark.type.2=Image +watermark.type.1=Tekst +watermark.type.2=Slika #Change permissions permissions.title=Promeni dozvole permissions.header=Promeni dozvole -permissions.warning=Upozorenje: Da biste ove dozvole učinili nepromenljivim, preporučuje se postavljanje šifre putem stranice za dodavanje šifre. -permissions.selectText.1=Izaberite PDF za promenu dozvola -permissions.selectText.2=Postavke dozvola +permissions.warning=Napomena: Da bi ove dozvole učinili nepromenljivim, preporučuje se postavljanje šifre putem stranice za dodavanje šifre. +permissions.selectText.1=Izaberi PDF za promenu dozvola: +permissions.selectText.2=Postavke dozvola: permissions.selectText.3=Onemogući sastavljanje dokumenta permissions.selectText.4=Onemogući ekstrakciju sadržaja -permissions.selectText.5=Onemogući ekstrakciju za pristupačnost +permissions.selectText.5=Onemogući ekstrakciju alatima za pristupačnost permissions.selectText.6=Onemogući popunjavanje formulara permissions.selectText.7=Onemogući modifikaciju -permissions.selectText.8=Onemogući modifikaciju anotacija +permissions.selectText.8=Onemogući modifikaciju beleški permissions.selectText.9=Onemogući štampanje permissions.selectText.10=Onemogući štampanje u različitim formatima permissions.submit=Promeni @@ -1397,16 +1407,16 @@ permissions.submit=Promeni #remove password removePassword.title=Ukloni šifru -removePassword.header=Ukloni šifru (Dekripcija) -removePassword.selectText.1=Izaberite PDF za dekripciju -removePassword.selectText.2=Šifra +removePassword.header=Ukloni šifru (dekripcija) +removePassword.selectText.1=Izaberi PDF za dekripciju: +removePassword.selectText.2=Šifra: removePassword.submit=Ukloni #changeMetadata changeMetadata.title=Naslov: -changeMetadata.header=Promeni metapodatke -changeMetadata.selectText.1=Izmenite promenljive koje želite promeniti +changeMetadata.header=Izmeni metapodatke +changeMetadata.selectText.1=Izmenite promenljive koje želite da izmenite: changeMetadata.selectText.2=Obriši sve metapodatke changeMetadata.selectText.3=Prikaži prilagođene metapodatke: changeMetadata.author=Autor: @@ -1414,94 +1424,94 @@ changeMetadata.creationDate=Datum kreiranja (gggg/MM/dd HH:mm:ss): changeMetadata.creator=Kreator: changeMetadata.keywords=Ključne reči: changeMetadata.modDate=Datum izmene (gggg/MM/dd HH:mm:ss): -changeMetadata.producer=Proizvođač: +changeMetadata.producer=Program koji je kreirao dokument: changeMetadata.subject=Tema: -changeMetadata.trapped=Zaglavljeno: +changeMetadata.trapped=Primenjen trapping: changeMetadata.selectText.4=Drugi metapodaci: -changeMetadata.selectText.5=Dodaj prilagođeni unos metapodataka -changeMetadata.submit=Promeni +changeMetadata.selectText.5=Dodaj sopstveni metapodatak +changeMetadata.submit=Izmeni #unlockPDFForms -unlockPDFForms.title=Remove Read-Only from Form Fields -unlockPDFForms.header=Unlock PDF Forms -unlockPDFForms.submit=Remove +unlockPDFForms.title=Ukloni režim samo-za-čitanje sa polja obrasca +unlockPDFForms.header=Otključaj PDF obrazac +unlockPDFForms.submit=Otključaj #pdfToPDFA pdfToPDFA.title=PDF u PDF/A pdfToPDFA.header=PDF u PDF/A -pdfToPDFA.credit=Ova usluga koristi libreoffice za konverziju u PDF/A format +pdfToPDFA.credit=Ova usluga koristi LibreOffice za konverziju u PDF/A format. pdfToPDFA.submit=Konvertuj -pdfToPDFA.tip=Currently does not work for multiple inputs at once -pdfToPDFA.outputFormat=Output format -pdfToPDFA.pdfWithDigitalSignature=The PDF contains a digital signature. This will be removed in the next step. +pdfToPDFA.tip=Trenutno nije podržano za više unosa istovremeno +pdfToPDFA.outputFormat=Izlazni format: +pdfToPDFA.pdfWithDigitalSignature=PDF sadrži digitalni potpis. Biće uklonjen u sledećem koraku. #PDFToWord PDFToWord.title=PDF u Word PDFToWord.header=PDF u Word -PDFToWord.selectText.1=Format izlaznog fajla -PDFToWord.credit=Ova usluga koristi LibreOffice za konverziju fajlova. +PDFToWord.selectText.1=Format izlazne datoteke: +PDFToWord.credit=Ova usluga koristi LibreOffice za konverziju datoteka. PDFToWord.submit=Konvertuj #PDFToPresentation -PDFToPresentation.title=PDF u Prezentaciju -PDFToPresentation.header=PDF u Prezentaciju -PDFToPresentation.selectText.1=Format izlaznog fajla -PDFToPresentation.credit=Ova usluga koristi LibreOffice za konverziju fajlova. +PDFToPresentation.title=PDF u prezentaciju +PDFToPresentation.header=PDF u prezentaciju +PDFToPresentation.selectText.1=Format izlazne datoteke: +PDFToPresentation.credit=Ova usluga koristi LibreOffice za konverziju datoteka. PDFToPresentation.submit=Konvertuj #PDFToText PDFToText.title=PDF u RTF (Tekst) PDFToText.header=PDF u RTF (Tekst) -PDFToText.selectText.1=Format izlaznog fajla -PDFToText.credit=Ova usluga koristi LibreOffice za konverziju fajlova. +PDFToText.selectText.1=Format izlazne datoteke: +PDFToText.credit=Ova usluga koristi LibreOffice za konverziju datoteka. PDFToText.submit=Konvertuj #PDFToHTML PDFToHTML.title=PDF u HTML PDFToHTML.header=PDF u HTML -PDFToHTML.credit=Ova usluga koristi pdftohtml za konverziju fajlova. +PDFToHTML.credit=Ova usluga koristi pdftohtml za konverziju datoteka. PDFToHTML.submit=Konvertuj #PDFToXML PDFToXML.title=PDF u XML PDFToXML.header=PDF u XML -PDFToXML.credit=Ova usluga koristi LibreOffice za konverziju fajlova. +PDFToXML.credit=Ova usluga koristi LibreOffice za konverziju datoteka. PDFToXML.submit=Konvertuj #PDFToCSV PDFToCSV.title=PDF u CSV PDFToCSV.header=PDF u CSV -PDFToCSV.prompt=Izaberite stranicu za ekstrakciju tabele +PDFToCSV.prompt=Izaberi stranicu za ekstrakciju tabele PDFToCSV.submit=Izvuci #split-by-size-or-count split-by-size-or-count.title=Razdvoji PDF po veličini ili broju split-by-size-or-count.header=Razdvoji PDF po veličini ili broju -split-by-size-or-count.type.label=Izaberite tip razdvajanja +split-by-size-or-count.type.label=Izaberi način razdvajanja: split-by-size-or-count.type.size=Po veličini split-by-size-or-count.type.pageCount=Po broju stranica split-by-size-or-count.type.docCount=Po broju dokumenata -split-by-size-or-count.value.label=Unesite vrednost -split-by-size-or-count.value.placeholder=Unesite veličinu (npr. 2MB ili 3KB) ili broj (npr. 5) +split-by-size-or-count.value.label=Unesi vrednost: +split-by-size-or-count.value.placeholder=Unesi veličinu (npr. 2MB ili 3KB) ili broj (npr. 5) split-by-size-or-count.submit=Potvrdi #overlay-pdfs overlay-pdfs.header=Preklapanje PDF fajlova -overlay-pdfs.baseFile.label=Izaberite osnovni PDF fajl -overlay-pdfs.overlayFiles.label=Izaberite PDF fajlove za preklapanje -overlay-pdfs.mode.label=Izaberite režim preklapanja +overlay-pdfs.baseFile.label=Izaberi osnovnu PDF datoteku +overlay-pdfs.overlayFiles.label=Izaberi PDF datoteke za preklapanje: +overlay-pdfs.mode.label=Izaberi režim preklapanja: overlay-pdfs.mode.sequential=Sekvencijalno preklapanje -overlay-pdfs.mode.interleaved=Interleaved preklapanje -overlay-pdfs.mode.fixedRepeat=Fixed Repeat preklapanje -overlay-pdfs.counts.label=Broj preklapanja (za režim Fixed Repeat) -overlay-pdfs.counts.placeholder=Unesite brojeve odvojene zarezom (npr. 2,3,1) -overlay-pdfs.position.label=Izaberite poziciju preklapanja +overlay-pdfs.mode.interleaved=Naizmenično preklapanje +overlay-pdfs.mode.fixedRepeat=Fiksno ponovljeno preklapanje +overlay-pdfs.counts.label=Broj preklapanja (za režim Fiksno preklapanje) +overlay-pdfs.counts.placeholder=Unesi brojeve odvojene zarezom (npr. 2,3,1) +overlay-pdfs.position.label=Izaberi poziciju preklapanja: overlay-pdfs.position.foreground=Prethodni plan overlay-pdfs.position.background=Pozadina overlay-pdfs.submit=Potvrdi @@ -1509,286 +1519,287 @@ overlay-pdfs.submit=Potvrdi #split-by-sections split-by-sections.title=Razdvoji PDF po sekcijama -split-by-sections.header=Razdvoji PDF u sekcije -split-by-sections.horizontal.label=Horizontalne podele -split-by-sections.vertical.label=Vertikalne podele -split-by-sections.horizontal.placeholder=Unesite broj horizontalnih podele -split-by-sections.vertical.placeholder=Unesite broj vertikalnih podele +split-by-sections.header=Razdvoji PDF po sekcijama +split-by-sections.horizontal.label=Horizontalne podele: +split-by-sections.vertical.label=Vertikalne podele: +split-by-sections.horizontal.placeholder=Unesi broj horizontalnih podela +split-by-sections.vertical.placeholder=Unesi broj vertikalnih podela split-by-sections.submit=Razdvoji PDF -split-by-sections.merge=Merge Into One PDF +split-by-sections.merge=Spoji u jedan PDF #printFile -printFile.title=Print File -printFile.header=Print File to Printer -printFile.selectText.1=Select File to Print -printFile.selectText.2=Enter Printer Name -printFile.submit=Print +printFile.title=Odštampaj datoteku +printFile.header=Odštampaj datoteku na štampaču +printFile.selectText.1=Izaberi datoteku za štampu +printFile.selectText.2=Unesi naziv štampača +printFile.submit=Štampaj #licenses -licenses.nav=Licenses -licenses.title=3rd Party Licenses -licenses.header=3rd Party Licenses -licenses.module=Module -licenses.version=Version -licenses.license=License +licenses.nav=Licence +licenses.title=Licence trećih strana +licenses.header=Licence trećih strana +licenses.module=Modul +licenses.version=Verzija +licenses.license=Licenca #survey -survey.nav=Survey -survey.title=Stirling-PDF Survey -survey.description=Stirling-PDF has no tracking so we want to hear from our users to improve Stirling-PDF! -survey.changes=Stirling-PDF has changed since the last survey! To find out more please check our blog post here: -survey.changes2=With these changes we are getting paid business support and funding -survey.please=Please consider taking our survey! -survey.disabled=(Survey popup will be disabled in following updates but available at foot of page) -survey.button=Take Survey -survey.dontShowAgain=Don't show again -survey.meeting.1=If you're using Stirling PDF at work, we'd love to speak to you. We're offering technical support sessions in exchange for a 15 minute user discovery session. -survey.meeting.2=This is a chance to: -survey.meeting.3=Get help with deployment, integrations, or troubleshooting -survey.meeting.4=Provide direct feedback on performance, edge cases, and feature gaps -survey.meeting.5=Help us refine Stirling PDF for real-world enterprise use -survey.meeting.6=If you're interested, you can book time with our team directly. (English speaking only) -survey.meeting.7=Looking forward to digging into your use cases and making Stirling PDF even better! -survey.meeting.notInterested=Not a business and/or interested in a meeting? -survey.meeting.button=Book meeting +survey.nav=Anketa +survey.title=Stirling-PDF anketa +survey.description=Stirling-PDF ne prati korisnike, zato želimo da čujemo tvoje utiske kako bismo unapredili Stirling-PDF! +survey.changes=Stirling-PDF se promenio od poslednje ankete! Za više informacija, pogledaj naš blog post ovde: +survey.changes2=Sa ovim promenama dobijamo plaćenu poslovnu podršku i finansiranje +survey.please=Molimo te da razmotriš učešće u našoj anketi! +survey.disabled=(Popup za anketu će biti onemogućen u narednim ažuriranjima, ali će ostati dostupan na dnu stranice) +survey.button=Popuni anketu +survey.dontShowAgain=Ne prikazuj ponovo +survey.meeting.1=Ako koristiš Stirling PDF na poslu, voleli bismo da razgovaramo sa tobom. Nudimo tehničke sesije podrške u zamenu za 15-minutni korisnički intervju. +survey.meeting.2=Ovo je prilika da: +survey.meeting.3=Dobiješ pomoć oko postavljanja, integracije ili rešavanja problema +survey.meeting.4=Pružiš direktne povratne informacije o performansama, specifičnim slučajevima i nedostacima funkcionalnosti +survey.meeting.5=Pomozi nam da unapredimo Stirling PDF za praktičnu upotrebu u preduzećima +survey.meeting.6=Ukoliko si zainteresov, možeš zakazati direktni termin sa našim timom. (Samo na engleskom jeziku) +survey.meeting.7=Radujemo se što ćemo detaljnije istražiti tvoje slučajeve korišćenja i učiniti Stirling PDF još boljim! +survey.meeting.notInterested=Nisi poslovni korisnik i/ili nisi zainteresovan za sastanak? +survey.meeting.button=Zakaži sastanak #error -error.sorry=Sorry for the issue! -error.needHelp=Need help / Found an issue? -error.contactTip=If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord: -error.404.head=404 - Page Not Found | Oops, we tripped in the code! -error.404.1=We can't seem to find the page you're looking for. -error.404.2=Something went wrong -error.github=Submit a ticket on GitHub -error.showStack=Show Stack Trace -error.copyStack=Copy Stack Trace -error.githubSubmit=GitHub - Submit a ticket -error.discordSubmit=Discord - Submit Support post +error.sorry=Izvinjavamo se zbog problema! +error.needHelp=Potrebna pomoć / Naišli ste na problem? +error.contactTip=Ako i dalje imaš problema, ne oklevaj da nas kontaktiraš za pomoć. Možeš poslati prijavu na našoj GitHub stranici ili nas kontaktirati putem Discord-a: +error.404.head=404 – Stranica nije pronađena | Ups, sapleli smo se u kodu! +error.404.1=Izgleda da ne možemo da pronađemo stranicu koju tražiš. +error.404.2=Nešto nije u redu +error.github=Pošalji prijavu na GitHub-u +error.showStack=Prikaži trag greške (Stack Trace) +error.copyStack=Kopiraj trag greške (Stack Trace) +error.githubSubmit=GitHub - pošalji prijavu +error.discordSubmit=Discord - pošalji poruku za podršku #remove-image -removeImage.title=Remove image -removeImage.header=Remove image -removeImage.removeImage=Remove image -removeImage.submit=Remove image +removeImage.title=Ukloni sliku +removeImage.header=Ukloni sliku +removeImage.removeImage=Ukloni sliku +removeImage.submit=Ukloni sliku -splitByChapters.title=Split PDF by Chapters -splitByChapters.header=Split PDF by Chapters -splitByChapters.bookmarkLevel=Bookmark Level -splitByChapters.includeMetadata=Include Metadata -splitByChapters.allowDuplicates=Allow Duplicates -splitByChapters.desc.1=This tool splits a PDF file into multiple PDFs based on its chapter structure. -splitByChapters.desc.2=Bookmark Level: Choose the level of bookmarks to use for splitting (0 for top-level, 1 for second-level, etc.). -splitByChapters.desc.3=Include Metadata: If checked, the original PDF's metadata will be included in each split PDF. -splitByChapters.desc.4=Allow Duplicates: If checked, allows multiple bookmarks on the same page to create separate PDFs. -splitByChapters.submit=Split PDF +splitByChapters.title=Podeli PDF po poglavljima +splitByChapters.header=Podeli PDF po poglavljima +splitByChapters.bookmarkLevel=Nivo oznake u sadržaju: +splitByChapters.includeMetadata=Uključi metapodatke +splitByChapters.allowDuplicates=Dozvoli duplikate +splitByChapters.desc.1=Ovaj alat deli PDF fajl na više PDF-ova po osnovu strukture poglavlja. +splitByChapters.desc.2=Nivo oznake: Izaberite nivo oznaka koji će se koristiti za deljenje (0 za najviši nivo, 1 za drugi nivo, itd.). +splitByChapters.desc.3=Uključi metapodatke: Ako je označeno, metapodaci iz originalnog PDF-a biće uključeni u svaki podeljeni PDF. +splitByChapters.desc.4=Dozvoli duplikate: Ako je označeno, omogućava da više oznaka na istoj strani kreira odvojene PDF fajlove. +splitByChapters.submit=Podeli PDF #File Chooser -fileChooser.click=Click -fileChooser.or=or -fileChooser.dragAndDrop=Drag & Drop -fileChooser.dragAndDropPDF=Drag & Drop PDF file -fileChooser.dragAndDropImage=Drag & Drop Image file -fileChooser.hoveredDragAndDrop=Drag & Drop file(s) here -fileChooser.extractPDF=Extracting... +fileChooser.click=Klikni +fileChooser.or=ili +fileChooser.dragAndDrop=Prevuci i ispusti +fileChooser.dragAndDropPDF=Prevuci PDF datoteku +fileChooser.dragAndDropImage=Prevuci sliku +fileChooser.hoveredDragAndDrop=Prevuci datoteku/e ovde +fileChooser.extractPDF=Izvlačim... +fileChooser.addAttachments=prevuci priloge ovde #release notes -releases.footer=Releases -releases.title=Release Notes -releases.header=Release Notes -releases.current.version=Current Release -releases.note=Release notes are only available in English +releases.footer=Izdanja +releases.title=Beleške o izdanju +releases.header=Beleške o izdanju +releases.current.version=Aktuelno izdanje +releases.note=Beleške o izdanju su dostupne samo na engleskom jeziku #Validate Signature -validateSignature.title=Validate PDF Signatures -validateSignature.header=Validate Digital Signatures -validateSignature.selectPDF=Select signed PDF file -validateSignature.submit=Validate Signatures -validateSignature.results=Validation Results +validateSignature.title=Verifikuj PDF potpise +validateSignature.header=Verifikuj digitalne potpise +validateSignature.selectPDF=Izaberi potpisanu PDF datoteku za proveru: +validateSignature.submit=Verifikuj potpise +validateSignature.results=Rezultati verifikacije: validateSignature.status=Status -validateSignature.signer=Signer -validateSignature.date=Date -validateSignature.reason=Reason -validateSignature.location=Location -validateSignature.noSignatures=No digital signatures found in this document -validateSignature.status.valid=Valid -validateSignature.status.invalid=Invalid -validateSignature.chain.invalid=Certificate chain validation failed - cannot verify signer's identity -validateSignature.trust.invalid=Certificate not in trust store - source cannot be verified -validateSignature.cert.expired=Certificate has expired -validateSignature.cert.revoked=Certificate has been revoked -validateSignature.signature.info=Signature Information -validateSignature.signature=Signature -validateSignature.signature.mathValid=Signature is mathematically valid BUT: -validateSignature.selectCustomCert=Custom Certificate File X.509 (Optional) -validateSignature.cert.info=Certificate Details -validateSignature.cert.issuer=Issuer -validateSignature.cert.subject=Subject -validateSignature.cert.serialNumber=Serial Number -validateSignature.cert.validFrom=Valid From -validateSignature.cert.validUntil=Valid Until -validateSignature.cert.algorithm=Algorithm -validateSignature.cert.keySize=Key Size -validateSignature.cert.version=Version -validateSignature.cert.keyUsage=Key Usage -validateSignature.cert.selfSigned=Self-Signed -validateSignature.cert.bits=bits +validateSignature.signer=Potpisnik +validateSignature.date=Datum +validateSignature.reason=Razlog +validateSignature.location=Lokacija +validateSignature.noSignatures=Digitalni potpisi nisu pronađeni u ovom dokumentu +validateSignature.status.valid=Validan +validateSignature.status.invalid=Invalidan +validateSignature.chain.invalid=Provera lanca sertifikata nije uspela – nije moguće potvrditi identitet potpisnika +validateSignature.trust.invalid=Sertifikat nije u skladištu poverenja – izvor nije moguće potvrditi +validateSignature.cert.expired=Sertifikat je istekao +validateSignature.cert.revoked=Sertifikat je opozvan +validateSignature.signature.info=Informacije o potpisu +validateSignature.signature=Potpis +validateSignature.signature.mathValid=Potpis je matematički validan ALI: +validateSignature.selectCustomCert=Prilagođena X.509 datoteka sertifikata (opciono) +validateSignature.cert.info=Detalji o sertifikatu: +validateSignature.cert.issuer=Izdavalac +validateSignature.cert.subject=Subjekat +validateSignature.cert.serialNumber=Serijski broj +validateSignature.cert.validFrom=Važi od +validateSignature.cert.validUntil=Važi do +validateSignature.cert.algorithm=Algoritam +validateSignature.cert.keySize=Veličina ključa +validateSignature.cert.version=Verzija +validateSignature.cert.keyUsage=Namena ključa +validateSignature.cert.selfSigned=Samopotpisan +validateSignature.cert.bits=bitova # Audit Dashboard -audit.dashboard.title=Audit Dashboard -audit.dashboard.systemStatus=Audit System Status +audit.dashboard.title=Nadzorna tabla za reviziju +audit.dashboard.systemStatus=Status sistema za reviziju audit.dashboard.status=Status -audit.dashboard.enabled=Enabled -audit.dashboard.disabled=Disabled -audit.dashboard.currentLevel=Current Level -audit.dashboard.retentionPeriod=Retention Period -audit.dashboard.days=days -audit.dashboard.totalEvents=Total Events +audit.dashboard.enabled=Omogućen +audit.dashboard.disabled=Onemogućen +audit.dashboard.currentLevel=Trenutni nivo +audit.dashboard.retentionPeriod=Period čuvanja +audit.dashboard.days=dana +audit.dashboard.totalEvents=Ukupno događaja # Audit Dashboard Tabs -audit.dashboard.tab.dashboard=Dashboard -audit.dashboard.tab.events=Audit Events -audit.dashboard.tab.export=Export +audit.dashboard.tab.dashboard=Kontrolna tabla +audit.dashboard.tab.events=Revizorski događaji +audit.dashboard.tab.export=Izvezi # Dashboard Charts -audit.dashboard.eventsByType=Events by Type -audit.dashboard.eventsByUser=Events by User -audit.dashboard.eventsOverTime=Events Over Time -audit.dashboard.period.7days=7 Days -audit.dashboard.period.30days=30 Days -audit.dashboard.period.90days=90 Days +audit.dashboard.eventsByType=Događaji po tipu +audit.dashboard.eventsByUser=Događaji po korisniku +audit.dashboard.eventsOverTime=Događaji tokom vremena +audit.dashboard.period.7days=7 dana +audit.dashboard.period.30days=30 dana +audit.dashboard.period.90days=90 dana # Events Tab -audit.dashboard.auditEvents=Audit Events -audit.dashboard.filter.eventType=Event Type -audit.dashboard.filter.allEventTypes=All event types -audit.dashboard.filter.user=User -audit.dashboard.filter.userPlaceholder=Filter by user -audit.dashboard.filter.startDate=Start Date -audit.dashboard.filter.endDate=End Date -audit.dashboard.filter.apply=Apply Filters -audit.dashboard.filter.reset=Reset Filters +audit.dashboard.auditEvents=Revizorski događaji +audit.dashboard.filter.eventType=Tip događaja +audit.dashboard.filter.allEventTypes=Svi tipovi događaja +audit.dashboard.filter.user=Korisnik +audit.dashboard.filter.userPlaceholder=Filtriraj po korisniku +audit.dashboard.filter.startDate=Početni datum +audit.dashboard.filter.endDate=Završni datum +audit.dashboard.filter.apply=Primeni filtere +audit.dashboard.filter.reset=Poništi filtere # Table Headers audit.dashboard.table.id=ID -audit.dashboard.table.time=Time -audit.dashboard.table.user=User -audit.dashboard.table.type=Type -audit.dashboard.table.details=Details -audit.dashboard.table.viewDetails=View Details +audit.dashboard.table.time=Vreme +audit.dashboard.table.user=Korisnik +audit.dashboard.table.type=Tip +audit.dashboard.table.details=Detalji +audit.dashboard.table.viewDetails=Pogledaj detalje # Pagination -audit.dashboard.pagination.show=Show -audit.dashboard.pagination.entries=entries -audit.dashboard.pagination.pageInfo1=Page -audit.dashboard.pagination.pageInfo2=of -audit.dashboard.pagination.totalRecords=Total records: +audit.dashboard.pagination.show=Prikaži +audit.dashboard.pagination.entries=stavke +audit.dashboard.pagination.pageInfo1=Strana +audit.dashboard.pagination.pageInfo2=od +audit.dashboard.pagination.totalRecords=Ukupno zapisa: # Modal -audit.dashboard.modal.eventDetails=Event Details +audit.dashboard.modal.eventDetails=Detalji događaja audit.dashboard.modal.id=ID -audit.dashboard.modal.user=User -audit.dashboard.modal.type=Type -audit.dashboard.modal.time=Time -audit.dashboard.modal.data=Data +audit.dashboard.modal.user=Korisnik +audit.dashboard.modal.type=Tip +audit.dashboard.modal.time=Vreme +audit.dashboard.modal.data=Datum # Export Tab -audit.dashboard.export.title=Export Audit Data -audit.dashboard.export.format=Export Format -audit.dashboard.export.csv=CSV (Comma Separated Values) -audit.dashboard.export.json=JSON (JavaScript Object Notation) -audit.dashboard.export.button=Export Data -audit.dashboard.export.infoTitle=Export Information -audit.dashboard.export.infoDesc1=The export will include all audit events matching the selected filters. For large datasets, the export may take a few moments to generate. -audit.dashboard.export.infoDesc2=Exported data will include: -audit.dashboard.export.infoItem1=Event ID -audit.dashboard.export.infoItem2=User -audit.dashboard.export.infoItem3=Event Type -audit.dashboard.export.infoItem4=Timestamp -audit.dashboard.export.infoItem5=Event Data +audit.dashboard.export.title=Izvoz revizorskih podataka +audit.dashboard.export.format=Format izvoza +audit.dashboard.export.csv=CSV (vrednosti odvojene zarezom) +audit.dashboard.export.json=JSON (JavaScript notacija objekata) +audit.dashboard.export.button=Izvezi podatke +audit.dashboard.export.infoTitle=Izvezi informacije +audit.dashboard.export.infoDesc1=Izvoz će obuhvatiti sve događaje revizije koji odgovaraju izabranim filterima. Za velike skupove podataka, izvoz može potrajati nekoliko trenutaka. +audit.dashboard.export.infoDesc2=Izvezeni podaci će sadržati: +audit.dashboard.export.infoItem1=ID događaja +audit.dashboard.export.infoItem2=Korisnik +audit.dashboard.export.infoItem3=Tip događaja +audit.dashboard.export.infoItem4=Vreme +audit.dashboard.export.infoItem5=Informacije o događaju # JavaScript i18n keys -audit.dashboard.js.noEventsFound=No audit events found matching the current filters -audit.dashboard.js.errorLoading=Error loading data: -audit.dashboard.js.errorRendering=Error rendering table: -audit.dashboard.js.loadingPage=Loading page +audit.dashboard.js.noEventsFound=Nisu pronađeni događaji koji odgovaraju trenutnim filterima +audit.dashboard.js.errorLoading=Greška prilikom učitavanja podataka: +audit.dashboard.js.errorRendering=Greška prilikom generisanja tabele: +audit.dashboard.js.loadingPage=Učitavam stranicu #################### # Cookie banner # #################### -cookieBanner.popUp.title=How we use Cookies -cookieBanner.popUp.description.1=We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love. -cookieBanner.popUp.description.2=If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly. -cookieBanner.popUp.acceptAllBtn=Okay -cookieBanner.popUp.acceptNecessaryBtn=No Thanks -cookieBanner.popUp.showPreferencesBtn=Manage preferences -cookieBanner.preferencesModal.title=Consent Preferences Center -cookieBanner.preferencesModal.acceptAllBtn=Accept all -cookieBanner.preferencesModal.acceptNecessaryBtn=Reject all -cookieBanner.preferencesModal.savePreferencesBtn=Save preferences -cookieBanner.preferencesModal.closeIconLabel=Close modal -cookieBanner.preferencesModal.serviceCounterLabel=Service|Services -cookieBanner.preferencesModal.subtitle=Cookie Usage -cookieBanner.preferencesModal.description.1=Stirling PDF uses cookies and similar technologies to enhance your experience and understand how our tools are used. This helps us improve performance, develop the features you care about, and provide ongoing support to our users. -cookieBanner.preferencesModal.description.2=Stirling PDF cannot—and will never—track or access the content of the documents you use. -cookieBanner.preferencesModal.description.3=Your privacy and trust are at the core of what we do. -cookieBanner.preferencesModal.necessary.title.1=Strictly Necessary Cookies -cookieBanner.preferencesModal.necessary.title.2=Always Enabled -cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off. -cookieBanner.preferencesModal.analytics.title=Analytics -cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with. +cookieBanner.popUp.title=Kako koristimo kolačiće +cookieBanner.popUp.description.1=Koristimo kolačiće i druge tehnologije kako bismo poboljšali rad Stirling PDF-a — pomažući nam da unapredimo naše alate i nastavimo da razvijamo funkcije koje ćete voleti. +cookieBanner.popUp.description.2=Ako to ne želite, klikom na 'Ne, hvala' biće omogućeni samo osnovni kolačići neophodni za nesmetan rad sistema. +cookieBanner.popUp.acceptAllBtn=U redu +cookieBanner.popUp.acceptNecessaryBtn=Ne, hvala +cookieBanner.popUp.showPreferencesBtn=Upravljaj podešavanjima +cookieBanner.preferencesModal.title=Centar za podešavanja saglasnoti +cookieBanner.preferencesModal.acceptAllBtn=Prihvati sve +cookieBanner.preferencesModal.acceptNecessaryBtn=Odbij sve +cookieBanner.preferencesModal.savePreferencesBtn=Sačuvaj podešavanja +cookieBanner.preferencesModal.closeIconLabel=Zatvori modal +cookieBanner.preferencesModal.serviceCounterLabel=Usluga|Usluge +cookieBanner.preferencesModal.subtitle=Korišćenje kolačića +cookieBanner.preferencesModal.description.1=Stirling PDF koristi kolačiće i slične tehnologije kako bi poboljšao vaše iskustvo i razumeo kako se naši alati koriste. Ovo nam pomaže da unapredimo performanse, razvijamo funkcije koje su vam važne i pružimo kontinuiranu podršku našim korisnicima. +cookieBanner.preferencesModal.description.2=Stirling PDF ne može — i nikada neće — pratiti ili pristupati sadržaju dokumenata koje koristite. +cookieBanner.preferencesModal.description.3=Vaša privatnost i poverenje su osnovni principi našeg rada. +cookieBanner.preferencesModal.necessary.title.1=Isključivo neophodni kolačići +cookieBanner.preferencesModal.necessary.title.2=Uvek omogućeno +cookieBanner.preferencesModal.necessary.description=Ovi kolačići su neophodni za pravilno funkcionisanje sajta. Omogućavaju osnovne funkcije kao što su podešavanje privatnosti, prijavljivanje i popunjavanje obrazaca — zato ih nije moguće isključiti. +cookieBanner.preferencesModal.analytics.title=Analitika +cookieBanner.preferencesModal.analytics.description=Ovi kolačići nam pomažu da razumemo kako se naši alati koriste, kako bismo mogli da se fokusiramo na razvoj funkcija koje naša zajednica najviše ceni. Budite sigurni — Stirling PDF ne može i nikada neće pratiti sadržaj dokumenata sa kojima radite. #fakeScan -fakeScan.title=Fake Scan -fakeScan.header=Fake Scan -fakeScan.description=Create a PDF that looks like it was scanned -fakeScan.selectPDF=Select PDF: -fakeScan.quality=Scan Quality -fakeScan.quality.low=Low -fakeScan.quality.medium=Medium -fakeScan.quality.high=High -fakeScan.rotation=Rotation Angle -fakeScan.rotation.none=None -fakeScan.rotation.slight=Slight -fakeScan.rotation.moderate=Moderate -fakeScan.rotation.severe=Severe -fakeScan.submit=Create Fake Scan +fakeScan.title=Lažno skeniranje +fakeScan.header=Lažno skeniranje +fakeScan.description=Kreiraj PDF koji izgleda kao da je skeniran +fakeScan.selectPDF=Izaberi PDF: +fakeScan.quality=Kvalitet skeniranja: +fakeScan.quality.low=Nizak +fakeScan.quality.medium=Srednji +fakeScan.quality.high=Visok +fakeScan.rotation=Ugao rotiranja: +fakeScan.rotation.none=Nijedno +fakeScan.rotation.slight=Blago +fakeScan.rotation.moderate=Umereno +fakeScan.rotation.severe=Značajno +fakeScan.submit=Kreiraj lažno skeniranje #home.fakeScan -home.fakeScan.title=Fake Scan -home.fakeScan.desc=Create a PDF that looks like it was scanned -fakeScan.tags=scan,simulate,realistic,convert +home.fakeScan.title=Lažno skeniranje +home.fakeScan.desc=Kreiraj PDF koji izgleda kao da je skeniran +fakeScan.tags=sken,simuliraj,realistično,konvertuj # FakeScan advanced settings (frontend) -fakeScan.advancedSettings=Enable Advanced Scan Settings -fakeScan.colorspace=Colorspace -fakeScan.colorspace.grayscale=Grayscale -fakeScan.colorspace.color=Color -fakeScan.border=Border (px) -fakeScan.rotate=Base Rotation (degrees) -fakeScan.rotateVariance=Rotation Variance (degrees) -fakeScan.brightness=Brightness -fakeScan.contrast=Contrast -fakeScan.blur=Blur -fakeScan.noise=Noise -fakeScan.yellowish=Yellowish (simulate old paper) -fakeScan.resolution=Resolution (DPI) +fakeScan.advancedSettings=Omogući naprednja podešavanja za skeniranje +fakeScan.colorspace=Režim boja: +fakeScan.colorspace.grayscale=Monohromatski +fakeScan.colorspace.color=Kolor +fakeScan.border=Ivica (px) +fakeScan.rotate=Osnovni ugao rotacije (stepeni) +fakeScan.rotateVariance=Varijacija rotacije (stepeni) +fakeScan.brightness=Osvetljenje +fakeScan.contrast=Kontrast +fakeScan.blur=Zamućenje +fakeScan.noise=Buka +fakeScan.yellowish=Žutilo (simulacija starog papira) +fakeScan.resolution=Rezolucija (DPI) # Table of Contents Feature -home.editTableOfContents.title=Edit Table of Contents -home.editTableOfContents.desc=Add or edit bookmarks and table of contents in PDF documents +home.editTableOfContents.title=Izmeni sadržaj +home.editTableOfContents.desc=Dodaj ili izmeni obeleživače i sadržaj u PDF dokumentima -editTableOfContents.tags=bookmarks,toc,navigation,index,table of contents,chapters,sections,outline -editTableOfContents.title=Edit Table of Contents -editTableOfContents.header=Add or Edit PDF Table of Contents -editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to append to existing) -editTableOfContents.editorTitle=Bookmark Editor -editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks. -editTableOfContents.addBookmark=Add New Bookmark -editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document. -editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks. -editTableOfContents.desc.3=Each bookmark requires a title and target page number. -editTableOfContents.submit=Apply Table of Contents +editTableOfContents.tags=obeleživači,sadržaj,navigacija,indeks,poglavlja,sekcije,raspored +editTableOfContents.title=Izmeni sadržaj +editTableOfContents.header=Dodaj ili izmeni sadržaj PDF dokumenta +editTableOfContents.replaceExisting=Zameni postojeće obeleživače (isključi da bi se dodali na postojeće) +editTableOfContents.editorTitle=Editor obeleživača +editTableOfContents.editorDesc=Dodaj i rasporedi obeleživače ispod. Klikni + za dodavanje podređenih obeleživača. +editTableOfContents.addBookmark=Dodaj novi obeleživač +editTableOfContents.desc.1=Ovaj alat omogućava dodavanje ili izmenu sadržaja (obeleživača) u PDF dokumentu. +editTableOfContents.desc.2=Moguće je kreirati hijerarhijsku strukturu dodavanjem podređenih obeleživača nadređenim obeleživačima. +editTableOfContents.desc.3=Svaki obeleživač zahteva naslov i broj ciljne strane. +editTableOfContents.submit=Potvrdi diff --git a/stirling-pdf/src/main/resources/settings.yml.template b/stirling-pdf/src/main/resources/settings.yml.template index d651eff9f..d45b8482b 100644 --- a/stirling-pdf/src/main/resources/settings.yml.template +++ b/stirling-pdf/src/main/resources/settings.yml.template @@ -125,6 +125,15 @@ system: weasyprint: '' #Defaults to /opt/venv/bin/weasyprint unoconvert: '' #Defaults to /opt/venv/bin/unoconvert fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB". + tempFileManagement: + baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf + libreofficeDir: '' # Defaults to tempFileManagement.baseTmpDir/libreoffice + systemTempDir: '' # Only used if cleanupSystemTemp is true + prefix: stirling-pdf- # Prefix for temp file names + maxAgeHours: 24 # Maximum age in hours before temp files are cleaned up + cleanupIntervalMinutes: 30 # How often to run cleanup (in minutes) + startupCleanup: true # Clean up old temp files on startup + cleanupSystemTemp: false # Whether to clean broader system temp directory ui: appName: '' # application's visible name diff --git a/stirling-pdf/src/main/resources/static/fonts/NotoSansThai-Regular.ttf b/stirling-pdf/src/main/resources/static/fonts/NotoSansThai-Regular.ttf new file mode 100644 index 000000000..7f72a6e14 Binary files /dev/null and b/stirling-pdf/src/main/resources/static/fonts/NotoSansThai-Regular.ttf differ diff --git a/stirling-pdf/src/main/resources/templates/misc/stamp.html b/stirling-pdf/src/main/resources/templates/misc/stamp.html index d25128303..f3745918a 100644 --- a/stirling-pdf/src/main/resources/templates/misc/stamp.html +++ b/stirling-pdf/src/main/resources/templates/misc/stamp.html @@ -88,6 +88,7 @@ + diff --git a/stirling-pdf/src/main/resources/templates/security/add-watermark.html b/stirling-pdf/src/main/resources/templates/security/add-watermark.html index 950985b0a..a556d21fc 100644 --- a/stirling-pdf/src/main/resources/templates/security/add-watermark.html +++ b/stirling-pdf/src/main/resources/templates/security/add-watermark.html @@ -38,6 +38,7 @@ +
diff --git a/testing/test.sh b/testing/test.sh index 4658edeb5..94370807b 100644 --- a/testing/test.sh +++ b/testing/test.sh @@ -55,10 +55,12 @@ capture_file_list() { -not -path '/config/*' \ -not -path '/logs/*' \ -not -path '*/home/stirlingpdfuser/.config/libreoffice/*' \ - -not -path '*/tmp/PDFBox*' \ + -not -path '*/home/stirlingpdfuser/.pdfbox.cache' \ + -not -path '*/tmp/stirling-pdf/PDFBox*' \ + -not -path '*/tmp/stirling-pdf/hsperfdata_stirlingpdfuser/*' \ -not -path '*/tmp/hsperfdata_stirlingpdfuser/*' \ - -not -path '*/tmp/lu*' \ - -not -path '*/tmp/tmp*' \ + -not -path '*/tmp/stirling-pdf/lu*' \ + -not -path '*/tmp/stirling-pdf/tmp*' \ 2>/dev/null | xargs -I{} sh -c 'stat -c \"%n %s %Y\" \"{}\" 2>/dev/null || true' | sort" > "$output_file" # Check if the output file has content @@ -74,8 +76,10 @@ capture_file_list() { -not -path '/config/*' \ -not -path '/logs/*' \ -not -path '*/home/stirlingpdfuser/.config/libreoffice/*' \ + -not -path '*/home/stirlingpdfuser/.pdfbox.cache' \ -not -path '*/tmp/PDFBox*' \ -not -path '*/tmp/hsperfdata_stirlingpdfuser/*' \ + -not -path '*/tmp/stirling-pdf/hsperfdata_stirlingpdfuser/*' \ -not -path '*/tmp/lu*' \ -not -path '*/tmp/tmp*' \ 2>/dev/null | sort" > "$output_file"