diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index 97cb44086..000000000 --- a/.github/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# CI Configuration - -## CI Lite Mode - -Skip non-essential CI workflows by setting a repository variable: - -**Settings → Secrets and variables → Actions → Variables → New repository variable** - -- Name: `CI_PROFILE` -- Value: `lite` - -Skips resource-intensive builds, releases, and OSS-specific workflows. Useful for deployment-only forks or faster CI runs. diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index ad6d6f3dc..c96d7d5b2 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -52,7 +52,6 @@ jobs: core.setOutput('repository', pr.head.repo.full_name); core.setOutput('ref', pr.head.ref); core.setOutput('is_fork', String(pr.head.repo.fork)); - core.setOutput('base_ref', pr.base.ref); core.setOutput('author', pr.user.login); core.setOutput('state', pr.state); @@ -65,10 +64,6 @@ jobs: IS_FORK: ${{ steps.resolve.outputs.is_fork }} # nur bei workflow_dispatch gesetzt: ALLOW_FORK_INPUT: ${{ inputs.allow_fork }} - # für Auto-PR-Logik: - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BRANCH: ${{ github.event.pull_request.head.ref }} - PR_BASE: ${{ steps.resolve.outputs.base_ref }} PR_AUTHOR: ${{ steps.resolve.outputs.author }} run: | set -e @@ -89,14 +84,8 @@ jobs: else auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done - if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then + if [ "$is_auth" = true ]; then should=true - else - title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true - branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true - if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then - should=true - fi fi fi @@ -174,7 +163,7 @@ jobs: owner, repo, issue_number: prNumber, - body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.` + body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment for approved V2 contributors._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.` }); return newComment.id; @@ -394,7 +383,7 @@ jobs: `🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` + `🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` + - `🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`; + `🔄 **Auto-deployed** for approved V2 contributors.`; await github.rest.issues.createComment({ owner, diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a049bb90..a5df3e9ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -262,7 +262,13 @@ jobs: strategy: fail-fast: false matrix: - docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/Dockerfile.fat"] + include: + - docker-rev: docker/embedded/Dockerfile + artifact-suffix: Dockerfile + - docker-rev: docker/embedded/Dockerfile.ultra-lite + artifact-suffix: Dockerfile.ultra-lite + - docker-rev: docker/embedded/Dockerfile.fat + artifact-suffix: Dockerfile.fat steps: - name: Harden Runner uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 @@ -272,6 +278,13 @@ jobs: - name: Checkout Repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Free disk space on runner + run: | + echo "Disk space before cleanup:" && df -h + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost + docker system prune -af || true + echo "Disk space after cleanup:" && df -h + - name: Set up JDK 17 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 with: @@ -313,7 +326,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: reports-docker-${{ matrix.docker-rev }} + name: reports-docker-${{ matrix.artifact-suffix }} path: | build/reports/tests/ build/test-results/ diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 35f68939c..ff48b5b2e 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -491,6 +491,9 @@ public class EndpointConfiguration { addEndpointToGroup("Ghostscript", "repair"); addEndpointToGroup("Ghostscript", "compress-pdf"); + /* ImageMagick */ + addEndpointToGroup("ImageMagick", "compress-pdf"); + /* tesseract */ addEndpointToGroup("tesseract", "ocr-pdf"); @@ -574,6 +577,7 @@ public class EndpointConfiguration { || "Javascript".equals(group) || "Weasyprint".equals(group) || "Pdftohtml".equals(group) + || "ImageMagick".equals(group) || "rar".equals(group); } diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index 272c0b35c..1e9d67269 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -37,10 +37,6 @@ public class AppConfig { private final ApplicationProperties applicationProperties; - @Getter - @Value("${baseUrl:http://localhost}") - private String baseUrl; - @Getter @Value("${server.servlet.context-path:/}") private String contextPath; @@ -49,6 +45,17 @@ public class AppConfig { @Value("${server.port:8080}") private String serverPort; + /** + * Get the backend URL from system configuration. Falls back to http://localhost if not + * configured. + * + * @return The backend base URL for SAML/OAuth/API callbacks + */ + public String getBackendUrl() { + String backendUrl = applicationProperties.getSystem().getBackendUrl(); + return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost"; + } + @Value("${v2}") public boolean v2Enabled; diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 35ddddeaa..72cfef1a0 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -68,6 +68,7 @@ public class ApplicationProperties { private AutoPipeline autoPipeline = new AutoPipeline(); private ProcessExecutor processExecutor = new ProcessExecutor(); + private PdfEditor pdfEditor = new PdfEditor(); @Bean public PropertySource dynamicYamlPropertySource(ConfigurableEnvironment environment) @@ -100,6 +101,46 @@ public class ApplicationProperties { private String outputFolder; } + @Data + public static class PdfEditor { + private Cache cache = new Cache(); + private FontNormalization fontNormalization = new FontNormalization(); + private CffConverter cffConverter = new CffConverter(); + private Type3 type3 = new Type3(); + private String fallbackFont = "classpath:/static/fonts/NotoSans-Regular.ttf"; + + @Data + public static class Cache { + private long maxBytes = -1; + private int maxPercent = 20; + } + + @Data + public static class FontNormalization { + private boolean enabled = false; + } + + @Data + public static class CffConverter { + private boolean enabled = true; + private String method = "python"; + private String pythonCommand = "/opt/venv/bin/python3"; + private String pythonScript = "/scripts/convert_cff_to_ttf.py"; + private String fontforgeCommand = "fontforge"; + } + + @Data + public static class Type3 { + private Library library = new Library(); + + @Data + public static class Library { + private boolean enabled = true; + private String index = "classpath:/type3/library/index.json"; + } + } + } + @Data public static class Legal { private String termsAndConditions; @@ -357,6 +398,7 @@ public class ApplicationProperties { private Boolean enableAnalytics; private Boolean enablePosthog; private Boolean enableScarf; + private Boolean enableDesktopInstallSlide; private Datasource datasource; private Boolean disableSanitize; private int maxDPI; @@ -367,10 +409,12 @@ public class ApplicationProperties { private TempFileManagement tempFileManagement = new TempFileManagement(); private DatabaseBackup databaseBackup = new DatabaseBackup(); private List corsAllowedOrigins = new ArrayList<>(); - private String - frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set, + private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g. + // 'http://localhost:8080', 'https://api.example.com'). Required for + // SSO. + private String frontendUrl; // Frontend URL for invite email links (e.g. - // falls back to backend URL. + // 'https://app.example.com'). If not set, falls back to backendUrl. public boolean isAnalyticsEnabled() { return this.getEnableAnalytics() != null && this.getEnableAnalytics(); @@ -535,6 +579,7 @@ public class ApplicationProperties { @ToString.Exclude private String key; private String UUID; private String appVersion; + private Boolean isNewServer; } // TODO: Remove post migration @@ -652,6 +697,7 @@ public class ApplicationProperties { private int weasyPrintSessionLimit; private int installAppSessionLimit; private int calibreSessionLimit; + private int imageMagickSessionLimit; private int qpdfSessionLimit; private int tesseractSessionLimit; private int ghostscriptSessionLimit; @@ -689,6 +735,10 @@ public class ApplicationProperties { return calibreSessionLimit > 0 ? calibreSessionLimit : 1; } + public int getImageMagickSessionLimit() { + return imageMagickSessionLimit > 0 ? imageMagickSessionLimit : 4; + } + public int getGhostscriptSessionLimit() { return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8; } @@ -718,6 +768,8 @@ public class ApplicationProperties { @JsonProperty("calibretimeoutMinutes") private long calibreTimeoutMinutes; + private long imageMagickTimeoutMinutes; + private long tesseractTimeoutMinutes; private long qpdfTimeoutMinutes; private long ghostscriptTimeoutMinutes; @@ -755,6 +807,10 @@ public class ApplicationProperties { return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30; } + public long getImageMagickTimeoutMinutes() { + return imageMagickTimeoutMinutes > 0 ? imageMagickTimeoutMinutes : 30; + } + public long getGhostscriptTimeoutMinutes() { return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30; } diff --git a/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java b/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java new file mode 100644 index 000000000..ab4f55d2e --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java @@ -0,0 +1,12 @@ +package stirling.software.common.service; + +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; + +public interface LineArtConversionService { + PDImageXObject convertImageToLineArt( + PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel) + throws IOException; +} diff --git a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java index 3b94fbfbc..269441813 100644 --- a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java +++ b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java @@ -86,6 +86,11 @@ public class ProcessExecutor { .getProcessExecutor() .getSessionLimit() .getCalibreSessionLimit(); + case IMAGEMAGICK -> + applicationProperties + .getProcessExecutor() + .getSessionLimit() + .getImageMagickSessionLimit(); case GHOSTSCRIPT -> applicationProperties .getProcessExecutor() @@ -141,6 +146,11 @@ public class ProcessExecutor { .getProcessExecutor() .getTimeoutMinutes() .getCalibreTimeoutMinutes(); + case IMAGEMAGICK -> + applicationProperties + .getProcessExecutor() + .getTimeoutMinutes() + .getImageMagickTimeoutMinutes(); case GHOSTSCRIPT -> applicationProperties .getProcessExecutor() @@ -301,6 +311,7 @@ public class ProcessExecutor { WEASYPRINT, INSTALL_APP, CALIBRE, + IMAGEMAGICK, TESSERACT, QPDF, GHOSTSCRIPT, diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index f1763e431..acad6f4a9 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -26,6 +26,7 @@ public class RequestUriUtils { || normalizedUri.startsWith("/public/") || normalizedUri.startsWith("/pdfjs/") || normalizedUri.startsWith("/pdfjs-legacy/") + || normalizedUri.startsWith("/pdfium/") || normalizedUri.startsWith("/assets/") || normalizedUri.startsWith("/locales/") || normalizedUri.startsWith("/Login/") @@ -61,7 +62,8 @@ public class RequestUriUtils { || normalizedUri.endsWith(".css") || normalizedUri.endsWith(".mjs") || normalizedUri.endsWith(".html") - || normalizedUri.endsWith(".toml"); + || normalizedUri.endsWith(".toml") + || normalizedUri.endsWith(".wasm"); } public static boolean isFrontendRoute(String contextPath, String requestURI) { @@ -125,11 +127,13 @@ public class RequestUriUtils { || requestURI.endsWith("popularity.txt") || requestURI.endsWith(".js") || requestURI.endsWith(".toml") + || requestURI.endsWith(".wasm") || requestURI.contains("swagger") || requestURI.startsWith("/api/v1/info") || requestURI.startsWith("/site.webmanifest") || requestURI.startsWith("/fonts") - || requestURI.startsWith("/pdfjs")); + || requestURI.startsWith("/pdfjs") + || requestURI.startsWith("/pdfium")); } /** diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index b7c121ab3..409615e68 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -24,6 +24,9 @@ public class RequestUriUtilsTest { assertTrue( RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"), "PDF.js files should be static"); + assertTrue( + RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"), + "PDFium wasm should be static"); assertTrue( RequestUriUtils.isStaticResource("/api/v1/info/status"), "API status should be static"); @@ -110,7 +113,8 @@ public class RequestUriUtilsTest { "/downloads/document.png", "/assets/brand.ico", "/any/path/with/image.svg", - "/deep/nested/folder/icon.png" + "/deep/nested/folder/icon.png", + "/pdfium/pdfium.wasm" }) void testIsStaticResourceWithFileExtensions(String path) { assertTrue( @@ -148,6 +152,9 @@ public class RequestUriUtilsTest { assertFalse( RequestUriUtils.isTrackableResource("/script.js"), "JS files should not be trackable"); + assertFalse( + RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"), + "PDFium wasm should not be trackable"); assertFalse( RequestUriUtils.isTrackableResource("/swagger/index.html"), "Swagger files should not be trackable"); @@ -224,7 +231,8 @@ public class RequestUriUtilsTest { "/api/v1/info/health", "/site.webmanifest", "/fonts/roboto.woff", - "/pdfjs/viewer.js" + "/pdfjs/viewer.js", + "/pdfium/pdfium.wasm" }) void testNonTrackableResources(String path) { assertFalse( diff --git a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java index d3a4ce776..9cb4a786a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java +++ b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -138,13 +138,13 @@ public class SPDFApplication { @PostConstruct public void init() { - String baseUrl = appConfig.getBaseUrl(); + String backendUrl = appConfig.getBackendUrl(); String contextPath = appConfig.getContextPath(); String serverPort = appConfig.getServerPort(); - baseUrlStatic = baseUrl; + baseUrlStatic = backendUrl; contextPathStatic = contextPath; serverPortStatic = serverPort; - String url = baseUrl + ":" + getStaticPort() + contextPath; + String url = backendUrl + ":" + getStaticPort() + contextPath; // Log Tauri mode information if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) { diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 59c8825fc..8606cc2a9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -46,6 +46,7 @@ public class ExternalAppDepConfig { put("qpdf", List.of("qpdf")); put("tesseract", List.of("tesseract")); put("rar", List.of("rar")); // Required for real CBR output + put("magick", List.of("ImageMagick")); } }; } @@ -128,6 +129,7 @@ public class ExternalAppDepConfig { checkDependencyAndDisableGroup("pdftohtml"); checkDependencyAndDisableGroup(unoconvPath); checkDependencyAndDisableGroup("rar"); + checkDependencyAndDisableGroup("magick"); // Special handling for Python/OpenCV dependencies boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python"); if (!pythonAvailable) { diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index 88755f950..2cded405f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -94,6 +94,7 @@ public class InitialSetup { } GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion); applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion); + applicationProperties.getAutomaticallyGenerated().setIsNewServer(isNewServer); } public static boolean isNewServer() { diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 0703708f5..71c402aa8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -1,10 +1,14 @@ package stirling.software.SPDF.config; +import java.util.concurrent.TimeUnit; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import lombok.RequiredArgsConstructor; @@ -25,6 +29,41 @@ public class WebMvcConfig implements WebMvcConfigurer { registry.addInterceptor(endpointInterceptor); } + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + // Cache hashed assets (JS/CSS with content hashes) for 1 year + // These files have names like index-ChAS4tCC.js that change when content changes + // Check customFiles/static first, then fall back to classpath + registry.addResourceHandler("/assets/**") + .addResourceLocations( + "file:" + + stirling.software.common.configuration.InstallationPathConfig + .getStaticPath() + + "assets/", + "classpath:/static/assets/") + .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()); + + // Don't cache index.html - it needs to be fresh to reference latest hashed assets + // Note: index.html is handled by ReactRoutingController for dynamic processing + registry.addResourceHandler("/index.html") + .addResourceLocations( + "file:" + + stirling.software.common.configuration.InstallationPathConfig + .getStaticPath(), + "classpath:/static/") + .setCacheControl(CacheControl.noCache().mustRevalidate()); + + // Handle all other static resources (js, css, images, fonts, etc.) + // Check customFiles/static first for user overrides + registry.addResourceHandler("/**") + .addResourceLocations( + "file:" + + stirling.software.common.configuration.InstallationPathConfig + .getStaticPath(), + "classpath:/static/") + .setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)); + } + @Override public void addCorsMappings(CorsRegistry registry) { // Check if running in Tauri mode diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java new file mode 100644 index 000000000..c82fe19e1 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java @@ -0,0 +1,60 @@ +package stirling.software.SPDF.controller.api.converters; + +import java.nio.charset.StandardCharsets; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.exception.CacheUnavailableException; + +@ControllerAdvice(assignableTypes = ConvertPdfJsonController.class) +@Slf4j +@RequiredArgsConstructor +public class ConvertPdfJsonExceptionHandler { + + private final ObjectMapper objectMapper; + + @ExceptionHandler(CacheUnavailableException.class) + @ResponseBody + public ResponseEntity handleCacheUnavailable(CacheUnavailableException ex) { + try { + byte[] body = + objectMapper.writeValueAsBytes( + java.util.Map.of( + "error", "cache_unavailable", + "action", "reupload", + "message", ex.getMessage())); + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.APPLICATION_JSON) + .body(body); + } catch (Exception e) { + log.warn("Failed to serialize cache_unavailable response", e); + var fallbackBody = + java.util.Map.of( + "error", "cache_unavailable", + "action", "reupload", + "message", String.valueOf(ex.getMessage())); + try { + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.APPLICATION_JSON) + .body(objectMapper.writeValueAsBytes(fallbackBody)); + } catch (Exception ignored) { + // Truly last-ditch fallback + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.APPLICATION_JSON) + .body( + "{\"error\":\"cache_unavailable\",\"action\":\"reupload\",\"message\":\"Cache unavailable\"}" + .getBytes(StandardCharsets.UTF_8)); + } + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java index 865a95c5c..9b0483da9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java @@ -28,10 +28,13 @@ import org.apache.pdfbox.pdmodel.PDResources; import org.apache.pdfbox.pdmodel.graphics.PDXObject; import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; import io.swagger.v3.oas.annotations.Operation; @@ -44,6 +47,7 @@ import stirling.software.SPDF.model.api.misc.OptimizePdfRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.LineArtConversionService; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.ProcessExecutor; @@ -58,6 +62,9 @@ public class CompressController { private final CustomPDFDocumentFactory pdfDocumentFactory; private final EndpointConfiguration endpointConfiguration; + @Autowired(required = false) + private LineArtConversionService lineArtConversionService; + private boolean isQpdfEnabled() { return endpointConfiguration.isGroupEnabled("qpdf"); } @@ -66,6 +73,10 @@ public class CompressController { return endpointConfiguration.isGroupEnabled("Ghostscript"); } + private boolean isImageMagickEnabled() { + return endpointConfiguration.isGroupEnabled("ImageMagick"); + } + @Data @AllArgsConstructor @NoArgsConstructor @@ -660,6 +671,9 @@ public class CompressController { Integer optimizeLevel = request.getOptimizeLevel(); String expectedOutputSizeString = request.getExpectedOutputSize(); Boolean convertToGrayscale = request.getGrayscale(); + Boolean convertToLineArt = request.getLineArt(); + Double lineArtThreshold = request.getLineArtThreshold(); + Integer lineArtEdgeLevel = request.getLineArtEdgeLevel(); if (expectedOutputSizeString == null && optimizeLevel == null) { throw new Exception("Both expected output size and optimize level are not specified"); } @@ -689,6 +703,26 @@ public class CompressController { optimizeLevel = determineOptimizeLevel(sizeReductionRatio); } + if (Boolean.TRUE.equals(convertToLineArt)) { + if (lineArtConversionService == null) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Line art conversion is unavailable - ImageMagick service not found"); + } + if (!isImageMagickEnabled()) { + throw new IOException( + "ImageMagick is not enabled but line art conversion was requested"); + } + double thresholdValue = + lineArtThreshold == null + ? 55d + : Math.min(100d, Math.max(0d, lineArtThreshold)); + int edgeLevel = + lineArtEdgeLevel == null ? 1 : Math.min(3, Math.max(1, lineArtEdgeLevel)); + currentFile = + applyLineArtConversion(currentFile, tempFiles, thresholdValue, edgeLevel); + } + boolean sizeMet = false; boolean imageCompressionApplied = false; boolean externalCompressionApplied = false; @@ -810,6 +844,75 @@ public class CompressController { } } + private Path applyLineArtConversion( + Path currentFile, List tempFiles, double threshold, int edgeLevel) + throws IOException { + + Path lineArtFile = Files.createTempFile("lineart_output_", ".pdf"); + tempFiles.add(lineArtFile); + + try (PDDocument doc = pdfDocumentFactory.load(currentFile.toFile())) { + Map> uniqueImages = findImages(doc); + CompressionStats stats = new CompressionStats(); + stats.uniqueImagesCount = uniqueImages.size(); + calculateImageStats(uniqueImages, stats); + + Map convertedImages = + createLineArtImages(doc, uniqueImages, stats, threshold, edgeLevel); + + replaceImages(doc, uniqueImages, convertedImages, stats); + + log.info( + "Applied line art conversion to {} unique images ({} total references)", + stats.uniqueImagesCount, + stats.totalImages); + + doc.save(lineArtFile.toString()); + return lineArtFile; + } + } + + private Map createLineArtImages( + PDDocument doc, + Map> uniqueImages, + CompressionStats stats, + double threshold, + int edgeLevel) + throws IOException { + + Map convertedImages = new HashMap<>(); + + for (Entry> entry : uniqueImages.entrySet()) { + String imageHash = entry.getKey(); + List references = entry.getValue(); + if (references.isEmpty()) continue; + + PDImageXObject originalImage = getOriginalImage(doc, references.get(0)); + + int originalSize = (int) originalImage.getCOSObject().getLength(); + stats.totalOriginalBytes += originalSize; + + PDImageXObject converted = + lineArtConversionService.convertImageToLineArt( + doc, originalImage, threshold, edgeLevel); + convertedImages.put(imageHash, converted); + stats.compressedImages++; + + int convertedSize = (int) converted.getCOSObject().getLength(); + stats.totalCompressedBytes += convertedSize * references.size(); + + double reductionPercentage = 100.0 - ((convertedSize * 100.0) / originalSize); + log.info( + "Image hash {}: Line art conversion {} → {} (reduced by {}%)", + imageHash, + GeneralUtils.formatBytes(originalSize), + GeneralUtils.formatBytes(convertedSize), + String.format("%.1f", reductionPercentage)); + } + + return convertedImages; + } + // Run Ghostscript compression private void applyGhostscriptCompression( OptimizePdfRequest request, int optimizeLevel, Path currentFile, List tempFiles) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 43aaecd9d..2f8d4b62b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -66,7 +66,8 @@ public class ConfigController { AppConfig appConfig = applicationContext.getBean(AppConfig.class); // Extract key configuration values from AppConfig - configData.put("baseUrl", appConfig.getBaseUrl()); + // Note: Frontend expects "baseUrl" field name for compatibility + configData.put("baseUrl", appConfig.getBackendUrl()); configData.put("contextPath", appConfig.getContextPath()); configData.put("serverPort", appConfig.getServerPort()); @@ -124,6 +125,9 @@ public class ConfigController { "enableAnalytics", applicationProperties.getSystem().getEnableAnalytics()); configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog()); configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf()); + configData.put( + "enableDesktopInstallSlide", + applicationProperties.getSystem().getEnableDesktopInstallSlide()); // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); @@ -227,4 +231,10 @@ public class ConfigController { } return ResponseEntity.ok(result); } + + @GetMapping("/group-enabled") + public ResponseEntity isGroupEnabled(@RequestParam(name = "group") String group) { + boolean enabled = endpointConfiguration.isGroupEnabled(group); + return ResponseEntity.ok(enabled); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index 3b27492a6..893c9c54c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -191,6 +191,12 @@ public class CertSignController { switch (certType) { case "PEM": + privateKeyFile = + validateFilePresent( + privateKeyFile, "PEM private key", "private key file is required"); + certFile = + validateFilePresent( + certFile, "PEM certificate", "certificate file is required"); ks = KeyStore.getInstance("JKS"); ks.load(null); PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password); @@ -200,10 +206,16 @@ public class CertSignController { break; case "PKCS12": case "PFX": + p12File = + validateFilePresent( + p12File, "PKCS12 keystore", "PKCS12/PFX keystore file is required"); ks = KeyStore.getInstance("PKCS12"); ks.load(p12File.getInputStream(), password.toCharArray()); break; case "JKS": + jksfile = + validateFilePresent( + jksfile, "JKS keystore", "JKS keystore file is required"); ks = KeyStore.getInstance("JKS"); ks.load(jksfile.getInputStream(), password.toCharArray()); break; @@ -251,6 +263,17 @@ public class CertSignController { GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_signed.pdf")); } + private MultipartFile validateFilePresent( + MultipartFile file, String argumentName, String errorDescription) { + if (file == null || file.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidArgument", + "Invalid argument: {0}", + argumentName + " - " + errorDescription); + } + return file; + } + private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password) throws IOException, OperatorCreationException, PKCSException { try (PEMParser pemParser = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 6373e0752..eddff0e1e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -3,9 +3,14 @@ package stirling.software.SPDF.controller.web; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; @@ -14,6 +19,11 @@ import org.springframework.web.bind.annotation.GetMapping; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; + +@Slf4j @Controller public class ReactRoutingController { @@ -22,24 +32,44 @@ public class ReactRoutingController { private String cachedIndexHtml; private boolean indexHtmlExists = false; + private boolean useExternalIndexHtml = false; @PostConstruct public void init() { - // Only cache if index.html exists (production builds) + log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath()); + + // Check for external index.html first (customFiles/static/) + Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html"); + log.debug("Checking for custom index.html at: {}", externalIndexPath); + if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) { + log.info("Using custom index.html from: {}", externalIndexPath); + try { + this.cachedIndexHtml = processIndexHtml(); + this.indexHtmlExists = true; + this.useExternalIndexHtml = true; + return; + } catch (IOException e) { + log.warn("Failed to load custom index.html, falling back to classpath", e); + } + } + + // Fall back to classpath index.html ClassPathResource resource = new ClassPathResource("static/index.html"); if (resource.exists()) { try { this.cachedIndexHtml = processIndexHtml(); this.indexHtmlExists = true; + this.useExternalIndexHtml = false; } catch (IOException e) { // Failed to cache, will process on each request + log.warn("Failed to cache index.html", e); this.indexHtmlExists = false; } } } private String processIndexHtml() throws IOException { - ClassPathResource resource = new ClassPathResource("static/index.html"); + Resource resource = getIndexHtmlResource(); try (InputStream inputStream = resource.getInputStream()) { String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); @@ -62,6 +92,17 @@ public class ReactRoutingController { } } + private Resource getIndexHtmlResource() throws IOException { + // Check external location first + Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html"); + if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) { + return new FileSystemResource(externalIndexPath.toFile()); + } + + // Fall back to classpath + return new ClassPathResource("static/index.html"); + } + @GetMapping( value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE) @@ -74,13 +115,13 @@ public class ReactRoutingController { } @GetMapping( - "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") + "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") public ResponseEntity forwardRootPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); } @GetMapping( - "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") + "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") public ResponseEntity forwardNestedPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); diff --git a/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java b/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java new file mode 100644 index 000000000..fd5d77677 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java @@ -0,0 +1,8 @@ +package stirling.software.SPDF.exception; + +public class CacheUnavailableException extends RuntimeException { + + public CacheUnavailableException(String message) { + super(message); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java index bf96dd217..d6e5c7021 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java @@ -45,4 +45,26 @@ public class OptimizePdfRequest extends PDFFile { requiredMode = Schema.RequiredMode.REQUIRED, defaultValue = "false") private Boolean grayscale = false; + + @Schema( + description = + "Whether to convert images to high-contrast line art using ImageMagick. Default is false.", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + defaultValue = "false") + private Boolean lineArt = false; + + @Schema( + description = "Threshold to use for line art conversion (0-100).", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + defaultValue = "55") + private Double lineArtThreshold = 55d; + + @Schema( + description = + "Edge detection strength to use for line art conversion (1-3). This maps to" + + " ImageMagick's -edge radius.", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + defaultValue = "1", + allowableValues = {"1", "2", "3"}) + private Integer lineArtEdgeLevel = 1; } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java index 623b99260..604e9ba38 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java @@ -86,7 +86,6 @@ import org.apache.pdfbox.text.PDFTextStripper; import org.apache.pdfbox.text.TextPosition; import org.apache.pdfbox.util.DateConverter; import org.apache.pdfbox.util.Matrix; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -144,15 +143,23 @@ public class PdfJsonConversionService { private final PdfJsonFontService fontService; private final Type3FontConversionService type3FontConversionService; private final Type3GlyphExtractor type3GlyphExtractor; + private final stirling.software.common.model.ApplicationProperties applicationProperties; private final Map type3NormalizedFontCache = new ConcurrentHashMap<>(); private final Map> type3GlyphCoverageCache = new ConcurrentHashMap<>(); - @Value("${stirling.pdf.json.font-normalization.enabled:true}") private boolean fontNormalizationEnabled; + private long cacheMaxBytes; + private int cacheMaxPercent; /** Cache for storing PDDocuments for lazy page loading. Key is jobId. */ private final Map documentCache = new ConcurrentHashMap<>(); + private final java.util.LinkedHashMap lruCache = + new java.util.LinkedHashMap<>(16, 0.75f, true); + private final Object cacheLock = new Object(); + private volatile long currentCacheBytes = 0L; + private volatile long cacheBudgetBytes = -1L; + private volatile boolean ghostscriptAvailable; private static final float FLOAT_EPSILON = 0.0001f; @@ -161,7 +168,23 @@ public class PdfJsonConversionService { @PostConstruct private void initializeToolAvailability() { + loadConfigurationFromProperties(); initializeGhostscriptAvailability(); + initializeCacheBudget(); + } + + private void loadConfigurationFromProperties() { + stirling.software.common.model.ApplicationProperties.PdfEditor cfg = + applicationProperties.getPdfEditor(); + if (cfg != null) { + fontNormalizationEnabled = cfg.getFontNormalization().isEnabled(); + cacheMaxBytes = cfg.getCache().getMaxBytes(); + cacheMaxPercent = cfg.getCache().getMaxPercent(); + } else { + fontNormalizationEnabled = false; + cacheMaxBytes = -1; + cacheMaxPercent = 20; + } } private void initializeGhostscriptAvailability() { @@ -202,6 +225,25 @@ public class PdfJsonConversionService { } } + private void initializeCacheBudget() { + long effective = -1L; + if (cacheMaxBytes > 0) { + effective = cacheMaxBytes; + } else if (cacheMaxPercent > 0) { + long maxMem = Runtime.getRuntime().maxMemory(); + effective = Math.max(0L, (maxMem * cacheMaxPercent) / 100); + } + cacheBudgetBytes = effective; + if (cacheBudgetBytes > 0) { + log.info( + "PDF JSON cache budget configured: {} bytes (source: {})", + cacheBudgetBytes, + cacheMaxBytes > 0 ? "max-bytes" : "max-percent"); + } else { + log.info("PDF JSON cache budget: unlimited"); + } + } + public byte[] convertPdfToJson(MultipartFile file) throws IOException { return convertPdfToJson(file, null, false); } @@ -236,7 +278,10 @@ public class PdfJsonConversionService { log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId); } else { jobId = contextJobId; - log.debug("Starting PDF to JSON conversion, jobId from context: {}", jobId); + log.info( + "Starting PDF to JSON conversion, jobId from context: {} (lightweight={})", + jobId, + lightweight); } Consumer progress = @@ -318,9 +363,9 @@ public class PdfJsonConversionService { try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) { int totalPages = document.getNumberOfPages(); - // Only use lazy images for real async jobs where client can access the cache - // Synchronous calls with synthetic jobId should do full extraction - boolean useLazyImages = totalPages > 5 && isRealJobId; + // Always enable lazy mode for real async jobs so cache is available regardless of + // page count. Synchronous calls with synthetic jobId still do full extraction. + boolean useLazyImages = isRealJobId; Map fontCache = new IdentityHashMap<>(); Map imageCache = new IdentityHashMap<>(); log.debug( @@ -403,6 +448,11 @@ public class PdfJsonConversionService { // Only cache for real async jobIds, not synthetic synchronous ones if (useLazyImages && isRealJobId) { + log.info( + "Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})", + jobId, + useLazyImages, + isRealJobId); PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata(); docMetadata.setMetadata(pdfJson.getMetadata()); docMetadata.setXmpMetadata(pdfJson.getXmpMetadata()); @@ -435,16 +485,23 @@ public class PdfJsonConversionService { cachedPdfBytes = Files.readAllBytes(workingPath); } CachedPdfDocument cached = - new CachedPdfDocument( - cachedPdfBytes, docMetadata, fonts, pageFontResources); - documentCache.put(jobId, cached); - log.debug( - "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy images, jobId: {}", - cachedPdfBytes.length, + buildCachedDocument( + jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources); + putCachedDocument(jobId, cached); + log.info( + "Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})", + cached.getPdfSize(), totalPages, fonts.size(), - jobId); + jobId, + cached.isDiskBacked()); scheduleDocumentCleanup(jobId); + } else { + log.warn( + "Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}", + useLazyImages, + isRealJobId, + jobId); } if (lightweight) { @@ -2973,6 +3030,139 @@ public class PdfJsonConversionService { } } + // Cache helpers + private CachedPdfDocument buildCachedDocument( + String jobId, + byte[] pdfBytes, + PdfJsonDocumentMetadata metadata, + Map fonts, + Map> pageFontResources) + throws IOException { + if (pdfBytes == null) { + throw new IllegalArgumentException("pdfBytes must not be null"); + } + long budget = cacheBudgetBytes; + // If single document is larger than budget, spill straight to disk + if (budget > 0 && pdfBytes.length > budget) { + TempFile tempFile = new TempFile(tempFileManager, ".pdfjsoncache"); + Files.write(tempFile.getPath(), pdfBytes); + log.debug( + "Cached PDF spilled to disk ({} bytes exceeds budget {}) for jobId {}", + pdfBytes.length, + budget, + jobId); + return new CachedPdfDocument( + null, tempFile, pdfBytes.length, metadata, fonts, pageFontResources); + } + return new CachedPdfDocument( + pdfBytes, null, pdfBytes.length, metadata, fonts, pageFontResources); + } + + private void putCachedDocument(String jobId, CachedPdfDocument cached) { + synchronized (cacheLock) { + CachedPdfDocument existing = documentCache.put(jobId, cached); + if (existing != null) { + lruCache.remove(jobId); + currentCacheBytes = Math.max(0L, currentCacheBytes - existing.getInMemorySize()); + existing.close(); + } + lruCache.put(jobId, cached); + currentCacheBytes += cached.getInMemorySize(); + enforceCacheBudget(); + } + } + + private CachedPdfDocument getCachedDocument(String jobId) { + synchronized (cacheLock) { + CachedPdfDocument cached = documentCache.get(jobId); + if (cached != null) { + lruCache.remove(jobId); + lruCache.put(jobId, cached); + } + return cached; + } + } + + private void enforceCacheBudget() { + if (cacheBudgetBytes <= 0) { + return; + } + // Must be called under cacheLock + java.util.Iterator> it = + lruCache.entrySet().iterator(); + while (currentCacheBytes > cacheBudgetBytes && it.hasNext()) { + java.util.Map.Entry entry = it.next(); + it.remove(); + CachedPdfDocument removed = entry.getValue(); + documentCache.remove(entry.getKey(), removed); + currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize()); + removed.close(); + log.warn( + "Evicted cached PDF for jobId {} to enforce cache budget (budget={} bytes, current={} bytes)", + entry.getKey(), + cacheBudgetBytes, + currentCacheBytes); + } + if (currentCacheBytes > cacheBudgetBytes && !lruCache.isEmpty()) { + // Spill the most recently used large entry to disk + String key = + lruCache.entrySet().stream() + .reduce((first, second) -> second) + .map(java.util.Map.Entry::getKey) + .orElse(null); + if (key != null) { + CachedPdfDocument doc = lruCache.get(key); + if (doc != null && doc.getInMemorySize() > 0) { + try { + CachedPdfDocument diskDoc = + buildCachedDocument( + key, + doc.getPdfBytes(), + doc.getMetadata(), + doc.getFonts(), + doc.getPageFontResources()); + lruCache.put(key, diskDoc); + documentCache.put(key, diskDoc); + currentCacheBytes = + Math.max(0L, currentCacheBytes - doc.getInMemorySize()) + + diskDoc.getInMemorySize(); + doc.close(); + log.debug("Spilled cached PDF for jobId {} to disk to satisfy budget", key); + } catch (IOException ex) { + log.warn( + "Failed to spill cached PDF for jobId {} to disk: {}", + key, + ex.getMessage()); + } + } + } + } + } + + private void removeCachedDocument(String jobId) { + log.warn( + "removeCachedDocument called for jobId: {} [CALLER: {}]", + jobId, + Thread.currentThread().getStackTrace()[2].toString()); + CachedPdfDocument removed = null; + synchronized (cacheLock) { + removed = documentCache.remove(jobId); + if (removed != null) { + lruCache.remove(jobId); + currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize()); + log.warn( + "Removed cached document for jobId: {} (size={} bytes)", + jobId, + removed.getInMemorySize()); + } else { + log.warn("Attempted to remove jobId: {} but it was not in cache", jobId); + } + } + if (removed != null) { + removed.close(); + } + } + private void applyTextState(PDPageContentStream contentStream, PdfJsonTextElement element) throws IOException { if (element.getCharacterSpacing() != null) { @@ -5311,6 +5501,8 @@ public class PdfJsonConversionService { */ private static class CachedPdfDocument { private final byte[] pdfBytes; + private final TempFile pdfTempFile; + private final long pdfSize; private final PdfJsonDocumentMetadata metadata; private final Map fonts; // Font map with UIDs for consistency private final Map> pageFontResources; // Page font resources @@ -5318,10 +5510,14 @@ public class PdfJsonConversionService { public CachedPdfDocument( byte[] pdfBytes, + TempFile pdfTempFile, + long pdfSize, PdfJsonDocumentMetadata metadata, Map fonts, Map> pageFontResources) { this.pdfBytes = pdfBytes; + this.pdfTempFile = pdfTempFile; + this.pdfSize = pdfSize; this.metadata = metadata; // Create defensive copies to prevent mutation of shared maps this.fonts = @@ -5336,8 +5532,14 @@ public class PdfJsonConversionService { } // Getters return defensive copies to prevent external mutation - public byte[] getPdfBytes() { - return pdfBytes; + public byte[] getPdfBytes() throws IOException { + if (pdfBytes != null) { + return pdfBytes; + } + if (pdfTempFile != null) { + return Files.readAllBytes(pdfTempFile.getPath()); + } + throw new IOException("Cached PDF backing missing"); } public PdfJsonDocumentMetadata getMetadata() { @@ -5352,6 +5554,18 @@ public class PdfJsonConversionService { return new java.util.concurrent.ConcurrentHashMap<>(pageFontResources); } + public long getPdfSize() { + return pdfSize; + } + + public long getInMemorySize() { + return pdfBytes != null ? pdfBytes.length : 0L; + } + + public boolean isDiskBacked() { + return pdfBytes == null && pdfTempFile != null; + } + public long getTimestamp() { return timestamp; } @@ -5363,7 +5577,19 @@ public class PdfJsonConversionService { public CachedPdfDocument withUpdatedFonts( byte[] nextBytes, Map nextFonts) { Map fontsToUse = nextFonts != null ? nextFonts : this.fonts; - return new CachedPdfDocument(nextBytes, metadata, fontsToUse, pageFontResources); + return new CachedPdfDocument( + nextBytes, + null, + nextBytes != null ? nextBytes.length : 0, + metadata, + fontsToUse, + pageFontResources); + } + + public void close() { + if (pdfTempFile != null) { + pdfTempFile.close(); + } } } @@ -5444,14 +5670,15 @@ public class PdfJsonConversionService { // Cache PDF bytes, metadata, and fonts for lazy page loading if (jobId != null) { CachedPdfDocument cached = - new CachedPdfDocument(pdfBytes, docMetadata, fonts, pageFontResources); - documentCache.put(jobId, cached); + buildCachedDocument(jobId, pdfBytes, docMetadata, fonts, pageFontResources); + putCachedDocument(jobId, cached); log.debug( - "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {}", - pdfBytes.length, + "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {} (diskBacked={})", + cached.getPdfSize(), totalPages, fonts.size(), - jobId); + jobId, + cached.isDiskBacked()); // Schedule cleanup after 30 minutes scheduleDocumentCleanup(jobId); @@ -5466,9 +5693,10 @@ public class PdfJsonConversionService { /** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */ public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException { - CachedPdfDocument cached = documentCache.get(jobId); + CachedPdfDocument cached = getCachedDocument(jobId); if (cached == null) { - throw new IllegalArgumentException("No cached document found for jobId: " + jobId); + throw new stirling.software.SPDF.exception.CacheUnavailableException( + "No cached document found for jobId: " + jobId); } int pageIndex = pageNumber - 1; @@ -5480,8 +5708,8 @@ public class PdfJsonConversionService { } log.debug( - "Loading PDF from bytes ({} bytes) to extract page {} (jobId: {})", - cached.getPdfBytes().length, + "Loading PDF from {} to extract page {} (jobId: {})", + cached.isDiskBacked() ? "disk cache" : "memory cache", pageNumber, jobId); @@ -5627,10 +5855,21 @@ public class PdfJsonConversionService { if (jobId == null || jobId.isBlank()) { throw new IllegalArgumentException("jobId is required for incremental export"); } - CachedPdfDocument cached = documentCache.get(jobId); + log.info("Looking up cache for jobId: {}", jobId); + CachedPdfDocument cached = getCachedDocument(jobId); if (cached == null) { - throw new IllegalArgumentException("No cached document available for jobId: " + jobId); + log.error( + "Cache not found for jobId: {}. Available cache keys: {}", + jobId, + documentCache.keySet()); + throw new stirling.software.SPDF.exception.CacheUnavailableException( + "No cached document available for jobId: " + jobId); } + log.info( + "Found cached document for jobId: {} (size={}, diskBacked={})", + jobId, + cached.getPdfSize(), + cached.isDiskBacked()); if (updates == null || updates.getPages() == null || updates.getPages().isEmpty()) { log.debug( "Incremental export requested with no page updates; returning cached PDF for jobId {}", @@ -5709,7 +5948,14 @@ public class PdfJsonConversionService { document.save(baos); byte[] updatedBytes = baos.toByteArray(); - documentCache.put(jobId, cached.withUpdatedFonts(updatedBytes, mergedFonts)); + CachedPdfDocument updated = + buildCachedDocument( + jobId, + updatedBytes, + cached.getMetadata(), + mergedFonts, + cached.getPageFontResources()); + putCachedDocument(jobId, updated); // Clear Type3 cache entries for this incremental update clearType3CacheEntriesForJob(updateJobId); @@ -5724,11 +5970,13 @@ public class PdfJsonConversionService { /** Clears a cached document. */ public void clearCachedDocument(String jobId) { - CachedPdfDocument cached = documentCache.remove(jobId); + CachedPdfDocument cached = getCachedDocument(jobId); + removeCachedDocument(jobId); if (cached != null) { log.debug( - "Removed cached PDF bytes ({} bytes) for jobId: {}", - cached.getPdfBytes().length, + "Removed cached PDF ({} bytes, diskBacked={}) for jobId: {}", + cached.getPdfSize(), + cached.isDiskBacked(), jobId); } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java index 107abbe2b..e4baee055 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java @@ -312,12 +312,29 @@ public class PdfJsonFallbackFontService { "ttf"))); private final ResourceLoader resourceLoader; + private final stirling.software.common.model.ApplicationProperties applicationProperties; @Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}") + private String legacyFallbackFontLocation; + private String fallbackFontLocation; private final Map fallbackFontCache = new ConcurrentHashMap<>(); + @jakarta.annotation.PostConstruct + private void loadConfig() { + String configured = null; + if (applicationProperties.getPdfEditor() != null) { + configured = applicationProperties.getPdfEditor().getFallbackFont(); + } + if (configured != null && !configured.isBlank()) { + fallbackFontLocation = configured; + } else { + fallbackFontLocation = legacyFallbackFontLocation; + } + log.info("Using fallback font location: {}", fallbackFontLocation); + } + public PdfJsonFont buildFallbackFontModel() throws IOException { return buildFallbackFontModel(FALLBACK_FONT_ID); } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java index 1a9f7f698..6a56bad09 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java @@ -5,7 +5,6 @@ import java.nio.file.Files; import java.util.Base64; import java.util.Locale; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import jakarta.annotation.PostConstruct; @@ -25,22 +24,16 @@ import stirling.software.common.util.TempFileManager; public class PdfJsonFontService { private final TempFileManager tempFileManager; + private final stirling.software.common.model.ApplicationProperties applicationProperties; - @Getter - @Value("${stirling.pdf.json.cff-converter.enabled:true}") - private boolean cffConversionEnabled; + @Getter private boolean cffConversionEnabled; - @Getter - @Value("${stirling.pdf.json.cff-converter.method:python}") - private String cffConverterMethod; + @Getter private String cffConverterMethod; - @Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}") private String pythonCommand; - @Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}") private String pythonScript; - @Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}") private String fontforgeCommand; private volatile boolean pythonCffConverterAvailable; @@ -48,6 +41,7 @@ public class PdfJsonFontService { @PostConstruct private void initialiseCffConverterAvailability() { + loadConfiguration(); if (!cffConversionEnabled) { log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration"); pythonCffConverterAvailable = false; @@ -77,6 +71,22 @@ public class PdfJsonFontService { log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod); } + private void loadConfiguration() { + if (applicationProperties.getPdfEditor() != null + && applicationProperties.getPdfEditor().getCffConverter() != null) { + var cfg = applicationProperties.getPdfEditor().getCffConverter(); + this.cffConversionEnabled = cfg.isEnabled(); + this.cffConverterMethod = cfg.getMethod(); + this.pythonCommand = cfg.getPythonCommand(); + this.pythonScript = cfg.getPythonScript(); + this.fontforgeCommand = cfg.getFontforgeCommand(); + } else { + // Use defaults when config is not available + this.cffConversionEnabled = false; + log.warn("[FONT-DEBUG] PdfEditor configuration not available, CFF conversion disabled"); + } + } + public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) { if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) { log.warn( diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java index 4385e5725..b4e8f9d95 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java @@ -2,7 +2,6 @@ package stirling.software.SPDF.service.pdfjson.type3; import java.io.IOException; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @@ -23,8 +22,8 @@ import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayl public class Type3LibraryStrategy implements Type3ConversionStrategy { private final Type3FontLibrary fontLibrary; + private final stirling.software.common.model.ApplicationProperties applicationProperties; - @Value("${stirling.pdf.json.type3.library.enabled:true}") private boolean enabled; @Override @@ -42,6 +41,19 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy { return enabled && fontLibrary != null && fontLibrary.isLoaded(); } + @jakarta.annotation.PostConstruct + private void loadConfiguration() { + if (applicationProperties.getPdfEditor() != null + && applicationProperties.getPdfEditor().getType3() != null + && applicationProperties.getPdfEditor().getType3().getLibrary() != null) { + var cfg = applicationProperties.getPdfEditor().getType3().getLibrary(); + this.enabled = cfg.isEnabled(); + } else { + this.enabled = false; + log.warn("PdfEditor Type3 library configuration not available, disabled"); + } + } + @Override public PdfJsonFontConversionCandidate convert( Type3ConversionRequest request, Type3GlyphContext context) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java index 32a6abec2..f00c729a2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java @@ -14,7 +14,6 @@ import java.util.stream.Collectors; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.font.PDType3Font; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Component; @@ -34,8 +33,8 @@ public class Type3FontLibrary { private final ObjectMapper objectMapper; private final ResourceLoader resourceLoader; + private final stirling.software.common.model.ApplicationProperties applicationProperties; - @Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}") private String indexLocation; private final Map signatureIndex = new ConcurrentHashMap<>(); @@ -44,6 +43,17 @@ public class Type3FontLibrary { @jakarta.annotation.PostConstruct void initialise() { + if (applicationProperties.getPdfEditor() != null + && applicationProperties.getPdfEditor().getType3() != null + && applicationProperties.getPdfEditor().getType3().getLibrary() != null) { + this.indexLocation = + applicationProperties.getPdfEditor().getType3().getLibrary().getIndex(); + } else { + log.warn( + "[TYPE3] PdfEditor Type3 library configuration not available; Type3 library disabled"); + entries = List.of(); + return; + } Resource resource = resourceLoader.getResource(indexLocation); if (!resource.exists()) { log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 5a50ef903..dffebe1bb 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -58,6 +58,8 @@ security: idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair + # IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId} + # Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration. jwt: # This feature is currently under development and not yet fully supported. Do not use in production. persistence: true # Set to 'true' to enable JWT key store enableKeyRotation: true # Set to 'true' to enable key pair rotation @@ -126,13 +128,15 @@ system: customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored. enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch + enableDesktopInstallSlide: true # Set to 'false' to hide the desktop app installation slide in the onboarding flow enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) maxDPI: 500 # Maximum allowed DPI for PDF to image conversion - corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. - frontendUrl: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL. + corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173' + backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. + frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. serverCertificate: enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option organizationName: Stirling-PDF # Organization name for generated certificates @@ -178,23 +182,6 @@ system: databaseBackup: cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight -stirling: - pdf: - fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font - json: - font-normalization: - enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts. - cff-converter: - enabled: true # Wrap CFF/Type1C fonts as OpenType-CFF for browser compatibility - method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts) - python-command: /opt/venv/bin/python3 # Python interpreter path - python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script - fontforge-command: fontforge # Override if FontForge is installed under a different name/path - type3: - library: - enabled: true # Match common Type3 fonts against the built-in library of converted programs - index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:) - ui: appNameNavbar: '' # name displayed on the navigation bar logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo) @@ -223,6 +210,7 @@ processExecutor: weasyPrintSessionLimit: 16 installAppSessionLimit: 1 calibreSessionLimit: 1 + imageMagickSessionLimit: 4 ghostscriptSessionLimit: 8 ocrMyPdfSessionLimit: 2 timeoutMinutes: # Process executor timeout in minutes @@ -232,7 +220,26 @@ processExecutor: weasyPrinttimeoutMinutes: 30 installApptimeoutMinutes: 60 calibretimeoutMinutes: 30 + imageMagickTimeoutMinutes: 30 tesseractTimeoutMinutes: 30 qpdfTimeoutMinutes: 30 ghostscriptTimeoutMinutes: 30 ocrMyPdfTimeoutMinutes: 30 + +pdfEditor: + fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font + cache: + max-bytes: -1 # Max in-memory cache size in bytes; -1 disables byte cap + max-percent: 20 # Max in-memory cache as % of JVM max; used when max-bytes <= 0 + font-normalization: + enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts. + cff-converter: + enabled: true # Wrap CFF/Type1CFF fonts as OpenType-CFF for browser compatibility + method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts) + python-command: /opt/venv/bin/python3 # Python interpreter path + python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script + fontforge-command: fontforge # Override if FontForge is installed under a different name/path + type3: + library: + enabled: true # Match common Type3 fonts against the built-in library of converted programs + index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:) diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java index c9f02cd28..5a5eff1f1 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java @@ -1,9 +1,10 @@ package stirling.software.SPDF.controller.api.security; 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 static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; import java.io.ByteArrayOutputStream; import java.io.InputStream; @@ -107,7 +108,8 @@ class CertSignControllerTest { derCertBytes = baos.toByteArray(); } - when(pdfDocumentFactory.load(any(MultipartFile.class))) + lenient() + .when(pdfDocumentFactory.load(any(MultipartFile.class))) .thenAnswer( invocation -> { MultipartFile file = invocation.getArgument(0); @@ -167,6 +169,31 @@ class CertSignControllerTest { assertTrue(response.getBody().length > 0); } + @Test + void testSignPdfWithMissingPkcs12FileThrowsError() { + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + + SignPDFWithCertRequest request = new SignPDFWithCertRequest(); + request.setFileInput(pdfFile); + request.setCertType("PFX"); + request.setPassword("password"); + request.setShowSignature(false); + request.setReason("test"); + request.setLocation("test"); + request.setName("tester"); + request.setPageNumber(1); + request.setShowLogo(false); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> certSignController.signPDFWithCert(request)); + + assertTrue(exception.getMessage().contains("PKCS12 keystore")); + } + @Test void testSignPdfWithJks() throws Exception { MockMultipartFile pdfFile = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 88fddb7ea..e13d807da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -94,6 +94,22 @@ public class ProprietaryUIDataController { this.auditRepository = auditRepository; } + /** + * Get the backend base URL for SAML/OAuth redirects. Uses system.backendUrl from config if set, + * otherwise defaults to http://localhost:8080 + */ + private String getBackendBaseUrl() { + String backendUrl = applicationProperties.getSystem().getBackendUrl(); + + // If backendUrl is configured, use it + if (backendUrl != null && !backendUrl.trim().isEmpty()) { + return backendUrl.trim(); + } + + // For development, default to localhost:8080 (backend port) + return "http://localhost:8080"; + } + @GetMapping("/audit-dashboard") @PreAuthorize("hasRole('ADMIN')") @EnterpriseEndpoint @@ -185,14 +201,17 @@ public class ProprietaryUIDataController { } SAML2 saml2 = securityProps.getSaml2(); - if (securityProps.isSaml2Active() - && applicationProperties.getSystem().getEnableAlphaFunctionality() - && applicationProperties.getPremium().isEnabled()) { + if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) { String samlIdp = saml2.getProvider(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); + // For SAML, we need to use the backend URL directly, not a relative path + // This ensures Spring Security generates the correct ACS URL + String backendUrl = getBackendBaseUrl(); + String fullSamlPath = backendUrl + saml2AuthenticationPath; + if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) { - providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)"); + providerList.put(fullSamlPath, samlIdp + " (SAML 2)"); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java index a073c2137..53b3e1f38 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java @@ -22,7 +22,6 @@ import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.annotations.api.UserApi; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.proprietary.model.api.signature.SavedSignatureRequest; import stirling.software.proprietary.model.api.signature.SavedSignatureResponse; @@ -34,7 +33,6 @@ import stirling.software.proprietary.service.SignatureService; * authentication and enforces per-user storage limits. All endpoints require authentication * via @PreAuthorize("isAuthenticated()"). */ -@UserApi @Slf4j @RestController @RequestMapping("/api/v1/proprietary/signatures") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 16272b37f..1f55dd25f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -198,7 +198,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { private SamlClient getSamlClient( String registrationId, SAML2 samlConf, List certificates) throws SamlException { - String serverUrl = appConfig.getBaseUrl() + ":" + appConfig.getServerPort(); + String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort(); String relyingPartyIdentifier = serverUrl + "/saml2/service-provider-metadata/" + registrationId; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java index d035dbc58..17857fc85 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java @@ -120,9 +120,7 @@ public class AccountWebController { SAML2 saml2 = securityProps.getSaml2(); - if (securityProps.isSaml2Active() - && applicationProperties.getSystem().getEnableAlphaFunctionality() - && applicationProperties.getPremium().isEnabled()) { + if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) { String samlIdp = saml2.getProvider(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 257d243ea..fa936211d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -334,7 +334,8 @@ public class SecurityConfiguration { securityProperties.getSaml2(), userService, jwtService, - licenseSettingsService)) + licenseSettingsService, + applicationProperties)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) .authenticationRequestResolver( @@ -343,7 +344,8 @@ public class SecurityConfiguration { log.error("Error configuring SAML 2 login", e); throw new RuntimeException(e); } - }); + }) + .saml2Metadata(metadata -> {}); } } else { log.debug("Login is not enabled."); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index de6428554..c3e11c3ab 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -244,10 +244,13 @@ public class AuthController { userMap.put("username", user.getUsername()); userMap.put("role", user.getRolesAsString()); userMap.put("enabled", user.isEnabled()); + userMap.put( + "authenticationType", + user.getAuthenticationType()); // Expose authentication type for SSO detection // Add metadata for OAuth compatibility Map appMetadata = new HashMap<>(); - appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider + appMetadata.put("provider", user.getAuthenticationType()); userMap.put("app_metadata", appMetadata); return userMap; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index e8bce579a..3c63f1bf4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -51,6 +51,7 @@ public class CustomSaml2AuthenticationSuccessHandler private final JwtServiceInterface jwtService; private final stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService; + private final ApplicationProperties applicationProperties; @Override @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) @@ -77,8 +78,8 @@ public class CustomSaml2AuthenticationSuccessHandler log.warn( "SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)", username); - response.sendRedirect( - request.getContextPath() + "/logout?saml2RequiresLicense=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); return; } } else if (!licenseSettingsService.isSamlEligible(null)) { @@ -86,8 +87,8 @@ public class CustomSaml2AuthenticationSuccessHandler log.warn( "SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)", username); - response.sendRedirect( - request.getContextPath() + "/logout?saml2RequiresLicense=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); return; } @@ -144,20 +145,28 @@ public class CustomSaml2AuthenticationSuccessHandler log.debug( "User {} exists with password but is not SSO user, redirecting to logout", username); - response.sendRedirect( - contextPath + "/logout?oAuth2AuthenticationErrorWeb=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true"); return; } try { - if (!userExists || saml2Properties.getBlockRegistration()) { - log.debug("Registration blocked for new user: {}", username); - response.sendRedirect( - contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); + // Block new users only if: blockRegistration is true OR autoCreateUser is false + if (!userExists + && (saml2Properties.getBlockRegistration() + || !saml2Properties.getAutoCreateUser())) { + log.debug( + "Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})", + username, + saml2Properties.getBlockRegistration(), + saml2Properties.getAutoCreateUser()); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser"); return; } if (!userExists && licenseSettingsService.wouldExceedLimit(1)) { - response.sendRedirect(contextPath + "/logout?maxUsersReached=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?maxUsersReached=true"); return; } @@ -222,16 +231,30 @@ public class CustomSaml2AuthenticationSuccessHandler String contextPath, String jwt) { String redirectPath = resolveRedirectPath(request, contextPath); - String origin = - resolveForwardedOrigin(request) - .orElseGet( - () -> - resolveOriginFromReferer(request) - .orElseGet(() -> buildOriginFromRequest(request))); + String origin = resolveOrigin(request); clearRedirectCookie(response); return origin + redirectPath + "#access_token=" + jwt; } + /** + * Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config, + * then falls back to detecting from request headers. + */ + private String resolveOrigin(HttpServletRequest request) { + // First check if frontendUrl is configured + String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl(); + if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) { + return configuredFrontendUrl.trim(); + } + + // Fall back to auto-detection from request headers + return resolveForwardedOrigin(request) + .orElseGet( + () -> + resolveOriginFromReferer(request) + .orElseGet(() -> buildOriginFromRequest(request))); + } + private String resolveRedirectPath(HttpServletRequest request, String contextPath) { return extractRedirectPathFromCookie(request) .filter(path -> path.startsWith("/")) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java index 9d21f88a3..6ccffa1da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java @@ -41,25 +41,92 @@ public class Saml2Configuration { @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception { SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); - X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getIdpCert()); + + log.info( + "Initializing SAML2 configuration with registration ID: {}", + samlConf.getRegistrationId()); + + // Load IdP certificate + X509Certificate idpCert; + try { + Resource idpCertResource = samlConf.getIdpCert(); + log.info("Loading IdP certificate from: {}", idpCertResource.getDescription()); + if (!idpCertResource.exists()) { + log.error( + "SAML2 IdP certificate not found at: {}", idpCertResource.getDescription()); + throw new IllegalStateException( + "SAML2 IdP certificate file does not exist: " + + idpCertResource.getDescription()); + } + idpCert = CertificateUtils.readCertificate(idpCertResource); + log.info( + "Successfully loaded IdP certificate. Subject: {}", + idpCert.getSubjectX500Principal().getName()); + } catch (Exception e) { + log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e); + throw new IllegalStateException("Failed to load SAML2 IdP certificate", e); + } + Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert); + + // Load SP private key and certificate Resource privateKeyResource = samlConf.getPrivateKey(); Resource certificateResource = samlConf.getSpCert(); - Saml2X509Credential signingCredential = - new Saml2X509Credential( - CertificateUtils.readPrivateKey(privateKeyResource), - CertificateUtils.readCertificate(certificateResource), - Saml2X509CredentialType.SIGNING); + + log.info("Loading SP private key from: {}", privateKeyResource.getDescription()); + if (!privateKeyResource.exists()) { + log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription()); + throw new IllegalStateException( + "SAML2 SP private key file does not exist: " + + privateKeyResource.getDescription()); + } + + log.info("Loading SP certificate from: {}", certificateResource.getDescription()); + if (!certificateResource.exists()) { + log.error( + "SAML2 SP certificate not found at: {}", certificateResource.getDescription()); + throw new IllegalStateException( + "SAML2 SP certificate file does not exist: " + + certificateResource.getDescription()); + } + + Saml2X509Credential signingCredential; + try { + signingCredential = + new Saml2X509Credential( + CertificateUtils.readPrivateKey(privateKeyResource), + CertificateUtils.readCertificate(certificateResource), + Saml2X509CredentialType.SIGNING); + log.info("Successfully loaded SP credentials"); + } catch (Exception e) { + log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e); + throw new IllegalStateException("Failed to load SAML2 SP credentials", e); + } + + // Get backend URL from configuration (for SAML endpoints) + String backendUrl = applicationProperties.getSystem().getBackendUrl(); + if (backendUrl == null || backendUrl.isBlank()) { + backendUrl = "{baseUrl}"; // Fallback to Spring's auto-resolution + log.warn( + "system.backendUrl not configured - SAML metadata will use request-based URLs. Set system.backendUrl for production use."); + } else { + log.info("Using configured backend URL for SAML: {}", backendUrl); + } + + String entityId = + backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId(); + String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}"; + String sloResponseLocation = backendUrl + "/login"; + RelyingPartyRegistration rp = RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) .signingX509Credentials(c -> c.add(signingCredential)) - .entityId(samlConf.getIdpIssuer()) + .entityId(entityId) .singleLogoutServiceBinding(Saml2MessageBinding.POST) .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) - .singleLogoutServiceResponseLocation("http://localhost:8080/login") + .singleLogoutServiceResponseLocation(sloResponseLocation) .assertionConsumerServiceBinding(Saml2MessageBinding.POST) - .assertionConsumerServiceLocation( - "{baseUrl}/login/saml2/sso/{registrationId}") + .assertionConsumerServiceLocation(acsLocation) .authnRequestsSigned(true) .assertingPartyMetadata( metadata -> @@ -75,9 +142,14 @@ public class Saml2Configuration { .singleLogoutServiceLocation( samlConf.getIdpSingleLogoutUrl()) .singleLogoutServiceResponseLocation( - "http://localhost:8080/login") + sloResponseLocation) .wantAuthnRequestsSigned(true)) .build(); + + log.info( + "SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}", + samlConf.getRegistrationId(), + samlConf.getIdpIssuer()); return new InMemoryRelyingPartyRegistrationRepository(rp); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java new file mode 100644 index 000000000..8ca6a83e7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java @@ -0,0 +1,81 @@ +package stirling.software.proprietary.service; + +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.Locale; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.LineArtConversionService; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; + +@Slf4j +@Service +public class ImageMagickLineArtConversionService implements LineArtConversionService { + + @Override + public PDImageXObject convertImageToLineArt( + PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel) + throws IOException { + + Path inputImage = Files.createTempFile("lineart_image_input_", ".png"); + Path outputImage = Files.createTempFile("lineart_image_output_", ".tiff"); + + try { + ImageIO.write(originalImage.getImage(), "png", inputImage.toFile()); + + List command = new ArrayList<>(); + command.add("magick"); + command.add(inputImage.toString()); + command.add("-colorspace"); + command.add("Gray"); + + // Edge-aware line art conversion using ImageMagick's built-in operators. + // -edge/-negate/-normalize are standard convert options (IM v6+/v7) that + // accentuate outlines before thresholding to a bilevel image. + command.add("-edge"); + command.add(String.valueOf(edgeLevel)); + command.add("-negate"); + command.add("-normalize"); + + command.add("-type"); + command.add("Bilevel"); + command.add("-threshold"); + command.add(String.format(Locale.ROOT, "%.1f%%", threshold)); + command.add("-compress"); + command.add("Group4"); + command.add(outputImage.toString()); + + ProcessExecutorResult result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.IMAGEMAGICK) + .runCommandWithOutputHandling(command); + + if (result.getRc() != 0) { + log.warn( + "ImageMagick line art conversion failed with return code: {}", + result.getRc()); + throw new IOException("ImageMagick line art conversion failed"); + } + + byte[] convertedBytes = Files.readAllBytes(outputImage); + return PDImageXObject.createFromByteArray( + doc, convertedBytes, originalImage.getCOSObject().toString()); + } catch (Exception e) { + log.warn("ImageMagick line art conversion failed", e); + throw new IOException("ImageMagick line art conversion failed", e); + } finally { + Files.deleteIfExists(inputImage); + Files.deleteIfExists(outputImage); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index aa794e699..54660a1cc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -177,6 +177,13 @@ public class UserLicenseSettingsService { */ @Transactional public void grandfatherExistingOAuthUsers() { + // Only grandfather users if this is a V1→V2 upgrade, not a fresh V2 install + Boolean isNewServer = applicationProperties.getAutomaticallyGenerated().getIsNewServer(); + if (Boolean.TRUE.equals(isNewServer)) { + log.info("Fresh V2 installation detected - skipping OAuth user grandfathering"); + return; + } + UserLicenseSettings settings = getOrCreateSettings(); // Check if we've already run this migration @@ -348,30 +355,22 @@ public class UserLicenseSettingsService { String username = (user != null) ? user.getUsername() : ""; log.info("OAuth eligibility check for user: {}", username); - // Grandfathered users always have OAuth access - if (user != null && user.isOauthGrandfathered()) { - log.debug("User {} is grandfathered for OAuth", user.getUsername()); + // Check license first - if paying, they're eligible (no need to check grandfathering) + boolean hasPaid = hasPaidLicense(); + if (hasPaid) { + log.debug("User {} eligible for OAuth via paid license", username); return true; } - // todo: remove - if (user != null) { - log.info( - "User {} is NOT grandfathered (isOauthGrandfathered={})", - username, - user.isOauthGrandfathered()); - } else { - log.info("New user attempting OAuth login - checking license requirement"); + // No license - check if grandfathered (fallback for V1 users) + if (user != null && user.isOauthGrandfathered()) { + log.info("User {} eligible for OAuth via grandfathering (no paid license)", username); + return true; } - // Users can use OAuth with SERVER or ENTERPRISE license - boolean hasPaid = hasPaidLicense(); - log.info( - "OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}", - hasPaid, - username, - hasPaid); - return hasPaid; + // Not grandfathered and no license + log.info("User {} NOT eligible for OAuth: no paid license and not grandfathered", username); + return false; } /** @@ -391,29 +390,26 @@ public class UserLicenseSettingsService { String username = (user != null) ? user.getUsername() : ""; log.info("SAML2 eligibility check for user: {}", username); - // Grandfathered users always have SAML access - if (user != null && user.isOauthGrandfathered()) { - log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username); + // Check license first - if paying, they're eligible (no need to check grandfathering) + boolean hasEnterprise = hasEnterpriseLicense(); + if (hasEnterprise) { + log.debug("User {} eligible for SAML2 via ENTERPRISE license", username); return true; } - if (user != null) { + // No license - check if grandfathered (fallback for V1 users) + if (user != null && user.isOauthGrandfathered()) { log.info( - "User {} is NOT grandfathered (isOauthGrandfathered={})", - username, - user.isOauthGrandfathered()); - } else { - log.info("New user attempting SAML2 login - checking license requirement"); + "User {} eligible for SAML2 via grandfathering (no ENTERPRISE license)", + username); + return true; } - // Users can use SAML only with ENTERPRISE license - boolean hasEnterprise = hasEnterpriseLicense(); + // Not grandfathered and no license log.info( - "SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}", - hasEnterprise, - username, - hasEnterprise); - return hasEnterprise; + "User {} NOT eligible for SAML2: no ENTERPRISE license and not grandfathered", + username); + return false; } /** diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java index 7f9445ad7..3f0214573 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java @@ -33,6 +33,7 @@ class UserLicenseSettingsServiceTest { @Mock private UserService userService; @Mock private ApplicationProperties applicationProperties; @Mock private ApplicationProperties.Premium premium; + @Mock private ApplicationProperties.AutomaticallyGenerated automaticallyGenerated; @Mock private LicenseKeyChecker licenseKeyChecker; @Mock private ObjectProvider licenseKeyCheckerProvider; @@ -49,6 +50,9 @@ class UserLicenseSettingsServiceTest { mockSettings.setGrandfatheredUserSignature("80:test-signature"); when(applicationProperties.getPremium()).thenReturn(premium); + when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated); + when(automaticallyGenerated.getIsNewServer()) + .thenReturn(false); // Default: not a new server when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings)); when(userService.getTotalUsersCount()).thenReturn(80L); when(settingsRepository.save(any(UserLicenseSettings.class))) diff --git a/build.gradle b/build.gradle index 64908ee4a..17bcdb878 100644 --- a/build.gradle +++ b/build.gradle @@ -12,6 +12,8 @@ plugins { } import com.github.jk1.license.render.* +import groovy.json.JsonOutput +import groovy.json.JsonSlurper ext { springBootVersion = "3.5.6" @@ -57,7 +59,7 @@ repositories { allprojects { group = 'stirling.software' - version = '2.1.2' + version = '2.1.4' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' @@ -65,6 +67,51 @@ allprojects { } } +def writeIfChanged(File targetFile, String newContent) { + if (targetFile.getText('UTF-8') != newContent) { + targetFile.write(newContent, 'UTF-8') + } +} + +def updateTauriConfigVersion(String version) { + File tauriConfig = file('frontend/src-tauri/tauri.conf.json') + def parsed = new JsonSlurper().parse(tauriConfig) + parsed.version = version + + def formatted = JsonOutput.prettyPrint(JsonOutput.toJson(parsed)) + System.lineSeparator() + writeIfChanged(tauriConfig, formatted) +} + +def updateSimulationVersion(File fileToUpdate, String version) { + def content = fileToUpdate.getText('UTF-8') + def matcher = content =~ /(appVersion:\s*')([^']*)(')/ + + if (!matcher.find()) { + throw new GradleException("Could not locate appVersion in ${fileToUpdate} for synchronization") + } + + def updatedContent = matcher.replaceFirst("${matcher.group(1)}${version}${matcher.group(3)}") + writeIfChanged(fileToUpdate, updatedContent) +} + +tasks.register('syncAppVersion') { + group = 'versioning' + description = 'Synchronizes app version across desktop and simulation configs.' + + doLast { + def appVersion = project.version.toString() + println "Synchronizing application version to ${appVersion}" + updateTauriConfigVersion(appVersion) + + [ + 'frontend/src/core/testing/serverExperienceSimulations.ts', + 'frontend/src/proprietary/testing/serverExperienceSimulations.ts' + ].each { path -> + updateSimulationVersion(file(path), appVersion) + } + } +} + tasks.register('writeVersion', WriteProperties) { destinationFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties') println "Writing version.properties to ${destinationFile.get().asFile.path}" @@ -314,7 +361,7 @@ tasks.named('bootRun') { tasks.named('build') { group = 'build' description = 'Delegates to :stirling-pdf:bootJar' - dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper' + dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper', 'syncAppVersion' doFirst { println "Delegating to :stirling-pdf:bootJar" diff --git a/docker/Dockerfile.unified b/docker/Dockerfile.unified index 0ba7cfb3c..2968f569c 100644 --- a/docker/Dockerfile.unified +++ b/docker/Dockerfile.unified @@ -105,6 +105,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a gcompat \ libc6-compat \ libreoffice \ + imagemagick \ # pdftohtml poppler-utils \ # OCR MY PDF diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index f2421fa94..b946e1e61 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -81,6 +81,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a libc6-compat \ libreoffice \ ghostscript \ + imagemagick \ fontforge \ # pdftohtml poppler-utils \ diff --git a/docker/backend/Dockerfile.fat b/docker/backend/Dockerfile.fat index c54a162da..78d395d9d 100644 --- a/docker/backend/Dockerfile.fat +++ b/docker/backend/Dockerfile.fat @@ -74,6 +74,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a libc6-compat \ libreoffice \ ghostscript \ + imagemagick \ fontforge \ # pdftohtml poppler-utils \ diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index a38aee9b4..6b189f310 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -99,6 +99,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a libc6-compat \ libreoffice \ ghostscript \ + imagemagick \ fontforge \ # pdftohtml poppler-utils \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 67e648aee..462daa901 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -101,6 +101,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a libc6-compat \ libreoffice \ ghostscript \ + imagemagick \ fontforge \ # pdftohtml poppler-utils \ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8d90d9658..f788474bb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -105,6 +105,7 @@ "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", + "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" } @@ -11093,6 +11094,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -14503,6 +14517,25 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-static-copy": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz", + "integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "p-map": "^7.0.3", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, "node_modules/vite-tsconfig-paths": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5489f6b46..914b7bb1f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -152,6 +152,7 @@ "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", + "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" }, diff --git a/frontend/public/locales/de-DE/translation.toml b/frontend/public/locales/de-DE/translation.toml index 274de764e..d48c424f4 100644 --- a/frontend/public/locales/de-DE/translation.toml +++ b/frontend/public/locales/de-DE/translation.toml @@ -6131,8 +6131,8 @@ tags = "text,anmerkung,beschriftung" applySignatures = "Text anwenden" [addText.text] -name = "Textinhalt" -placeholder = "Geben Sie den hinzuzufügenden Text ein" +name = "Text" +placeholder = "Text eingeben" fontLabel = "Schriftart" fontSizeLabel = "Schriftgröße" fontSizePlaceholder = "Schriftgröße eingeben oder wählen (8-200)" diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 9d9278922..4f81d64a3 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -312,10 +312,10 @@ yamlAdvert = "Stirling PDF Pro supports YAML configuration files and other SSO f ssoAdvert = "Looking for more user management features? Check out Stirling PDF Pro" [analytics] -title = "Do you want make Stirling PDF better?" -paragraph1 = "Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents." +title = "Do you want to help make Stirling PDF better?" +paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents." paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better." -learnMore = "Learn more" +learnMore = "Learn more about our analytics" enable = "Enable analytics" disable = "Disable analytics" settings = "You can change the settings for analytics in the config/settings.yml file" @@ -340,6 +340,10 @@ advance = "Advanced" edit = "View & Edit" popular = "Popular" +[footer] +discord = "Discord" +issues = "GitHub" + [settings.preferences] title = "Preferences" @@ -435,6 +439,25 @@ latestVersion = "Latest Version" checkForUpdates = "Check for Updates" viewDetails = "View Details" +[settings.security] +title = "Security" +description = "Update your password to keep your account secure." + +[settings.security.password] +subtitle = "Change your password. You will be logged out after updating." +required = "All fields are required." +mismatch = "New passwords do not match." +error = "Unable to update password. Please verify your current password and try again." +success = "Password updated successfully. Please sign in again." +ssoDisabled = "Password changes are managed by your identity provider." +current = "Current password" +currentPlaceholder = "Enter your current password" +new = "New password" +newPlaceholder = "Enter a new password" +confirm = "Confirm new password" +confirmPlaceholder = "Re-enter your new password" +update = "Update password" + [settings.hotkeys] title = "Keyboard Shortcuts" description = "Customize keyboard shortcuts for quick tool access. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel." @@ -488,11 +511,16 @@ low = "Low" title = "Change Credentials" header = "Update Your Account Details" changePassword = "You are using default login credentials. Please enter a new password" +ssoManaged = "Your account is managed by your identity provider." newUsername = "New Username" oldPassword = "Current Password" newPassword = "New Password" confirmNewPassword = "Confirm New Password" submit = "Submit Changes" +credsUpdated = "Account updated" +description = "Changes saved. Please log in again." +error = "Unable to update username. Please verify your password and try again." +changeUsername = "Update your username. You will be logged out after updating." [account] title = "Account Settings" @@ -3703,6 +3731,16 @@ filesize = "File Size" [compress.grayscale] label = "Apply Grayscale for Compression" +[compress.lineArt] +label = "Convert images to line art" +description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction." +unavailable = "ImageMagick is not installed or enabled on this server" +detailLevel = "Detail level" +edgeEmphasis = "Edge emphasis" +edgeLow = "Gentle" +edgeMedium = "Balanced" +edgeHigh = "Strong" + [compress.tooltip.header] title = "Compress Settings Overview" @@ -3720,6 +3758,10 @@ bullet2 = "Higher values reduce file size" title = "Grayscale" text = "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents." +[compress.tooltip.lineArt] +title = "Line Art" +text = "Convert pages to high-contrast black and white using ImageMagick. Use detail level to control how much content becomes black, and edge emphasis to control how aggressively edges are detected." + [compress.error] failed = "An error occurred while compressing the PDF." @@ -4038,12 +4080,20 @@ settings = "Settings" adminSettings = "Admin Settings" allTools = "Tools" reader = "Reader" +tours = "Tours" +showMeAround = "Show me around" + +[quickAccess.toursTooltip] +admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour." +user = "Watch walkthroughs here: Tools tour and the New V2 layout tour." [quickAccess.helpMenu] toolsTour = "Tools Tour" toolsTourDesc = "Learn what the tools can do" adminTour = "Admin Tour" adminTourDesc = "Explore admin settings & features" +whatsNewTour = "See what's new in V2" +whatsNewTourDesc = "Tour the updated layout" [admin] error = "Error" @@ -5070,6 +5120,7 @@ loading = "Loading..." back = "Back" continue = "Continue" error = "Error" +save = "Save" [config.overview] title = "Application Configuration" @@ -5236,6 +5287,16 @@ finish = "Finish" startTour = "Start Tour" startTourDescription = "Take a guided tour of Stirling PDF's key features" +[onboarding.whatsNew] +quickAccess = "Start at the Quick Access rail to jump between Reader, Automate, your files, and all the tours." +leftPanel = "The left Tools panel lists everything you can do. Browse categories or search to find a tool quickly." +fileUpload = "Use the Files button to upload or pick a recent PDF. We will load a sample so you can see the workspace." +rightRail = "The Right Rail holds quick actions to select files, change theme or language, and download results." +topBar = "The top bar lets you swap between Viewer, Page Editor, and Active Files." +pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages." +activeFilesView = "Use Active Files to see everything you have open and pick what to work on." +wrapUp = "That is what is new in V2. Open the Tours menu anytime to replay this, the Tools tour, or the Admin tour." + [onboarding.welcomeModal] title = "Welcome to Stirling PDF!" description = "Would you like to take a quick 1-minute tour to learn the key features and how to get started?" @@ -5256,6 +5317,10 @@ download = "Download →" showMeAround = "Show me around" skipTheTour = "Skip the tour" +[onboarding.tourOverview] +title = "Tour Overview" +body = "Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need." + [onboarding.serverLicense] skip = "Skip for now" seePlans = "See Plans →" @@ -5569,6 +5634,28 @@ contactSales = "Contact Sales" contactToUpgrade = "Contact us to upgrade or customize your plan" maxUsers = "Max Users" upTo = "Up to" +getLicense = "Get Server License" +upgradeToEnterprise = "Upgrade to Enterprise" +selectPeriod = "Select Billing Period" +monthlyBilling = "Monthly Billing" +yearlyBilling = "Yearly Billing" +checkoutOpened = "Checkout Opened" +checkoutInstructions = "Complete your purchase in the Stripe tab. After payment, return here and refresh the page to activate your license. You will also receive an email with your license key." +activateLicense = "Activate Your License" + +[plan.static.licenseActivation] +checkoutOpened = "Checkout Opened in New Tab" +instructions = "Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key." +enterKey = "Enter your license key below to activate your plan:" +keyDescription = "Paste the license key from your email" +activate = "Activate License" +doLater = "I'll do this later" +success = "License Activated!" +successMessage = "Your license has been successfully activated. You can now close this window." + +[plan.static.billingPortal] +title = "Email Verification Required" +message = "You will need to verify your email address in the Stripe billing portal. Check your email for a login link." [plan.period] month = "month" @@ -5772,6 +5859,8 @@ notAvailable = "Audit system not available" notAvailableMessage = "The audit system is not configured or not available." disabled = "Audit logging is disabled" disabledMessage = "Enable audit logging in your application configuration to track system events." +enterpriseRequired = "Enterprise License Required" +enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics." [audit.error] title = "Error loading audit system" diff --git a/frontend/public/locales/nl-NL/translation.toml b/frontend/public/locales/nl-NL/translation.toml index b9599e127..a1192b2a7 100644 --- a/frontend/public/locales/nl-NL/translation.toml +++ b/frontend/public/locales/nl-NL/translation.toml @@ -1,5 +1,5 @@ -unsavedChanges = "Je hebt niet-opgeslagen wijzigingen in je PDF." -areYouSure = "Weet je zeker dat je wilt vertrekken?" +unsavedChanges = "U heeft niet-opgeslagen wijzigingen in uw PDF." +areYouSure = "Weet u zeker dat u wilt vertrekken?" unsavedChangesTitle = "Niet-opgeslagen wijzigingen" keepWorking = "Doorgaan met werken" discardChanges = "Verwerpen en verlaten" @@ -10,11 +10,11 @@ pageSelectionPrompt = "Aangepaste pagina selectie (Voer een komma-gescheiden lij startingNumberTooltip = "Het eerste getal dat wordt weergegeven. Volgende pagina's tellen door vanaf dit nummer." marginTooltip = "Afstand tussen het paginanummer en de rand van de pagina." fontSizeTooltip = "Grootte van de paginanummertekst in punten. Grotere getallen geven grotere tekst." -fontTypeTooltip = "Lettertypefamilie voor de paginanummers. Kies passend bij de stijl van je document." -customTextTooltip = "Optioneel aangepast formaat voor paginanummers. Gebruik {n} als placeholder voor het nummer. Voorbeeld: \"Pagina {n}\" toont \"Pagina 1\", \"Pagina 2\", enz." -pdfPrompt = "Selecteer PDF('s)" -multiPdfPrompt = "Selecteer PDF's (2+)" -multiPdfDropPrompt = "Selecteer (of sleep & zet neer) alle PDF's die je nodig hebt" +fontTypeTooltip = "Lettertypefamilie voor de paginanummers. Kies passend bij de stijl van uw document." +customTextTooltip = "Optioneel aangepast formaat voor paginanummers. Gebruik {n} als plaatsvervanger voor het nummer. Voorbeeld: \"Pagina {n}\" toont \"Pagina 1\", \"Pagina 2\", enz." +pdfPrompt = "PDF(-en) selecteren" +multiPdfPrompt = "PDF-en (2+) selecteren" +multiPdfDropPrompt = "Selecteer (of sleep & zet neer) alle PDF-en die u nodig hebt" imgPrompt = "Selecteer afbeelding(en)" genericSubmit = "Indienen" uploadLimit = "Maximale bestandsgrootte:" @@ -22,7 +22,7 @@ uploadLimitExceededSingular = "is te groot. Maximale toegestane grootte is" uploadLimitExceededPlural = "zijn te groot. Maximale toegestane grootte is" processTimeWarning = "Waarschuwing: Dit proces kan tot een minuut duren afhankelijk van de bestandsgrootte" pageOrderPrompt = "Aangepaste pagina volgorde (Voer een komma-gescheiden lijst van paginanummers of functies in, zoals 2n+1) :" -goToPage = "Ga" +goToPage = "Gaan" true = "Waar" false = "Onwaar" unknown = "Onbekend" @@ -38,11 +38,11 @@ undo = "Ongedaan maken" back = "Terug" nothingToUndo = "Niets om ongedaan te maken" moreOptions = "Meer opties" -editYourNewFiles = "Je nieuwe bestand(en) bewerken" +editYourNewFiles = "Uw nieuwe bestand(en) bewerken" close = "Sluiten" openInViewer = "Openen in Viewer" confirmClose = "Sluiten bevestigen" -confirmCloseMessage = "Weet je zeker dat je dit bestand wilt sluiten?" +confirmCloseMessage = "Weet u zeker dat u dit bestand wilt sluiten?" confirmCloseCancel = "Annuleren" confirmCloseConfirm = "Bestand sluiten" fileSelected = "Geselecteerd: {{filename}}" @@ -55,7 +55,7 @@ alphabet = "Alfabet" downloadPdf = "PDF downloaden" text = "Tekst" font = "Lettertype" -selectFillter = "-- Selecteer --" +selectFillter = "-- Selecteren --" pageNum = "Paginanummer" edit = "Bewerken" delete = "Verwijderen" @@ -88,15 +88,15 @@ deleteUsernameExistsMessage = "De gebruikersnaam bestaat niet en kan niet verwij downgradeCurrentUserMessage = "Kan de rol van de huidige gebruiker niet downgraden" disabledCurrentUserMessage = "De huidige gebruiker kan niet worden uitgeschakeld" downgradeCurrentUserLongMessage = "Kan de rol van de huidige gebruiker niet downgraden. Huidige gebruiker wordt dus niet weergegeven." -userAlreadyExistsOAuthMessage = "De gebruiker bestaat al als een OAuth2 gebruiker." -userAlreadyExistsWebMessage = "De gebruiker bestaat al als een web gebruiker." +userAlreadyExistsOAuthMessage = "De gebruiker bestaat al als een OAuth2-gebruiker." +userAlreadyExistsWebMessage = "De gebruiker bestaat al als een webgebruiker." oops = "Oeps!" help = "Hulp" goHomepage = "Ga naar de startpagina" -joinDiscord = "Word lid van onze Discord server" +joinDiscord = "Word lid van onze Discord-server" seeDockerHub = "Zie Docker Hub" visitGithub = "Ga naar de Github Repository" -donate = "Doneer" +donate = "Doneren" color = "Kleur" sponsor = "Sponsor" info = "Informatie" @@ -108,7 +108,7 @@ review = "Beoordelen" addToDoc = "Toevoegen aan document" reset = "Reset" apply = "Toepassen" -noFileSelected = "Geen bestand geselecteerd. Upload er één." +noFileSelected = "Geen bestand geselecteerd. Upload er een." termsAndConditions = "Algemene voorwaarden" logOut = "Uitloggen" customPosition = "Aangepaste positie" @@ -136,14 +136,14 @@ premiumFeature = "Premiumfunctie:" comingSoon = "Binnenkort beschikbaar:" [toolPanel.modePrompt] -title = "Kies hoe je tools bekijkt" -description = "Bekijk beide lay-outs en beslis hoe je de Stirling PDF-tools wilt verkennen." +title = "Kies hoe u door de tools bladert" +description = "Bekijk beide lay-outs en beslis hoe u de Stirling PDF-tools wilt verkennen." sidebarTitle = "Zijbalkmodus" -sidebarDescription = "Houd tools naast je werkruimte voor snel wisselen." +sidebarDescription = "Houd tools naast uw werkruimte voor snel wisselen." recommended = "Aanbevolen" chooseSidebar = "Zijbalkmodus gebruiken" fullscreenTitle = "Volledig scherm-modus - (verouderd)" -fullscreenDescription = "Blader door alle tools in een catalogus die de werkruimte bedekt totdat je er één kiest." +fullscreenDescription = "Blader door alle tools in een catalogus die de werkruimte bedekt totdat u er een kiest." chooseFullscreen = "Volledig scherm-modus gebruiken" dismiss = "Misschien later" @@ -155,7 +155,7 @@ favorites = "Favorieten" unavailable = "Uitgeschakeld door serverbeheerder:" unavailableDependency = "Niet beschikbaar - vereiste tool ontbreekt op server:" heading = "Alle tools (volledig scherm)" -noResults = "Pas je zoekopdracht aan of schakel beschrijvingen om te vinden wat je nodig hebt." +noResults = "Pas uw zoekopdracht aan of schakel beschrijvingen om te vinden wat u nodig hebt." recommended = "Aanbevolen" unfavorite = "Uit favorieten verwijderen" @@ -164,9 +164,9 @@ fullscreen = "Overschakelen naar volledig scherm" sidebar = "Overschakelen naar zijbalkmodus" [backendStartup] -notFoundTitle = "Backend niet gevonden" +notFoundTitle = "Back-end niet gevonden" retry = "Opnieuw proberen" -unreachable = "De applicatie kan momenteel geen verbinding maken met de backend. Controleer de status van de backend en de netwerkverbinding en probeer het vervolgens opnieuw." +unreachable = "De applicatie kan momenteel geen verbinding maken met de back-end. Controleer de status van de back-end en de netwerkverbinding en probeer het vervolgens opnieuw." [zipWarning] title = "Groot ZIP-bestand" @@ -176,23 +176,23 @@ confirm = "Uitpakken" [defaultApp] title = "Instellen als standaard PDF-app" -message = "Wil je Stirling PDF instellen als je standaard PDF-editor?" -description = "Je kunt dit later wijzigen in je systeeminstellingen." +message = "Wilt u Stirling PDF instellen als standaard PDF-editor?" +description = "U kunt dit later wijzigen in de systeeminstellingen." notNow = "Niet nu" setDefault = "Als standaard instellen" dismiss = "Sluiten" [defaultApp.prompt] title = "Instellen als standaard PDF-editor" -message = "Maak Stirling PDF je standaardapp voor het openen van PDF-bestanden." +message = "Maak Stirling PDF de standaardapp voor het openen van PDF-bestanden." [defaultApp.success] title = "Standaardapp ingesteld" -message = "Stirling PDF is nu je standaard PDF-editor" +message = "Stirling PDF is nu de standaard PDF-editor" [defaultApp.settingsOpened] title = "Instellingen geopend" -message = "Selecteer Stirling PDF in je systeeminstellingen" +message = "Selecteer Stirling PDF in de systeeminstellingen" [defaultApp.error] title = "Fout" @@ -252,23 +252,23 @@ x-large = "Extra groot" [error] pdfPassword = "Het PDF document is beveiligd met een wachtwoord en het wachtwoord is niet ingevoerd of is onjuist" -encryptedPdfMustRemovePassword = "Deze PDF is versleuteld of met een wachtwoord beveiligd. Ontgrendel hem voordat je naar PDF/A converteert." +encryptedPdfMustRemovePassword = "Deze PDF is versleuteld of met een wachtwoord beveiligd. Ontgrendel hem voordat u naar PDF/A converteert." incorrectPasswordProvided = "Het PDF-wachtwoord is onjuist of niet opgegeven." _value = "Fout" dismissAllErrors = "Alle fouten sluiten" sorry = "Excuses voor het probleem!" needHelp = "Hulp nodig / probleem gevonden?" -contactTip = "Als je nog steeds problemen hebt, schroom niet om contact met ons op te nemen voor hulp. Je kan een ticket op onze Github pagina indienen of ons via Discord bereiken:" +contactTip = "Als u nog steeds problemen hebt, schroom niet om contact met ons op te nemen voor hulp. U kunt een ticket op onze Github pagina indienen of ons via Discord bereiken:" github = "Dien een ticket op Github in." -showStack = "Geeft tracering weer" -copyStack = "Kopieer tracering" +showStack = "Tracering weergeven" +copyStack = "Tracering kopiëren" githubSubmit = "GitHub - Dien een ticket in" discordSubmit = "Discord - Maak een support post" [error.404] head = "404 - Pagina niet gevonden | Oeps, we struikelden over de code!" -1 = "We kunnen de pagina die je zoek niet vinden." -2 = "Er ging iets mis." +1 = "We kunnen de pagina die u zoekt niet vinden." +2 = "Er ging iets niet goed." [warning] tooltipTitle = "Waarschuwing" @@ -283,14 +283,14 @@ impressum = "Imprint" showCookieBanner = "Cookievoorkeuren" [pipeline] -header = "Pijplijn menu (Beta)" +header = "Pijplijn-menu (Beta)" uploadButton = "Aangepast uploaden" configureButton = "Configureren" defaultOption = "Aangepast" submitButton = "Opslaan" help = "Pijplijn help" scanHelp = "Map scannen help" -deletePrompt = "Weet je zeker dat je deze pijplijn wil verwijderen?" +deletePrompt = "Weet u zeker dat u deze pijplijn wil verwijderen?" tags = "automatiseren,volgorde,gescrript,batch-verwerking" title = "Pijplijn" @@ -312,14 +312,14 @@ yamlAdvert = "Stirling PDF Pro ondersteunt YAML-configuratie­bestanden en ander ssoAdvert = "Op zoek naar meer gebruikersbeheerfuncties? Bekijk Stirling PDF Pro" [analytics] -title = "Wil je Stirling PDF beter maken?" +title = "Wilt u Stirling PDF beter maken?" paragraph1 = "Stirling PDF heeft opt-in analyses om ons te helpen het product te verbeteren. We volgen geen persoonlijke informatie of bestandsinhoud." paragraph2 = "Overweeg analyses in te schakelen om Stirling PDF te helpen groeien en ons onze gebruikers beter te laten begrijpen." learnMore = "Meer informatie" enable = "Analyses inschakelen" disable = "Analyses uitschakelen" -settings = "Je kunt de instellingen voor analyses wijzigen in het bestand config/settings.yml" -privacyAssurance = "We volgen geen persoonlijke informatie of de inhoud van je bestanden." +settings = "U kunt de instellingen voor analyses wijzigen in het bestand config/settings.yml" +privacyAssurance = "We volgen geen persoonlijke informatie of de inhoud van uw bestanden." [navbar] favorite = "Favorieten" @@ -335,11 +335,15 @@ search = "Zoeken" organize = "Organizeren" convertTo = "Converteren naar PDF" convertFrom = "Converteren van PDF" -security = "Ondertekenen & beveiliging" +security = "Ondertekening & beveiliging" advance = "Geavanceerd" edit = "Bekijken & wijzigen" popular = "Populair" +[footer] +discord = "Discord" +issues = "GitHub" + [settings.preferences] title = "Voorkeuren" @@ -362,7 +366,7 @@ security = "Beveiliging" connections = "Verbindingen" [settings.licensingAnalytics] -title = "Licenties & Analytics" +title = "Licenties & Analyse" plan = "Abonnement" audit = "Audit" usageAnalytics = "Gebruiksstatistieken" @@ -378,7 +382,7 @@ apiKeys = "API-sleutels" [settings.tooltips] enableLoginFirst = "Schakel eerst de loginmodus in" -requiresEnterprise = "Vereist Enterprise-licentie" +requiresEnterprise = "Enterprise-licentie vereist" [settings.connection] title = "Verbindingsmodus" @@ -404,20 +408,20 @@ autoUnzipFileLimitTooltip = "Alleen uitpakken als de ZIP dit aantal bestanden of autoUnzipFileLimit = "Limiet automatisch uitpakken" autoUnzipFileLimitDescription = "Maximaal aantal bestanden om uit ZIP te halen" defaultPdfEditor = "Standaard PDF-editor" -defaultPdfEditorActive = "Stirling PDF is je standaard PDF-editor" +defaultPdfEditorActive = "Stirling PDF is de standaard PDF-editor" defaultPdfEditorInactive = "Een andere app is als standaard ingesteld" defaultPdfEditorChecking = "Controleren..." defaultPdfEditorSet = "Al standaard" setAsDefault = "Als standaard instellen" hideUnavailableTools = "Niet-beschikbare tools verbergen" -hideUnavailableToolsDescription = "Verwijder tools die door je server zijn uitgeschakeld in plaats van ze grijs te tonen." +hideUnavailableToolsDescription = "Verwijder tools die door de server zijn uitgeschakeld in plaats van ze grijs te tonen." hideUnavailableConversions = "Niet-beschikbare conversies verbergen" hideUnavailableConversionsDescription = "Verwijder uitgeschakelde conversie-opties in de tool Converteren in plaats van ze grijs te tonen." [settings.general.enableFeatures] dismiss = "Sluiten" title = "Voor systeembeheerders" -intro = "Schakel gebruikersauthenticatie, teambeheer en werkruimtefuncties in voor je organisatie." +intro = "Schakel gebruikersauthenticatie, teambeheer en werkruimtefuncties in voor uw organisatie." action = "Configureren" and = "en" benefit = "Schakelt gebruikersrollen, teamsamenwerking, beheerdersfuncties en enterprise-functies in." @@ -435,6 +439,25 @@ latestVersion = "Laatste versie" checkForUpdates = "Op updates controleren" viewDetails = "Details bekijken" +[settings.security] +title = "Beveiliging" +description = "Werk uw wachtwoord bij om uw account veilig te houden." + +[settings.security.password] +subtitle = "Wijzig uw wachtwoord. U wordt uitgelogd na het bijwerken." +required = "Alle velden zijn vereist." +mismatch = "Nieuwe wachtwoorden komen niet overeen." +error = "Kan wachtwoord niet bijwerken. Controleer uw huidige wachtwoord en probeer het opnieuw." +success = "Wachtwoord succesvol bijgewerkt. Log alstublieft opnieuw in." +ssoDisabled = "Wachtwoordwijzigingen worden beheerd door uw identiteitsprovider." +current = "Huidig wachtwoord" +currentPlaceholder = "Voer het huidige wachtwoord in" +new = "Nieuw wachtwoord" +newPlaceholder = "Voer een nieuw wachtwoord in" +confirm = "Nieuw wachtwoord bevestigen" +confirmPlaceholder = "Voer uw nieuwe wachtwoord nogmaals in" +update = "Wachtwoord bijwerken" + [settings.hotkeys] title = "Sneltoetsen" description = "Pas sneltoetsen aan voor snelle toegang tot tools. Klik op \"Sneltoets wijzigen\" en druk een nieuwe toetsencombinatie in. Druk op Esc om te annuleren." @@ -443,15 +466,15 @@ searchPlaceholder = "Tools zoeken..." none = "Niet toegewezen" customBadge = "Aangepast" defaultLabel = "Standaard: {{shortcut}}" -capturing = "Toetsen indrukken… (Esc om te annuleren)" +capturing = "Toetsen indrukken... (Esc om te annuleren)" change = "Sneltoets wijzigen" reset = "Reset" shortcut = "Sneltoets" noShortcut = "Geen sneltoets ingesteld" [settings.hotkeys.errorModifier] -mac = "Neem ⌘ (Command), ⌥ (Option) of een andere modificatietoets op in je sneltoets." -windows = "Neem Ctrl, Alt of een andere modificatietoets op in je sneltoets." +mac = "Neem ⌘ (Command), ⌥ (Option) of een andere modificatietoets op in de sneltoets." +windows = "Neem Ctrl, Alt of een andere modificatietoets op in de sneltoets." [update] modalTitle = "Update beschikbaar" @@ -461,7 +484,7 @@ latestStable = "Laatste stabiele" priorityLabel = "Prioriteit" recommendedAction = "Aanbevolen actie" breakingChangesDetected = "Incompatibele wijzigingen gedetecteerd" -breakingChangesMessage = "Sommige versies bevatten incompatibele wijzigingen. Bekijk onderstaande migratiehandleidingen voordat je update." +breakingChangesMessage = "Sommige versies bevatten incompatibele wijzigingen. Bekijk onderstaande migratiehandleidingen voordat u update." migrationGuides = "Migratiehandleidingen" viewGuide = "Handleiding bekijken" loadingDetailedInfo = "Gedetailleerde informatie laden..." @@ -486,13 +509,18 @@ low = "Laag" [changeCreds] title = "Inloggegevens wijzigen" -header = "Werk je accountgegevens bij" -changePassword = "Je gebruikt de standaard inloggegevens. Voer alstublieft een nieuw wachtwoord in" +header = "Werk uw accountgegevens bij" +changePassword = "U gebruikt de standaard inloggegevens. Voer alstublieft een nieuw wachtwoord in" +ssoManaged = "Uw account wordt beheerd door uw identity-provider." newUsername = "Nieuwe gebruikersnaam" oldPassword = "Huidige wachtwoord" newPassword = "Nieuw wachtwoord" confirmNewPassword = "Bevestig nieuw wachtwoord" submit = "Wijzigingen opslaan" +credsUpdated = "Account bijgewerkt" +description = "Wijzigingen opgeslagen. Log alstublieft opnieuw in." +error = "Kan gebruikersnaam niet bijwerken. Controleer het wachtwoord en probeer het opnieuw." +changeUsername = "Werk uw gebruikersnaam bij. U wordt uitgelogd na het bijwerken." [account] title = "Account instellingen" @@ -507,7 +535,7 @@ newPassword = "Nieuw wachtwoord" changePassword = "Wijzig wachtwoord" confirmNewPassword = "Bevestig nieuw wachtwoord" signOut = "Uitloggen" -yourApiKey = "Jouw API sleutel" +yourApiKey = "Uw API-sleutel" syncTitle = "Synchroniseer browserinstellingen met account" settingsCompare = "Instellingen vergelijking:" property = "Eigenschap" @@ -517,11 +545,11 @@ syncToAccount = "Synchroniseer account <- browser" [adminUserSettings] title = "Gebruikersbeheer" -header = "Beheer gebruikers" +header = "Gebruikers beheren" admin = "Beheerder" user = "Gebruiker" -addUser = "Voeg nieuwe gebruiker toe" -deleteUser = "Verwijder gebruiker" +addUser = "Nieuwe gebruiker toevoegen" +deleteUser = "Gebruiker verwijderen" confirmDeleteUser = "Moet deze gebruiker verwijderd worden?" confirmChangeUserStatus = "Moet de gebruiker worden uitgeschakeld/ingeschakeld?" usernameInfo = "Gebruikersnaam kan alleen letters, nummers en de volgende speciale tekens @._+- bevatten of moet een geldig emailadres zijn." @@ -565,7 +593,7 @@ endpoint = "Eindpunt" visits = "Bezoeken" percentage = "Percentage" loading = "Laden..." -failedToLoad = "Het is niet gelukt de endpointgegevens te laden. Probeer te vernieuwen." +failedToLoad = "Het is niet gelukt de eindpuntgegevens te laden. Probeer te vernieuwen." home = "Home" login = "Inloggen" top = "Top" @@ -577,29 +605,29 @@ retry = "Opnieuw proberen" title = "Database Importeer/Exporteer" header = "Database Importeer/Exporteer" fileName = "Bestandsnaam" -creationDate = "Creatiedatum" +creationDate = "Aanmaakdatum" fileSize = "Bestandsgrootte" deleteBackupFile = "Backupbestand verwijderen" importBackupFile = "Backupbestand importeren" createBackupFile = "Back-upbestand maken" downloadBackupFile = "Backupbestand downloaden" -info_1 = "Bij het importeren van gegevens is het cruciaal om de juiste structuur te zorgen voor. Als je niet zeker bent van wat je doet, raadpleeg dan advies en ondersteuning bij een professionele. Een fout in de structuur kan leiden tot toepassingsfouten, waarmee wellicht zelfs de volledige uitvoerbaarheid van de toepassing belemmerd wordt." +info_1 = "Bij het importeren van gegevens is het cruciaal om de juiste structuur te zorgen voor. Als u niet zeker bent van wat u doet, raadpleeg dan advies en ondersteuning bij een professionele. Een fout in de structuur kan leiden tot toepassingsfouten, waarmee wellicht zelfs de volledige uitvoerbaarheid van de toepassing belemmerd wordt." info_2 = "De bestandsnaam maakt geen verschil bij het uploaden. Hij zal later worden herbewoond om de indeling backup_user_yyyyMMddHHmm.sql te volgen, waardoor een consistente bestandsnaamconventie waarborgd wordt." submit = "Backup importeren" -importIntoDatabaseSuccessed = "Importeer naar database succesvol" +importIntoDatabaseSuccessed = "Importeren naar database succesvol" backupCreated = "Databaseback-up geslaagd" -fileNotFound = "File not Found" +fileNotFound = "Bestand niet gevonden" fileNullOrEmpty = "Bestand mag niet null of leeg zijn" -failedImportFile = "Failed Import File" -notSupported = "Deze functie is niet beschikbaar voor je databaseverbinding." +failedImportFile = "Bestand importeren is mislukt" +notSupported = "Deze functie is niet beschikbaar voor de databaseverbinding." [session] -expired = "Je sessie is verlopen. Voer de pagina opnieuw in en probeer het opnieuw." +expired = "Uw sessie is verlopen. Voer de pagina opnieuw in en probeer het opnieuw." refreshPage = "Pagina vernieuwen" [home] -desc = "Jouw lokaal gehoste one-stop-shop voor al je PDF-behoeften." -searchBar = "Zoek naar functies..." +desc = "Uw lokaal gehoste one-stop-shop voor al uw PDF-behoeften." +searchBar = "Functies zoeken..." setFavorites = "Favorieten instellen" hideFavorites = "Favorieten verbergen" showFavorites = "Favorieten tonen" @@ -611,7 +639,7 @@ sortBy = "Sorteren op:" [home.viewPdf] title = "PDF bekijken/bewerken" -desc = "Bekijk, annoteer, voeg tekst of afbeeldingen toe" +desc = "Bekijken, annoteren, tekst of afbeeldingen toevoegen" [home.mobile] brandAlt = "Stirling PDF-logo" @@ -631,17 +659,17 @@ desc = "Pagina's samenvoegen, draaien, herschikken en verwijderen" [home.merge] tags = "combineren,samenvoegen,verenigen" title = "Samenvoegen" -desc = "Voeg eenvoudig meerdere PDF's samen tot één." +desc = "Meerdere PDF-en eenvoudig samenvoegen tot één." [home.split] tags = "splitsen,scheiden,opdelen" title = "Splitsen" -desc = "Splits PDF's in meerdere documenten" +desc = "Splits PDF-en in meerdere documenten" [home.rotate] tags = "draaien,omklappen,oriënteren" title = "Roteren" -desc = "Roteer eenvoudig je PDF's." +desc = "Roteer eenvoudig uw PDF-en." [home.convert] tags = "converteren,wijzigen" @@ -666,17 +694,17 @@ desc = "Ingesloten bestanden (bijlagen) toevoegen aan of verwijderen uit een PDF [home.watermark] tags = "stempel,markeren,overlay" title = "Watermerk toevoegen" -desc = "Voeg een aangepast watermerk toe aan je PDF-document." +desc = "Voeg een aangepast watermerk toe aan uw PDF-document." [home.removePassword] tags = "ontgrendelen" title = "Wachtwoord verwijderen" -desc = "Verwijder wachtwoordbeveiliging van je PDF-document." +desc = "Verwijder wachtwoordbeveiliging van uw PDF-document." [home.compress] tags = "verkleinen,verminderen,optimaliseren" title = "Comprimeren" -desc = "Comprimeer PDF's om hun bestandsgrootte te verkleinen." +desc = "Comprimeer PDF-en om hun bestandsgrootte te verkleinen." [home.unlockPDFForms] tags = "ontgrendelen,inschakelen,bewerken" @@ -710,7 +738,7 @@ desc = "Voegt handtekening toe aan PDF via tekenen, tekst of afbeelding" [home.flatten] tags = "vereenvoudigen,verwijderen,interactief" -title = "Platdrukken" +title = "Afvlakken" desc = "Verwijder alle interactieve elementen en formulieren uit een PDF" [home.certSign] @@ -725,7 +753,7 @@ desc = "Probeert een corrupt/beschadigd PDF te herstellen" [home.removeBlanks] tags = "verwijderen,opschonen,leeg" -title = "Verwijder lege pagina's" +title = "Lege pagina's verwijderen" desc = "Detecteert en verwijdert lege pagina's uit een document" [home.removeAnnotations] @@ -755,7 +783,7 @@ desc = "Maak boekjes met de juiste paginavolgorde en meerpagina-indeling voor af [home.scalePages] tags = "formaat wijzigen,aanpassen,schalen" -title = "Aanpassen paginaformaat/schaal" +title = "Paginaformaat/schaal aanpassen" desc = "Wijzig de grootte/schaal van een pagina en/of de inhoud ervan." [home.addPageNumbers] @@ -780,37 +808,37 @@ desc = "Snijd een PDF bij om de grootte te verkleinen (behoudt tekst!)" [home.autoSplitPDF] tags = "auto,splitsen,QR" -title = "Automatisch splitsen pagina's" +title = "Automatisch pagina's splitsen" desc = "Automatisch splitsen van gescande PDF met fysieke gescande paginasplitter QR-code" [home.sanitize] tags = "opschonen,schonen,verwijderen" -title = "Sanitiseren" +title = "Opschonen" desc = "Potentieel schadelijke elementen uit PDF-bestanden verwijderen" [home.getPdfInfo] tags = "info,metadata,details" -title = "Haal ALLE informatie op over PDF" -desc = "Haalt alle mogelijke informatie op van PDF's" +title = "ALLE informatie over PDF ophalen" +desc = "Haalt alle mogelijke informatie op van PDF-en" [home.pdfToSinglePage] -tags = "combineren,samenvoegen,één" +tags = "combineren,samenvoegen,een,enkel" title = "PDF naar één grote pagina" desc = "Voegt alle PDF-pagina's samen tot één grote pagina" [home.showJS] tags = "javascript,code,script" -title = "Toon Javascript" +title = "Javascript weergeven" desc = "Zoekt en toont ieder script dat in een PDF is geïnjecteerd" [home.redact] tags = "censureren,zwartlakken,verbergen" -title = "Manual Redaction" -desc = "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)" +title = "Redigeren" +desc = "Redigeert een PDF op basis van geselecteerde tekst, getekende vormen en/of geselecteerde pagina('s)" [home.splitBySections] tags = "splitsen,secties,verdelen" -title = "PDF splitsen op secties" +title = "PDF splitsen in secties" desc = "Elke pagina van een PDF opdelen in kleinere horizontale en verticale secties" [home.addStamp] @@ -825,7 +853,7 @@ desc = "Afbeeldingen uit PDF verwijderen om het bestandsgrootte te verminderen" [home.splitByChapters] tags = "splitsen,hoofdstukken,structuur" -title = "PDF op hoofdstukken splitsen" +title = "PDF splitsen in hoofdstukken" desc = "Splits een PDF op basis van zijn hoofdstukstructuur in meerdere bestanden." [home.validateSignature] @@ -851,12 +879,12 @@ desc = "Bladwijzers en inhoudsopgave toevoegen of bewerken in PDF-documenten" [home.manageCertificates] tags = "certificaten,importeren,exporteren" title = "Certificaten beheren" -desc = "Digitale certificaatbestanden importeren, exporteren of verwijderen die worden gebruikt voor het ondertekenen van PDF's." +desc = "Digitale certificaatbestanden importeren, exporteren of verwijderen die worden gebruikt voor het ondertekenen van PDF-en." [home.read] tags = "bekijken,openen,weergeven" title = "Lezen" -desc = "PDF's bekijken en annoteren. Markeer tekst, teken of voeg opmerkingen in voor beoordeling en samenwerking." +desc = "PDF-en bekijken en annoteren. Markeer tekst, teken of voeg opmerkingen in voor beoordeling en samenwerking." [home.reorganizePages] tags = "herordenen,herindelen,organiseren" @@ -871,7 +899,7 @@ desc = "Specifieke pagina's uit een PDF-document extraheren" [home.removePages] tags = "verwijderen,extraheren,uitsluiten" title = "Verwijderen" -desc = "Verwijder ongewenste pagina's uit je PDF-document." +desc = "Verwijder ongewenste pagina's uit uw PDF-document." [home.autoSizeSplitPDF] tags = "auto,splitsen,grootte" @@ -902,7 +930,7 @@ desc = "Link naar handleiding voor air-gapped-installatie" [home.addPassword] title = "Wachtwoord toevoegen" -desc = "Versleutel je PDF-document met een wachtwoord." +desc = "Versleutel uw PDF-document met een wachtwoord." [home.changePermissions] title = "Rechten wijzigen" @@ -914,17 +942,17 @@ title = "Automatiseren" desc = "Bouw workflows met meerdere stappen door PDF-acties te koppelen. Ideaal voor terugkerende taken." [home.overlay-pdfs] -desc = "Plaatst PDF's over een andere PDF heen" -title = "PDF's overlappen" +desc = "Plaatst PDF-en over een andere PDF heen" +title = "PDF-en overlappen" [home.pdfTextEditor] title = "PDF-teksteditor" -desc = "Bewerk bestaande tekst en afbeeldingen in PDF's" +desc = "Bewerk bestaande tekst en afbeeldingen in PDF-en" [home.addText] tags = "tekst,annotatie,label" title = "Tekst toevoegen" -desc = "Voeg overal in je PDF aangepaste tekst toe" +desc = "Voeg overal in uw PDF aangepaste tekst toe" [landing] addFiles = "Bestanden toevoegen" @@ -958,8 +986,8 @@ moveLeft = "Naar links verplaatsen" moveRight = "Naar rechts verplaatsen" delete = "Verwijderen" dragDropMessage = "Pagina('s) geselecteerd" -undo = "Undo" -redo = "Redo" +undo = "Undo (CTRL + Z)" +redo = "Redo (CTRL + Y)" [merge] tags = "samenvoegen,pagina bewerkingen,serverzijde" @@ -990,7 +1018,7 @@ descending = "Aflopend" sort = "Sorteren" [merge.error] -failed = "Er is een fout opgetreden bij het samenvoegen van de PDF's." +failed = "Er is een fout opgetreden bij het samenvoegen van de PDF-en." [merge.tooltip.header] title = "Overzicht instellingen samenvoegen" @@ -1004,8 +1032,8 @@ selectMethod = "Selecteer een splitsmethode" resultsTitle = "Splitsresultaten" [split.desc] -1 = "De nummers die je kiest zijn de paginanummers waarop je een splitsing wilt uitvoeren" -2 = "Als zodanig selecteren van 1,3,7-9 zou een 10 pagina's tellend document splitsen in 6 aparte PDF's met:" +1 = "De nummers die u kiest zijn de paginanummers waarop u een splitsing wilt uitvoeren" +2 = "Als zodanig selecteren van 1,3,7-9 zou een 10 pagina's tellend document splitsen in 6 aparte PDF-en met:" 3 = "Document #1: Pagina 1" 4 = "Document #2: Pagina 2 en 3" 5 = "Document #3: Pagina 4, 5, 6 en 7" @@ -1025,7 +1053,7 @@ failed = "Er is een fout opgetreden bij het splitsen van de PDF." [split.method] label = "Kies een splitsmethode" -placeholder = "Selecteer hoe je de PDF wilt splitsen" +placeholder = "Selecteer hoe u de PDF wilt splitsen" [split.methods.prefix] splitAt = "Splitsen op" @@ -1054,7 +1082,7 @@ tooltip = "Voer het aantal pagina's in voor elk gesplitst bestand" [split.methods.byDocCount] name = "Aantal documenten" desc = "Specifiek aantal bestanden maken" -tooltip = "Voer in hoeveel bestanden je wilt maken" +tooltip = "Voer in hoeveel bestanden u wilt maken" [split.methods.byChapters] name = "Hoofdstukken" @@ -1083,7 +1111,7 @@ title = "Overzicht splitsmethoden" [split.tooltip.byPages] title = "Splitsen op paginanummers" -text = "Splits je PDF op specifieke paginanummers. Met 'n' wordt gesplitst na pagina n. Met 'n-m' wordt gesplitst vóór pagina n en na pagina m." +text = "Splits de PDF op specifieke paginanummers. Met 'n' wordt gesplitst na pagina n. Met 'n-m' wordt gesplitst vóór pagina n en na pagina m." bullet1 = "Enkele splitspunten: 3,7 (splitst na pagina's 3 en 7)" bullet2 = "Reeks-splitspunten: 3-8 (splitst vóór pagina 3 en na pagina 8)" bullet3 = "Gemengd: 2,5-10,15 (splitst na pagina 2, vóór pagina 5, na pagina 10 en na pagina 15)" @@ -1093,25 +1121,25 @@ title = "Splitsen op rastersecties" text = "Deel elke pagina op in een raster van secties. Handig voor documenten met meerdere kolommen of het extraheren van specifieke gebieden." bullet1 = "Horizontaal: aantal rijen om te maken" bullet2 = "Verticaal: aantal kolommen om te maken" -bullet3 = "Samenvoegen: alle secties combineren in één PDF" +bullet3 = "Samenvoegen: alle secties combineren in een PDF" [split.tooltip.bySize] title = "Splitsen op bestandsgrootte" -text = "Maak meerdere PDF's die een opgegeven bestandsgrootte niet overschrijden. Ideaal bij limieten of e-mailbijlagen." +text = "Maak meerdere PDF-en die een opgegeven bestandsgrootte niet overschrijden. Ideaal bij limieten of e-mailbijlagen." bullet1 = "Gebruik MB voor grotere bestanden (bijv. 10MB)" bullet2 = "Gebruik KB voor kleinere bestanden (bijv. 500KB)" bullet3 = "Het systeem splitst op paginagrens" [split.tooltip.byCount] title = "Splitsen op aantal" -text = "Maak meerdere PDF's met een specifiek aantal pagina's of documenten elk." +text = "Maak meerdere PDF-en met een specifiek aantal pagina's of documenten elk." bullet1 = "Aantal pagina's: vast aantal pagina's per bestand" bullet2 = "Aantal documenten: vast aantal uitvoerbestanden" bullet3 = "Handig voor batchverwerkingsworkflows" [split.tooltip.byChapters] title = "Splitsen op hoofdstukken" -text = "Gebruik PDF-bladwijzers om automatisch te splitsen op hoofdstukgrenzen. Vereist PDF's met bladwijzerstructuur." +text = "Gebruik PDF-bladwijzers om automatisch te splitsen op hoofdstukgrenzen. Vereist PDF-en met bladwijzerstructuur." bullet1 = "Bladwijzerniveau: welk niveau om op te splitsen (1=bovenste niveau)" bullet2 = "Metadata opnemen: documenteigenschappen behouden" bullet3 = "Duplicaten toestaan: herhaalde bladwijzernamen afhandelen" @@ -1119,7 +1147,7 @@ bullet3 = "Duplicaten toestaan: herhaalde bladwijzernamen afhandelen" [split.tooltip.byDocCount] bullet1 = "Voer het aantal gewenste uitvoerbestanden in" bullet2 = "Pagina's worden zo gelijk mogelijk verdeeld" -bullet3 = "Handig wanneer je een specifiek aantal bestanden nodig hebt" +bullet3 = "Handig wanneer u een specifiek aantal bestanden nodig hebt" text = "Maak een specifiek aantal uitvoerbestanden door pagina's gelijkmatig te verdelen." title = "Splitsen op aantal documenten" @@ -1127,27 +1155,27 @@ title = "Splitsen op aantal documenten" bullet1 = "Voer het aantal pagina's per uitvoerbestand in" bullet2 = "Het laatste bestand kan minder pagina's hebben als het niet gelijkmatig deelbaar is" bullet3 = "Handig voor batchverwerkingsworkflows" -text = "Maak meerdere PDF's met een specifiek aantal pagina's elk. Perfect voor uniforme documentdelen." +text = "Maak meerdere PDF-en met een specifiek aantal pagina's elk. Perfect voor uniforme documentdelen." title = "Splitsen op aantal pagina's" [split.tooltip.byPageDivider] bullet1 = "Print scheidingsvellen via de downloadlink" -bullet2 = "Plaats scheidingsvellen tussen je documenten" +bullet2 = "Plaats scheidingsvellen tussen uw documenten" bullet3 = "Scan alle documenten samen als één PDF" bullet4 = "Uploaden - scheidingspagina's worden automatisch gedetecteerd en verwijderd" -bullet5 = "Schakel Duplex-modus in als je beide zijden van scheidingsvellen scant" +bullet5 = "Schakel Duplex-modus in als u beide zijden van scheidingsvellen scant" text = "Scans automatisch splitsen met fysieke scheidingsvellen met QR-codes. Perfect voor meerdere documenten die samen zijn gescand." title = "Splitsen met paginascheider" [split.methodSelection.tooltip] bullet1 = "Klik op een methodekaart om te selecteren" bullet2 = "Beweeg over elke kaart om een korte beschrijving te zien" -bullet3 = "De instellingenstap verschijnt nadat je een methode hebt geselecteerd" -bullet4 = "Je kunt de methode op elk moment wijzigen vóór verwerking" -title = "Kies je splitsmethode" +bullet3 = "De instellingenstap verschijnt nadat u een methode hebt geselecteerd" +bullet4 = "U kunt de methode op elk moment wijzigen vóór verwerking" +title = "Kies de splitsmethode" [split.methodSelection.tooltip.header] -text = "Kies hoe je je PDF-document wilt splitsen. Elke methode is geoptimaliseerd voor verschillende use-cases en documenttypen." +text = "Kies hoe u het PDF-document wilt splitsen. Elke methode is geoptimaliseerd voor verschillende use-cases en documenttypen." title = "Selectie splitsmethode" [rotate] @@ -1167,7 +1195,7 @@ title = "Rotatievoorbeeld" title = "Overzicht rotatie-instellingen" [rotate.tooltip.description] -text = "Draai je PDF-pagina's met de klok mee of tegen de klok in in stappen van 90 graden. Alle pagina's in de PDF worden gedraaid. Het voorbeeld toont hoe je document eruitziet na het draaien." +text = "Draai uw PDF-pagina's met de klok mee of tegen de klok in in stappen van 90 graden. Alle pagina's in de PDF worden gedraaid. Het voorbeeld toont hoe u document eruitziet na het draaien." [rotate.tooltip.controls] title = "Bediening" @@ -1206,7 +1234,7 @@ fillPage = "Pagina vullen" autoRotate = "Automatisch roteren" autoRotateDescription = "Afbeeldingen automatisch draaien zodat ze beter op de PDF-pagina passen" combineImages = "Afbeeldingen combineren" -combineImagesDescription = "Combineer alle afbeeldingen in één PDF, of maak afzonderlijke PDF's voor elke afbeelding" +combineImagesDescription = "Combineer alle afbeeldingen in één PDF, of maak afzonderlijke PDF-en voor elke afbeelding" webOptions = "Web-naar-PDF-opties" zoomLevel = "Zoomniveau" emailOptions = "E-mail-naar-PDF-opties" @@ -1260,7 +1288,7 @@ multi = "Meerdere afbeeldingen, één afbeelding per pagina" colorType = "Kleurtype" color = "Kleur" grey = "Grijstinten" -blackwhite = "Zwart en wit (kan data verliezen!)" +blackwhite = "Zwart en wit (kans op gegevensverlies!)" submit = "Omzetten" info = "Python is niet geïnstalleerd. Vereist voor WebP-conversie." placeholder = "(bijv. 1,2,8 of 4,7,12-16 of 2n-1)" @@ -1287,11 +1315,11 @@ _value = "Modus" 11 = "Alle pagina's dupliceren" [pdfOrganiser.mode.desc] -BOOKLET_SORT = "Pagina's rangschikken voor boekjes afdrukken (laatste, eerste, tweede, een-na-laatste, …)." +BOOKLET_SORT = "Pagina's rangschikken voor boekjes afdrukken (laatste, eerste, tweede, een-na-laatste, ...)." CUSTOM = "Gebruik een aangepaste reeks paginanummers of expressies om een nieuwe volgorde te definiëren." -DUPLEX_SORT = "Voorkanten en daarna achterkanten verweven alsof een duplexscanner eerst alle voorkanten en daarna alle achterkanten heeft gescand (1, n, 2, n-1, …)." +DUPLEX_SORT = "Voorkanten en daarna achterkanten verweven alsof een duplexscanner eerst alle voorkanten en daarna alle achterkanten heeft gescand (1, n, 2, n-1, ...)." DUPLICATE = "Dupliceer elke pagina volgens het aantal in de aangepaste volgorde (bijv. 4 duplicaten betekent elke pagina 4×)." -ODD_EVEN_MERGE = "Twee PDF's samenvoegen door pagina's af te wisselen: oneven uit de eerste, even uit de tweede." +ODD_EVEN_MERGE = "Twee PDF-en samenvoegen door pagina's af te wisselen: oneven uit de eerste, even uit de tweede." ODD_EVEN_SPLIT = "Het document splitst in twee uitvoerbestanden: alle oneven pagina's en alle even pagina's." REMOVE_FIRST = "De eerste pagina uit het document verwijderen." REMOVE_FIRST_AND_LAST = "Zowel de eerste als de laatste pagina uit het document verwijderen." @@ -1302,11 +1330,11 @@ SIDE_STITCH_BOOKLET_SORT = "Pagina's rangschikken voor zijkant-geniete boekjes ( [pdfOrganiser.desc] CUSTOM = "Gebruik een aangepaste reeks paginanummers of expressies om een nieuwe volgorde te definiëren." REVERSE_ORDER = "Document omdraaien zodat de laatste pagina de eerste wordt, enzovoort." -DUPLEX_SORT = "Voorkanten en daarna achterkanten verweven alsof een duplexscanner eerst alle voorkanten en daarna alle achterkanten heeft gescand (1, n, 2, n-1, …)." -BOOKLET_SORT = "Pagina's rangschikken voor boekjes afdrukken (laatste, eerste, tweede, een-na-laatste, …)." +DUPLEX_SORT = "Voorkanten en daarna achterkanten verweven alsof een duplexscanner eerst alle voorkanten en daarna alle achterkanten heeft gescand (1, n, 2, n-1, ...)." +BOOKLET_SORT = "Pagina's rangschikken voor boekjes afdrukken (laatste, eerste, tweede, een-na-laatste, ...)." SIDE_STITCH_BOOKLET_SORT = "Pagina's rangschikken voor zijkant-geniete boekjes (geoptimaliseerd voor binden aan de zijkant)." ODD_EVEN_SPLIT = "Het document splitst in twee uitvoerbestanden: alle oneven pagina's en alle even pagina's." -ODD_EVEN_MERGE = "Twee PDF's samenvoegen door pagina's af te wisselen: oneven uit de eerste, even uit de tweede." +ODD_EVEN_MERGE = "Twee PDF-en samenvoegen door pagina's af te wisselen: oneven uit de eerste, even uit de tweede." DUPLICATE = "Dupliceer elke pagina volgens het aantal in de aangepaste volgorde (bijv. 4 duplicaten betekent elke pagina 4×)." REMOVE_FIRST = "Verwijder de eerste pagina uit het document." REMOVE_LAST = "Verwijder de laatste pagina uit het document." @@ -1327,15 +1355,15 @@ label = "Afbeeldingsbestand" configure = "Afbeelding configureren" [addImage.step] -createDesc = "Upload de afbeelding die je wilt toevoegen" +createDesc = "Upload de afbeelding die u wilt toevoegen" place = "Afbeelding plaatsen" -placeDesc = "Klik op de PDF om je afbeelding toe te voegen" +placeDesc = "Klik op de PDF om uw afbeelding toe te voegen" [addImage.instructions] title = "Afbeeldingen toevoegen" -text = "Klik na het uploaden van je afbeelding ergens op de PDF om deze te plaatsen." +text = "Klik na het uploaden van de afbeelding ergens op de PDF om deze te plaatsen." paused = "Plaatsing gepauzeerd" -resumeHint = "Hervat de plaatsing om te klikken en je afbeelding toe te voegen." +resumeHint = "Hervat de plaatsing om te klikken en uw afbeelding toe te voegen." noSignature = "Upload hierboven een afbeelding om te kunnen plaatsen." [addImage.mode] @@ -1583,7 +1611,7 @@ tabTitle = "Werkruimte inhoud" subtitle = "Importeer bladwijzers, bouw hiërarchieën en pas de inhoud toe zonder krappe zijpanelen." noFile = "Geen PDF geselecteerd" fileLabel = "Wijzigingen worden toegepast op de momenteel geselecteerde PDF." -filePrompt = "Selecteer een PDF uit je bibliotheek of upload een nieuwe om te beginnen." +filePrompt = "Selecteer een PDF uit uw bibliotheek of upload een nieuwe om te beginnen." changeFile = "PDF wijzigen" selectFile = "PDF selecteren" @@ -1593,7 +1621,7 @@ description = "Selecteer de tool Inhoudsopgave bewerken om de werkruimte te lade [editTableOfContents.editor] heading = "Bladwijzer-editor" -description = "Voeg bladwijzers toe, nestel ze en wijzig de volgorde om je PDF-inhoud te maken." +description = "Voeg bladwijzers toe, nestel ze en wijzig de volgorde om uw PDF-inhoud te maken." addTopLevel = "Bladwijzer op hoogste niveau toevoegen" defaultTitle = "Nieuwe bladwijzer" defaultChildTitle = "Onderliggende bladwijzer" @@ -1605,7 +1633,7 @@ confirmRemove = "Deze bladwijzer en alle onderliggende verwijderen?" [editTableOfContents.editor.empty] title = "Nog geen bladwijzers" -description = "Importeer bestaande bladwijzers of begin met je eerste item toe te voegen." +description = "Importeer bestaande bladwijzers of begin met uw eerste item toe te voegen." action = "Eerste bladwijzer toevoegen" [editTableOfContents.editor.field] @@ -1624,7 +1652,7 @@ loadedBody = "Bestaande bladwijzers uit de PDF zijn in de editor geladen." noBookmarks = "Geen bladwijzers gevonden in de geselecteerde PDF." loadFailed = "Kan geen bladwijzers uit de geselecteerde PDF extraheren." imported = "Bladwijzers geïmporteerd" -importedBody = "Je JSON-inhoud heeft de huidige editorinhoud vervangen." +importedBody = "De JSON-inhoud heeft de huidige editorinhoud vervangen." importedClipboard = "Klembordgegevens hebben de huidige bladwijzerlijst vervangen." invalidJson = "Ongeldige JSON-structuur" invalidJsonBody = "Geef een geldig bladwijzer-JSON-bestand op en probeer het opnieuw." @@ -1765,10 +1793,10 @@ description = "Voer cijfers gescheiden door komma's in." title = "Afzonderlijke pagina's" [pageSelection.tooltip.mathematical] -bullet1 = "2n → alle even pagina's (2, 4, 6…)" -bullet2 = "2n-1 → alle oneven pagina's (1, 3, 5…)" -bullet3 = "3n → elke 3e pagina (3, 6, 9…)" -bullet4 = "4n-1 → pagina's 3, 7, 11, 15…" +bullet1 = "2n → alle even pagina's (2, 4, 6...)" +bullet2 = "2n-1 → alle oneven pagina's (1, 3, 5...)" +bullet3 = "3n → elke 3e pagina (3, 6, 9...)" +bullet4 = "4n-1 → pagina's 3, 7, 11, 15..." description = "Gebruik n in formules voor patronen." title = "Wiskundige functies" @@ -1926,7 +1954,7 @@ valuePlaceholder = "Aangepaste waarde" remove = "Verwijderen" [changeMetadata.results] -title = "Bijgewerkte PDF's" +title = "Bijgewerkte PDF-en" [changeMetadata.error] failed = "Er is een fout opgetreden bij het wijzigen van de PDF-metadata." @@ -2068,7 +2096,7 @@ title = "Geavanceerde OCR-verwerking" [ocr.tooltip.advanced.compatibility] title = "Compatibiliteitsmodus" -text = "Gebruikt OCR-'sandwich PDF'-modus: resulteert in grotere bestanden, maar betrouwbaarder met bepaalde talen en oudere PDF-software. Standaard gebruiken we hOCR voor kleinere, moderne PDF's." +text = "Gebruikt OCR-'sandwich PDF'-modus: resulteert in grotere bestanden, maar betrouwbaarder met bepaalde talen en oudere PDF-software. Standaard gebruiken we hOCR voor kleinere, moderne PDF-en." [ocr.tooltip.advanced.sidecar] title = "Tekstbestand maken" @@ -2095,7 +2123,7 @@ title = "Afbeeldingen extraheren" header = "Afbeeldingen extraheren" selectText = "Selecteer het beeldformaat voor geëxtraheerde afbeeldingen" allowDuplicates = "Dubbele afbeeldingen opslaan" -submit = "Extraheer" +submit = "Extraheren" [extractImages.settings] title = "Instellingen" @@ -2205,7 +2233,7 @@ headsUpDesc = "Overlappende foto's of achtergronden die qua kleur erg op de foto [sign] title = "Ondertekenen" -header = "PDF's ondertekenen" +header = "PDF-en ondertekenen" upload = "Upload afbeelding" clear = "Wissen" add = "Toevoegen" @@ -2233,9 +2261,9 @@ title = "Teken uw handtekening" clear = "Wissen" [sign.canvas] -heading = "Teken je handtekening" +heading = "Teken uw handtekening" clickToOpen = "Klik om het tekenvenster te openen" -modalTitle = "Teken je handtekening" +modalTitle = "Teken uw handtekening" colorLabel = "Kleur" penSizeLabel = "Pendikte" penSizePlaceholder = "Grootte" @@ -2256,7 +2284,7 @@ description = "Gebruik opgeslagen handtekeningen op elk moment opnieuw." emptyTitle = "Nog geen opgeslagen handtekeningen" emptyDescription = "Teken, upload of typ hierboven een handtekening en kies vervolgens 'Opslaan in bibliotheek' om tot {{max}} favorieten paraat te hebben." limitTitle = "Limiet bereikt" -limitDescription = "Verwijder een opgeslagen handtekening voordat je nieuwe toevoegt (max. {{max}})." +limitDescription = "Verwijder een opgeslagen handtekening voordat u nieuwe toevoegt (max. {{max}})." carouselPosition = "{{current}} van {{total}}" prev = "Vorige" next = "Volgende" @@ -2272,7 +2300,7 @@ saveShared = "Als gedeeld opslaan" saveUnavailable = "Maak eerst een handtekening om deze op te slaan." noChanges = "De huidige handtekening is al opgeslagen." tempStorageTitle = "Tijdelijke browseropslag" -tempStorageDescription = "Handtekeningen worden alleen in je browser opgeslagen. Ze gaan verloren als je je browsergegevens wist of van browser wisselt." +tempStorageDescription = "Handtekeningen worden alleen in de browser opgeslagen. Ze gaan verloren als u de browsergegevens wist of van browser wisselt." personalHeading = "Persoonlijke handtekeningen" sharedHeading = "Gedeelde handtekeningen" personalDescription = "Alleen jij kunt deze handtekeningen zien." @@ -2290,9 +2318,9 @@ saved = "Opgeslagen" configure = "Handtekening configureren" [sign.step] -createDesc = "Kies hoe je de handtekening wilt maken" +createDesc = "Kies hoe u de handtekening wilt maken" place = "Plaatsen en opslaan" -placeDesc = "Plaats de handtekening op je PDF" +placeDesc = "Plaats de handtekening op uw PDF" [sign.type] title = "Type handtekening" @@ -2314,7 +2342,7 @@ image = "Na het uploaden van uw handtekeningafbeelding hierboven, klik ergens op saved = "Selecteer hierboven een opgeslagen handtekening en klik vervolgens ergens op de PDF om deze te plaatsen." text = "Na het invoeren van uw naam hierboven, klik ergens op de PDF om uw handtekening te plaatsen." paused = "Plaatsing gepauzeerd" -resumeHint = "Hervat de plaatsing om te klikken en je handtekening toe te voegen." +resumeHint = "Hervat de plaatsing om te klikken en uw handtekening toe te voegen." noSignature = "Maak hierboven een handtekening om de plaatsingstools in te schakelen." [sign.mode] @@ -2331,7 +2359,7 @@ failed = "Er is een fout opgetreden bij het ondertekenen van de PDF." [flatten] title = "Afvlakken" -header = "PDF's afvlakken" +header = "PDF-en afvlakken" flattenOnlyForms = "Alleen formulieren afvlakken" submit = "Afvlakken" filenamePrefix = "afgevlakt" @@ -2358,7 +2386,7 @@ title = "Resultaten afvlakken" failed = "Er is een fout opgetreden bij het afvlakken van de PDF." [flatten.tooltip.header] -title = "Over PDF's afvlakken" +title = "Over PDF-en afvlakken" [flatten.tooltip.description] title = "Wat doet afvlakken?" @@ -2379,7 +2407,7 @@ bullet4 = "Bladwijzers helpen u nog steeds te navigeren" [repair] tags = "repareren,herstellen,correctie,terughalen" title = "Repareren" -header = "PDF's repareren" +header = "PDF-en repareren" submit = "Repareren" description = "Deze tool probeert corrupte of beschadigde PDF-bestanden te repareren. Er zijn geen extra instellingen nodig." filenamePrefix = "gerepareerd" @@ -2466,17 +2494,17 @@ failed = "Er is een fout opgetreden bij het verwijderen van aantekeningen uit de [compare] tags = "onderscheiden,contrasteren,veranderingen,analyse" title = "Vergelijken" -header = "PDF's vergelijken" +header = "PDF-en vergelijken" clearSelected = "Selectie wissen" -addFilesHint = "Voeg PDF's toe in de stap Bestanden om selectie mogelijk te maken." -noFiles = "Nog geen PDF's beschikbaar" +addFilesHint = "Voeg PDF-en toe in de stap Bestanden om selectie mogelijk te maken." +noFiles = "Nog geen PDF-en beschikbaar" pages = "Pagina's" cta = "Vergelijken" loading = "Bezig met vergelijken..." newLine = "nieuwe-regel" [compare.clear] -confirmTitle = "Geselecteerde PDF's wissen?" +confirmTitle = "Geselecteerde PDF-en wissen?" confirmBody = "Dit sluit de huidige vergelijking en brengt u terug naar Actieve bestanden." confirm = "Wissen en terugkeren" @@ -2495,7 +2523,7 @@ label = "Bewerkt document" placeholder = "Selecteer de bewerkte PDF" [compare.selection] -originalEditedTitle = "Originele en bewerkte PDF's selecteren" +originalEditedTitle = "Originele en bewerkte PDF-en selecteren" [compare.original] label = "Originele PDF" @@ -2517,7 +2545,7 @@ pageLabel = "Pagina" pageNotReadyTitle = "Pagina nog niet gerenderd" pageNotReadyBody = "Sommige pagina's worden nog gerenderd. De navigatie klikt vast zodra ze gereed zijn." rendering = "renderen" -inProgress = "Ten minste een van deze PDF's is zeer groot, scrollen verloopt niet soepel totdat het renderen is voltooid" +inProgress = "Ten minste een van deze PDF-en is zeer groot, scrollen verloopt niet soepel totdat het renderen is voltooid" pagesRendered = "pagina's gerenderd" complete = "Renderen voltooid" @@ -2556,10 +2584,10 @@ complete = "Vergelijking gereed" [compare.longJob] title = "Grote vergelijking bezig" -body = "Deze PDF's overschrijden samen 2,000 pagina's. De verwerking kan enkele minuten duren." +body = "Deze PDF-en overschrijden samen 2,000 pagina's. De verwerking kan enkele minuten duren." [compare.slowOperation] -title = "Nog bezig…" +title = "Nog bezig..." body = "Deze vergelijking duurt langer dan normaal. U kunt het laten doorgaan of annuleren." cancel = "Vergelijking annuleren" @@ -2576,7 +2604,7 @@ message = "Een of beide geselecteerde PDF-bestanden bevatten geen tekstinhoud. K message = "Deze documenten lijken sterk van elkaar te verschillen. De vergelijking is gestopt om tijd te besparen." [compare.earlyDissimilarity] -title = "Deze PDF's verschillen sterk" +title = "Deze PDF-en verschillen sterk" body = "We zien tot nu toe zeer weinig overeenkomsten. U kunt de vergelijking stoppen als dit geen verwante documenten zijn." stopButton = "Vergelijking stoppen" @@ -2594,7 +2622,7 @@ logoTitle = "Logo" name = "Naam" noLogo = "Geen logo" pageNumber = "Paginanummer" -password = "Voer je sleutelopslag of privésleutel wachtwoord in (indien van toepassing):" +password = "Voer de sleutelopslag of privésleutel wachtwoord in (indien van toepassing):" passwordOptional = "Leeg laten als er geen wachtwoord is" reason = "Reden" serverCertMessage = "Servercertificaat gebruiken - geen bestanden of wachtwoord nodig" @@ -2668,7 +2696,7 @@ title = "Over het beheren van handtekeningen" [certSign.tooltip.overview] title = "Wat kan deze tool doen?" -text = "Met deze tool kunt u controleren of uw PDF's digitaal zijn ondertekend en nieuwe digitale handtekeningen toevoegen. Digitale handtekeningen bewijzen wie een document heeft gemaakt of goedgekeurd en tonen of het sinds de ondertekening is gewijzigd." +text = "Met deze tool kunt u controleren of uw PDF-en digitaal zijn ondertekend en nieuwe digitale handtekeningen toevoegen. Digitale handtekeningen bewijzen wie een document heeft gemaakt of goedgekeurd en tonen of het sinds de ondertekening is gewijzigd." bullet1 = "Controleer bestaande handtekeningen en hun geldigheid" bullet2 = "Bekijk gedetailleerde informatie over ondertekenaars en certificaten" bullet3 = "Voeg nieuwe digitale handtekeningen toe om uw documenten te beveiligen" @@ -2701,7 +2729,7 @@ text = "Het is een veilige ID voor uw handtekening die bewijst dat u hebt ondert [certSign.certType.tooltip.which] title = "Welke optie moet ik gebruiken?" text = "Kies de indeling die overeenkomt met uw certificaatbestand:" -bullet1 = "PKCS#12 (.p12 / .pfx) – één gecombineerd bestand (meest voorkomend)" +bullet1 = "PKCS#12 (.p12 / .pfx) – een gecombineerd bestand (meest voorkomend)" bullet2 = "PFX (.pfx) – Microsofts versie van PKCS12" bullet3 = "PEM – aparte .pem-bestanden voor privésleutel en certificaat" bullet4 = "JKS – Java .jks-keystore voor dev-/CI-CD-workflows" @@ -2956,7 +2984,7 @@ failed = "PDF bijsnijden mislukt" selectArea = "Selecteer bijsnijgebied" [crop.tooltip] -title = "Hoe PDF's bijsnijden" +title = "PDF-en bijsnijden" description = "Selecteer het gebied om bij te snijden in uw PDF door de blauwe overlay op de miniatuur te slepen en te schalen." drag = "Sleep de overlay om het bijsnijgebied te verplaatsen" resize = "Sleep aan de hoek- en randgrepen om te schalen" @@ -2966,14 +2994,14 @@ precision = "Gebruik coördinatenvelden voor nauwkeurige positionering" title = "Resultaten bijsnijden" [crop.automation] -info = "Voer bijsnijcoördinaten in PDF-punten in. Oorsprong (0,0) bevindt zich linksonder. Deze waarden worden toegepast op alle PDF's die in deze automatisering worden verwerkt." +info = "Voer bijsnijcoördinaten in PDF-punten in. Oorsprong (0,0) bevindt zich linksonder. Deze waarden worden toegepast op alle PDF-en die in deze automatisering worden verwerkt." reference = "Referentie: A4-pagina is 595,28 × 841,89 punten (210mm × 297mm). 1 inch = 72 punten." [autoSplitPDF] tags = "QR-gebaseerd,scheiden,scan-segment,organiseren" title = "PDF automatisch splitsen" header = "PDF automatisch splitsen" -description = "Print, Voeg in, Scan, upload, en laat ons je documenten automatisch scheiden. Geen handmatig sorteerwerk nodig." +description = "Print, Voeg in, Scan, upload, en laat ons uw documenten automatisch scheiden. Geen handmatig sorteerwerk nodig." formPrompt = "Dien PDF in met Stirling-PDF Pagina-scheiders:" duplexMode = "Duplex Modus (voor- en achterkant scannen)" dividerDownload2 = "Download 'Auto Splitter Divider (with instructions).pdf'" @@ -2981,7 +3009,7 @@ submit = "Indienen" [autoSplitPDF.selectText] 1 = "Print enkele scheidingsbladen van hieronder (Zwart-wit is prima)." -2 = "Scan al je documenten tegelijk door het scheidingsblad ertussen te plaatsen." +2 = "Scan al uw documenten tegelijk door het scheidingsblad ertussen te plaatsen." 3 = "Upload het enkele grote gescande PDF-bestand en laat Stirling PDF de rest afhandelen." 4 = "Scheidingspagina's worden automatisch gedetecteerd en verwijderd, wat een net einddocument garandeert." @@ -3263,8 +3291,8 @@ tags = "pdf,splitsen,document,organiseren" [overlay-pdfs] tags = "Overlappen" header = "PDF bestanden overlappen" -title = "PDF's over elkaar leggen" -desc = "Leg één PDF over een andere heen" +title = "PDF-en over elkaar leggen" +desc = "Leg een PDF over een andere heen" submit = "Indienen" [overlay-pdfs.baseFile] @@ -3273,7 +3301,7 @@ label = "Selecteer basis PDF-bestand" [overlay-pdfs.overlayFiles] label = "Selecteer overlappende PDF-bestanden" placeholder = "Kies PDF('s)..." -addMore = "Meer PDF's toevoegen..." +addMore = "Meer PDF-en toevoegen..." [overlay-pdfs.mode] label = "Selecteer overlappingsmodus" @@ -3299,11 +3327,11 @@ title = "Instellingen" title = "Resultaten overlay" [overlay-pdfs.tooltip.header] -title = "Overzicht PDF's over elkaar leggen" +title = "Overzicht PDF-en over elkaar leggen" [overlay-pdfs.tooltip.description] title = "Beschrijving" -text = "Combineer een basis-PDF met een of meer overlay-PDF's. Overlays kunnen pagina-voor-pagina in verschillende modi worden toegepast en in de voorgrond of achtergrond worden geplaatst." +text = "Combineer een basis-PDF met een of meer overlay-PDF-en. Overlays kunnen pagina-voor-pagina in verschillende modi worden toegepast en in de voorgrond of achtergrond worden geplaatst." [overlay-pdfs.tooltip.mode] title = "Overlaymodus" @@ -3318,21 +3346,21 @@ text = "Voorgrond plaatst de overlay boven op de pagina. Achtergrond plaatst dez [overlay-pdfs.tooltip.overlayFiles] title = "Overlay-bestanden" -text = "Selecteer een of meer PDF's om op de basis te leggen. De volgorde van deze bestanden beïnvloedt hoe pagina's worden toegepast in Sequentiële en Vaste herhalingsmodus." +text = "Selecteer een of meer PDF-en om op de basis te leggen. De volgorde van deze bestanden beïnvloedt hoe pagina's worden toegepast in Sequentiële en Vaste herhalingsmodus." [overlay-pdfs.tooltip.counts] title = "Aantallen (alleen Vaste herhaling)" text = "Geef een positief getal op voor elk overlay-bestand dat aangeeft hoeveel pagina's moeten worden genomen voordat wordt doorgeschakeld. Vereist wanneer de modus Vaste herhaling is." [overlay-pdfs.error] -failed = "Er is een fout opgetreden bij het over elkaar leggen van PDF's." +failed = "Er is een fout opgetreden bij het over elkaar leggen van PDF-en." [split-by-sections] tags = "Sectie splitsen, Verdelen, Aanpassen" title = "PDF in secties splitsen" header = "PDF in secties splitsen" submit = "PDF splitsen" -merge = "Samenvoegen in één PDF" +merge = "Samenvoegen in een PDF" [split-by-sections.horizontal] label = "Horizontale secties" @@ -3533,7 +3561,7 @@ signInWith = "Inloggen met" signInAnonymously = "Als gast aanmelden" rememberme = "Onthoud mij" invalid = "Ongeldige gebruikersnaam of wachtwoord." -locked = "Je account is geblokkeerd." +locked = "Uw account is geblokkeerd." sessionExpired = "Uw sessie is verlopen. Log alstublieft opnieuw in." signinTitle = "Gelieve in te loggen" ssoSignIn = "Inloggen via Single Sign-on" @@ -3570,7 +3598,7 @@ login = "Inloggen" or = "Of" useMagicLink = "Gebruik in plaats daarvan een magic link" enterEmailForMagicLink = "Voer uw e-mailadres in voor een magic link" -sending = "Verzenden…" +sending = "Verzenden..." sendMagicLink = "Magic link verzenden" cancel = "Annuleren" dontHaveAccount = "Nog geen account? Registreer" @@ -3591,17 +3619,17 @@ changePasswordWarning = "Wijzig uw wachtwoord nadat u voor het eerst bent ingelo [login.slides.overview] alt = "Stirling PDF-overzicht" -title = "Uw alles-in-één oplossing voor al uw PDF-behoeften." -subtitle = "Een privacy-first cloud suite voor PDF's waarmee u documenten kunt converteren, ondertekenen, anonimiseren en beheren, plus 50+ andere krachtige tools." +title = "Uw alles-in-een oplossing voor al uw PDF-behoeften." +subtitle = "Een privacy-first cloud suite voor PDF-en waarmee u documenten kunt converteren, ondertekenen, anonimiseren en beheren, plus 50+ andere krachtige tools." [login.slides.edit] -alt = "PDF's bewerken" -title = "Bewerk PDF's om de gewenste informatie te tonen/beveiligen" -subtitle = "Met meer dan een dozijn tools om PDF's te anonimiseren, ondertekenen, lezen en bewerken vindt u zeker wat u zoekt." +alt = "PDF-en bewerken" +title = "Bewerk PDF-en om de gewenste informatie te tonen/beveiligen" +subtitle = "Met meer dan een dozijn tools om PDF-en te anonimiseren, ondertekenen, lezen en bewerken vindt u zeker wat u zoekt." [login.slides.secure] -alt = "PDF's beveiligen" -title = "Bescherm gevoelige informatie in uw PDF's" +alt = "PDF-en beveiligen" +title = "Bescherm gevoelige informatie in uw PDF-en" subtitle = "Voeg wachtwoorden toe, anonimiseer inhoud en beheer certificaten met gemak." [signup] @@ -3636,7 +3664,7 @@ confirmPasswordRequired = "Bevestig uw wachtwoord" title = "PDF naar enkele pagina" header = "PDF naar enkele pagina" submit = "Converteren naar enkele pagina" -description = "Deze tool voegt alle pagina's van uw PDF samen tot één grote enkele pagina. De breedte blijft hetzelfde als de oorspronkelijke pagina's, maar de hoogte wordt de som van alle paginahoogten." +description = "Deze tool voegt alle pagina's van uw PDF samen tot een grote enkele pagina. De breedte blijft hetzelfde als de oorspronkelijke pagina's, maar de hoogte wordt de som van alle paginahoogten." filenamePrefix = "enkele_pagina" [pdfToSinglePage.files] @@ -3671,7 +3699,7 @@ submit = "PDF opschonen" title = "Contrast aanpassen" header = "Contrast aanpassen" basic = "Basisaanpassingen" -contrast = "Kehrbrechting:" +contrast = "Contrast:" brightness = "Helderheid:" saturation = "Verzadiging:" download = "Downloaden" @@ -3690,7 +3718,7 @@ title = "Aangepaste PDF" [compress] title = "Comprimeren" -desc = "Comprimeer PDF's om de bestandsgrootte te verkleinen." +desc = "Comprimeer PDF-en om de bestandsgrootte te verkleinen." header = "PDF comprimeren" credit = "Deze functie gebruikt qpdf voor PDF Compressie/Optimalisatie." submit = "Comprimeren" @@ -3701,7 +3729,17 @@ quality = "Kwaliteit" filesize = "Bestandsgrootte" [compress.grayscale] -label = "Grijsschaal toepassen voor compressie" +label = "Grijstinten toepassen voor compressie" + +[compress.lineArt] +label = "Afbeeldingen omzetten in lijntekening" +description = "Gebruikt ImageMagick om pagina's te reduceren tot hoogcontrast zwart-wit voor maximale verkleining van de bestandsgrootte." +unavailable = "ImageMagick is niet geïnstalleerd of ingeschakeld op deze server" +detailLevel = "Detailniveau" +edgeEmphasis = "Randaccent" +edgeLow = "Zacht" +edgeMedium = "Gebalanceerd" +edgeHigh = "Sterk" [compress.tooltip.header] title = "Overzicht compressie-instellingen" @@ -3718,7 +3756,11 @@ bullet2 = "Hogere waarden verminderen de bestandsgrootte" [compress.tooltip.grayscale] title = "Grijstinten" -text = "Selecteer deze optie om alle afbeeldingen naar zwart-wit te converteren, wat de bestandsgrootte aanzienlijk kan verminderen, vooral voor gescande PDF's of documenten met veel afbeeldingen." +text = "Selecteer deze optie om alle afbeeldingen naar zwart-wit te converteren, wat de bestandsgrootte aanzienlijk kan verminderen, vooral voor gescande PDF-en of documenten met veel afbeeldingen." + +[compress.tooltip.lineArt] +title = "Lijntekening" +text = "Zet pagina's om naar hoogcontrast zwart-wit met ImageMagick. Gebruik het detailniveau om te bepalen hoeveel inhoud zwart wordt, en randaccentuering om te controleren hoe agressief randen worden gedetecteerd." [compress.error] failed = "Er is een fout opgetreden bij het comprimeren van de PDF." @@ -3765,7 +3807,7 @@ maintainAspectRatio = "Beeldverhoudingen behouden" 2 = "PDF automatisch draaien" 3 = "Meervoudige bestandslogica (Alleen ingeschakeld bij werken met meerdere afbeeldingen)" 4 = "Voeg samen in één PDF" -5 = "Zet om naar afzonderlijke PDF's" +5 = "Zet om naar afzonderlijke PDF-en" [PDFToCSV] title = "PDF naar CSV" @@ -3817,13 +3859,13 @@ button = "Vul enquête in." dontShowAgain = "Niet weer tonen" [survey.meeting] -1 = "Als je Stirling PDF op het werk gebruikt, spreken we je graag. We bieden technische supportsessies aan in ruil voor een gebruikersgesprek van 15 minuten." +1 = "Als u Stirling PDF op het werk gebruikt, spreken we u graag. We bieden technische supportsessies aan in ruil voor een gebruikersgesprek van 15 minuten." 2 = "Dit is een kans om:" 3 = "Hulp te krijgen bij deployment, integraties of troubleshooting" 4 = "Direct feedback te geven over performance, edge-cases en ontbrekende functies" 5 = "Ons te helpen Stirling PDF te verfijnen voor gebruik in echte enterprise-omgevingen" -6 = "Als je geïnteresseerd bent, kun je direct tijd met ons team boeken. (Alleen Engelstalig)" -7 = "We kijken ernaar uit om jouw gebruiksscenario's te bespreken en Stirling PDF nog beter te maken!" +6 = "Als u geïnteresseerd bent, kunt u direct tijd met ons team boeken. (Alleen Engelstalig)" +7 = "We kijken ernaar uit om uw gebruiksscenario's te bespreken en Stirling PDF nog beter te maken!" notInterested = "Geen bedrijf en/of geen interesse in een afspraak?" button = "Afspraak boeken" @@ -3848,9 +3890,9 @@ allowDuplicates = "Dubbele items toestaan" submit = "PDF splitsen" [splitByChapters.desc] -1 = "Dit hulpmiddel splits een PDF-bestand op in meerdere PDF's gebaseerd op zijn hoofdstukstructuur." +1 = "Dit hulpmiddel splits een PDF-bestand op in meerdere PDF-en gebaseerd op zijn hoofdstukstructuur." 2 = "Boekmarkeer niveau: Kies het boekmarkeer niveau om te gebruiken voor delen (0 voor topniveau, 1 voor tweedelvou, etc.)." -3 = "Metadata inclusief: Als gecijfeld, de originele PDF's metadata wordt ingevoegd in elk gesplitst PDF-bestand." +3 = "Metadata inclusief: Als gecijfeld, de originele PDF-en metadata wordt ingevoegd in elk gesplitst PDF-bestand." 4 = "Dubbele items toestaan: Als gecijfeld, zorgen multiple boekmarkeersymboolen op dezelfde pagina voor het maken van aparte PDF-bestanden." [fileChooser] @@ -3884,7 +3926,7 @@ acceptNecessaryBtn = "Nee, bedankt" showPreferencesBtn = "Voorkeuren beheren" [cookieBanner.popUp.description] -1 = "We gebruiken cookies en andere technologieën om Stirling PDF beter voor je te laten werken—zo verbeteren we onze tools en blijven we functies bouwen die je waardeert." +1 = "We gebruiken cookies en andere technologieën om Stirling PDF beter voor u te laten werken—zo verbeteren we onze tools en blijven we functies bouwen die u waardeert." 2 = "If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." [cookieBanner.preferencesModal] @@ -3897,9 +3939,9 @@ serviceCounterLabel = "Dienst|Diensten" subtitle = "Cookiegebruik" [cookieBanner.preferencesModal.description] -1 = "Stirling PDF gebruikt cookies en vergelijkbare technologieën om je ervaring te verbeteren en te begrijpen hoe onze tools worden gebruikt. Dit helpt ons de prestaties te verbeteren, de functies te ontwikkelen die jij belangrijk vindt en doorlopende ondersteuning te bieden." -2 = "Stirling PDF kan niet—en zal nooit—de inhoud van de documenten die je gebruikt volgen of openen." -3 = "Jouw privacy en vertrouwen staan centraal in wat we doen." +1 = "Stirling PDF gebruikt cookies en vergelijkbare technologieën om uw ervaring te verbeteren en te begrijpen hoe onze tools worden gebruikt. Dit helpt ons de prestaties te verbeteren, de functies te ontwikkelen die jij belangrijk vindt en doorlopende ondersteuning te bieden." +2 = "Stirling PDF kan niet—en zal nooit—de inhoud van de documenten die u gebruikt volgen of openen." +3 = "Uw privacy en vertrouwen staan centraal in wat we doen." [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." @@ -3910,7 +3952,7 @@ description = "These cookies are essential for the website to function properly. [cookieBanner.preferencesModal.analytics] title = "Analyse" -description = "Deze cookies helpen ons te begrijpen hoe onze tools worden gebruikt, zodat we ons kunnen richten op het bouwen van de functies die onze community het meest waardeert. Wees gerust—Stirling PDF kan niet en zal nooit de inhoud van de documenten waarmee je werkt volgen." +description = "Deze cookies helpen ons te begrijpen hoe onze tools worden gebruikt, zodat we ons kunnen richten op het bouwen van de functies die onze community het meest waardeert. Wees gerust—Stirling PDF kan niet en zal nooit de inhoud van de documenten waarmee u werkt volgen." [cookieBanner.services] posthog = "PostHog Analytics" @@ -3996,8 +4038,8 @@ noResults = "Geen resultaten gevonden" searching = "Zoeken..." [guestBanner] -title = "Je gebruikt Stirling PDF als gast!" -message = "Maak een gratis account aan om je werk op te slaan, meer functies te gebruiken en het project te steunen." +title = "U gebruikt Stirling PDF als gast!" +message = "Maak een gratis account aan om uw werk op te slaan, meer functies te gebruiken en het project te steunen." dismiss = "Banner sluiten" signUp = "Gratis aanmelden" @@ -4038,12 +4080,20 @@ settings = "Opties" adminSettings = "Beheer" allTools = "All Tools" reader = "Reader" +tours = "Tours" +showMeAround = "Leid me rond" + +[quickAccess.toursTooltip] +admin = "Bekijk hier rondleidingen: Tools-tour, nieuwe V2-indeling tour en de Admin-tour." +user = "Bekijk hier rondleidingen: Ronde langs de tools en de nieuwe V2-indeling." [quickAccess.helpMenu] toolsTour = "Rondleiding tools" toolsTourDesc = "Leer wat de tools kunnen" adminTour = "Rondleiding voor beheer" adminTourDesc = "Ontdek beheerinstellingen en functies" +whatsNewTour = "Bekijk wat er nieuw is in V2" +whatsNewTourDesc = "Verken de vernieuwde layout" [admin] error = "Fout" @@ -4079,13 +4129,13 @@ hint = "U hebt niet-opgeslagen wijzigingen" [admin.settings.loginDisabled] title = "Inlogmodus vereist" -message = "Inlogmodus moet zijn ingeschakeld om beheerinstellingen te wijzigen. Stel SECURITY_ENABLELOGIN=true in je omgeving of security.enableLogin: true in settings.yml in, start daarna de server opnieuw." +message = "Inlogmodus moet zijn ingeschakeld om beheerinstellingen te wijzigen. Stel SECURITY_ENABLELOGIN=true in uw omgeving of security.enableLogin: true in settings.yml in, start daarna de server opnieuw." readOnly = "De onderstaande instellingen tonen voorbeeldwaarden ter referentie. Schakel de inlogmodus in om de werkelijke configuratie te bekijken en te bewerken." [admin.settings.restart] title = "Herstart vereist" message = "Instellingen zijn succesvol opgeslagen. Een herstart van de server is vereist om de wijzigingen door te voeren." -question = "Wil je de server nu of later herstarten?" +question = "Wilt u de server nu of later herstarten?" now = "Nu herstarten" later = "Later herstarten" @@ -4168,11 +4218,11 @@ label = "Pipelinemappen" [admin.settings.general.customPaths.pipeline.watchedFoldersDir] label = "Map met bewaakte mappen" -description = "Map waarin de pipeline inkomende PDF's bewaakt (laat leeg voor standaard: /pipeline/watchedFolders)" +description = "Map waarin de pipeline inkomende PDF-en bewaakt (laat leeg voor standaard: /pipeline/watchedFolders)" [admin.settings.general.customPaths.pipeline.finishedFoldersDir] label = "Map met voltooide mappen" -description = "Map waar verwerkte PDF's worden weggeschreven (laat leeg voor standaard: /pipeline/finishedFolders)" +description = "Map waar verwerkte PDF-en worden weggeschreven (laat leeg voor standaard: /pipeline/finishedFolders)" [admin.settings.general.customPaths.operations] label = "Paden naar externe tools" @@ -4344,11 +4394,11 @@ description = "De issuer-URL van de OAuth2-provider" [admin.settings.connections.oauth2.clientId] label = "Client-ID" -description = "De OAuth2 client-ID van je provider" +description = "De OAuth2 client-ID van uw provider" [admin.settings.connections.oauth2.clientSecret] label = "Clientgeheim" -description = "Het OAuth2 clientgeheim van je provider" +description = "Het OAuth2 clientgeheim van uw provider" [admin.settings.connections.oauth2.useAsUsername] label = "Gebruik als gebruikersnaam" @@ -4396,7 +4446,7 @@ configuration = "Databaseconfiguratie" [admin.settings.database.enableCustom] label = "Aangepaste database inschakelen" -description = "Gebruik je eigen aangepaste databaseconfiguratie in plaats van de standaard ingesloten database" +description = "Gebruik uw eigen aangepaste databaseconfiguratie in plaats van de standaard ingesloten database" [admin.settings.database.customUrl] label = "Aangepaste database-URL" @@ -4544,7 +4594,7 @@ description = "E-mailmeldingen en SMTP-functionaliteit inschakelen" [admin.settings.mail.host] label = "SMTP-host" -description = "De hostnaam of het IP-adres van je SMTP-server" +description = "De hostnaam of het IP-adres van uw SMTP-server" [admin.settings.mail.port] label = "SMTP-poort" @@ -4568,7 +4618,7 @@ description = "Sta beheerders toe om gebruikers via e-mail uit te nodigen met au [admin.settings.mail.frontendUrl] label = "Frontend-URL" -description = "Basis-URL voor de frontend (bijv. https://pdf.example.com). Wordt gebruikt voor het genereren van uitnodigingslinks in e-mails. Laat leeg om de backend-URL te gebruiken." +description = "Basis-URL voor de frontend (bijv. https://pdf.example.com). Wordt gebruikt voor het genereren van uitnodigingslinks in e-mails. Laat leeg om de back-end-URL te gebruiken." [admin.settings.legal] title = "Juridische documenten" @@ -4576,7 +4626,7 @@ description = "Links naar juridische documenten en beleidsregels configureren." [admin.settings.legal.disclaimer] title = "Waarschuwing juridische verantwoordelijkheid" -message = "Door deze juridische documenten aan te passen, neem je de volledige verantwoordelijkheid voor conformiteit met alle toepasselijke wetten en voorschriften, inclusief maar niet beperkt tot de GDPR en andere EU-vereisten voor gegevensbescherming. Wijzig deze instellingen alleen als: (1) je een persoonlijke/private instantie beheert, (2) je buiten de EU-jurisdictie valt en je lokale wettelijke verplichtingen begrijpt, of (3) je passend juridisch advies hebt ingewonnen en volledige verantwoordelijkheid aanvaardt voor alle gebruikersgegevens en juridische naleving. Stirling-PDF en zijn ontwikkelaars aanvaarden geen aansprakelijkheid voor jouw wettelijke verplichtingen." +message = "Door deze juridische documenten aan te passen, neemt u de volledige verantwoordelijkheid voor conformiteit met alle toepasselijke wetten en voorschriften, inclusief maar niet beperkt tot de GDPR en andere EU-vereisten voor gegevensbescherming. Wijzig deze instellingen alleen als: (1) u een persoonlijke/private instantie beheert, (2) u buiten de EU-jurisdictie valt en u lokale wettelijke verplichtingen begrijpt, of (3) u passend juridisch advies hebt ingewonnen en volledige verantwoordelijkheid aanvaardt voor alle gebruikersgegevens en juridische naleving. Stirling-PDF en zijn ontwikkelaars aanvaarden geen aansprakelijkheid voor uw wettelijke verplichtingen." [admin.settings.legal.termsAndConditions] label = "Algemene voorwaarden" @@ -4600,24 +4650,24 @@ description = "URL of bestandsnaam van het impressum (in sommige jurisdicties ve [admin.settings.premium] title = "Premium & Enterprise" -description = "Configureer je premium- of enterprise-licentiesleutel." +description = "Configureer uw premium- of enterprise-licentiesleutel." license = "Licentieconfiguratie" noInput = "Geef een licentiesleutel of bestand op" [admin.settings.premium.licenseKey] -toggle = "Heb je een licentiesleutel of certificaatbestand?" -info = "Als je een licentiesleutel of certificaatbestand hebt van een directe aankoop, kun je die hier invoeren om premium- of enterprisefuncties te activeren." +toggle = "Heeft u een licentiesleutel of certificaatbestand?" +info = "Als u een licentiesleutel of certificaatbestand hebt van een directe aankoop, kunt u die hier invoeren om premium- of enterprisefuncties te activeren." [admin.settings.premium.key] label = "Licentiesleutel" -description = "Voer je premium- of enterprise-licentiesleutel in" +description = "Voer uw premium- of enterprise-licentiesleutel in" success = "Licentiesleutel opgeslagen" -successMessage = "Je licentiesleutel is succesvol geactiveerd. Herstart is niet nodig." +successMessage = "Uw licentiesleutel is succesvol geactiveerd. Herstart is niet nodig." [admin.settings.premium.key.overwriteWarning] title = "⚠️ Waarschuwing: Bestaande licentie gedetecteerd" -line1 = "Het overschrijven van je huidige licentiesleutel kan niet ongedaan worden gemaakt." -line2 = "Je vorige licentie gaat permanent verloren, tenzij je er elders een back-up van hebt." +line1 = "Het overschrijven van uw huidige licentiesleutel kan niet ongedaan worden gemaakt." +line2 = "Uw vorige licentie gaat permanent verloren, tenzij u er elders een back-up van hebt." line3 = "Belangrijk: houd licentiesleutels privé en veilig. Deel ze nooit openbaar." [admin.settings.premium.inputMethod] @@ -4626,7 +4676,7 @@ file = "Certificaatbestand" [admin.settings.premium.file] label = "Licentiecertificaatbestand" -description = "Upload je .lic- of .cert-licentiebestand van offline aankopen" +description = "Upload uw .lic- of .cert-licentiebestand van offline aankopen" choose = "Kies licentiebestand" selected = "Geselecteerd: {{filename}} ({{size}})" successMessage = "Licentiebestand succesvol geüpload en geactiveerd. Herstarten niet vereist." @@ -4636,6 +4686,7 @@ title = "Actieve licentie" file = "Bron: licentiebestand ({{path}})" key = "Bron: licentiesleutel" type = "Type: {{type}}" + noInput = "Geef een licentiesleutel op of upload een certificaatbestand" success = "Succes" @@ -4691,7 +4742,7 @@ selectFiles = "Selecteer bestanden" selectPdfToView = "Selecteer een PDF om te bekijken" selectPdfToEdit = "Selecteer een PDF om te bewerken" chooseFromStorage = "Kies een bestand uit opslag of upload een nieuwe PDF" -chooseFromStorageMultiple = "Kies bestanden uit opslag of upload nieuwe PDF's" +chooseFromStorageMultiple = "Kies bestanden uit opslag of upload nieuwe PDF-en" loadFromStorage = "Laden vanuit opslag" filesAvailable = "bestanden beschikbaar" loading = "Laden..." @@ -4719,7 +4770,7 @@ addFiles = "Bestanden toevoegen" [fileManager] title = "PDF-bestanden uploaden" -subtitle = "Voeg bestanden toe aan je opslag voor gemakkelijke toegang in alle tools" +subtitle = "Voeg bestanden toe aan uw opslag voor gemakkelijke toegang in alle tools" filesSelected = "bestanden geselecteerd" clearSelection = "Selectie wissen" openInFileEditor = "Openen in bestandseditor" @@ -4778,7 +4829,7 @@ closeFile = "Bestand sluiten" deleteAll = "Alles verwijderen" loadingFiles = "Bestanden laden..." noFiles = "Geen bestanden beschikbaar" -noFilesFound = "Geen bestanden gevonden die overeenkomen met je zoekopdracht" +noFilesFound = "Geen bestanden gevonden die overeenkomen met uw zoekopdracht" openInPageEditor = "Openen in Pagina-editor" showAll = "Alles weergeven" sortByDate = "Sorteren op datum" @@ -4786,12 +4837,12 @@ sortByName = "Sorteren op naam" sortBySize = "Sorteren op grootte" [storage] -temporaryNotice = "Bestanden worden tijdelijk opgeslagen in je browser en kunnen automatisch worden gewist" +temporaryNotice = "Bestanden worden tijdelijk opgeslagen in uw browser en kunnen automatisch worden gewist" storageLimit = "Opslaglimiet" storageUsed = "Tijdelijke opslag gebruikt" storageFull = "Opslag is bijna vol. Overweeg enkele bestanden te verwijderen." fileTooLarge = "Bestand te groot. Maximale grootte per bestand is" -storageQuotaExceeded = "Opslagquotum overschreden. Verwijder enkele bestanden voordat je meer uploadt." +storageQuotaExceeded = "Opslagquotum overschreden. Verwijder enkele bestanden voordat u meer uploadt." approximateSize = "Geschatte grootte" [sanitize] @@ -4816,7 +4867,7 @@ placeholder = "Selecteer een PDF-bestand in het hoofdscherm om te beginnen" [sanitize.options] title = "Opschoonopties" -note = "Selecteer de elementen die je uit de PDF wilt verwijderen. Er moet minstens één optie geselecteerd zijn." +note = "Selecteer de elementen die u uit de PDF wilt verwijderen. Er moet minstens één optie geselecteerd zijn." [sanitize.options.removeJavaScript] label = "JavaScript verwijderen" @@ -4844,7 +4895,7 @@ desc = "Ingesloten lettertypen uit de PDF verwijderen" [addPassword] title = "Wachtwoord toevoegen" -desc = "Versleutel je PDF-document met een wachtwoord." +desc = "Versleutel uw PDF-document met een wachtwoord." completed = "Wachtwoordbeveiliging toegepast" submit = "Versleutelen" filenamePrefix = "versleuteld" @@ -4871,14 +4922,14 @@ label = "Sleutellengte voor versleuteling" 256bit = "256-bit (Hoog)" [addPassword.results] -title = "Versleutelde PDF's" +title = "Versleutelde PDF-en" [addPassword.tooltip.header] title = "Overzicht wachtwoordbeveiliging" [addPassword.tooltip.passwords] title = "Wachtwoordtypen" -text = "Gebruikerswachtwoorden beperken het openen van het document, terwijl eigenaarwachtwoorden bepalen wat er met het document kan worden gedaan zodra het is geopend. Je kunt beide instellen of slechts één." +text = "Gebruikerswachtwoorden beperken het openen van het document, terwijl eigenaarwachtwoorden bepalen wat er met het document kan worden gedaan zodra het is geopend. U kunt beide instellen of slechts één." bullet1 = "Gebruikerswachtwoord: vereist om de PDF te openen" bullet2 = "Eigenaarwachtwoord: beheert documentmachtigingen (niet door alle PDF-viewers ondersteund)" @@ -4927,7 +4978,7 @@ label = "Voorkom afdrukken" label = "Voorkom afdrukken in verschillende formaten" [changePermissions.results] -title = "Aangepaste PDF's" +title = "Aangepaste PDF-en" [changePermissions.tooltip.header] title = "Rechten wijzigen" @@ -4940,7 +4991,7 @@ text = "Om deze machtigingen niet wijzigbaar te maken, gebruik de tool Wachtwoor [removePassword] title = "Wachtwoord verwijderen" -desc = "Verwijder wachtwoordbeveiliging van je PDF-document." +desc = "Verwijder wachtwoordbeveiliging van uw PDF-document." tags = "veilig,ontsleutelen,beveiliging,wachtwoord verwijderen" filenamePrefix = "ontsleuteld" submit = "Verwijderen" @@ -4958,7 +5009,7 @@ failed = "Er is een fout opgetreden bij het verwijderen van het wachtwoord uit d description = "Voor het verwijderen van wachtwoordbeveiliging is het wachtwoord nodig dat is gebruikt om de PDF te versleutelen. Dit zal het document ontsleutelen, waardoor het toegankelijk wordt zonder wachtwoord." [removePassword.results] -title = "Ontsleutelde PDF's" +title = "Ontsleutelde PDF-en" [automate] title = "Automatiseren" @@ -4985,7 +5036,7 @@ title = "Aanbevolen" [automate.creation] createTitle = "Automatisering maken" editTitle = "Automatisering bewerken" -intro = "Automatiseringen voeren tools opeenvolgend uit. Voeg tools toe in de volgorde waarin je ze wilt uitvoeren." +intro = "Automatiseringen voeren tools opeenvolgend uit. Voeg tools toe in de volgorde waarin u ze wilt uitvoeren." save = "Automatisering opslaan" [automate.creation.name] @@ -5007,7 +5058,7 @@ add = "Een tool toevoegen..." [automate.creation.unsavedChanges] title = "Niet-opgeslagen wijzigingen" -message = "Je hebt niet-opgeslagen wijzigingen. Weet je zeker dat je terug wilt gaan? Alle wijzigingen gaan verloren." +message = "U heeft niet-opgeslagen wijzigingen. Weet u zeker dat u terug wilt gaan? Alle wijzigingen gaan verloren." cancel = "Annuleren" confirm = "Teruggaan" @@ -5036,13 +5087,13 @@ save = "Configuratie opslaan" securePdfIngestion = "Beveiligde PDF-invoer" securePdfIngestionDesc = "Uitgebreide PDF-verwerkingsworkflow die documenten opschoont, OCR met opschoning toepast, converteert naar PDF/A-formaat voor langdurige archivering en de bestandsgrootte optimaliseert." emailPreparation = "E-mailvoorbereiding" -emailPreparationDesc = "Optimaliseert PDF's voor e-maildistributie door bestanden te comprimeren, grote documenten op te splitsen in 20MB-stukken voor e-mailcompatibiliteit en metagegevens te verwijderen voor privacy." +emailPreparationDesc = "Optimaliseert PDF-en voor e-maildistributie door bestanden te comprimeren, grote documenten op te splitsen in 20MB-stukken voor e-mailcompatibiliteit en metagegevens te verwijderen voor privacy." secureWorkflow = "Beveiligingsworkflow" secureWorkflowDesc = "Beveiligt PDF-documenten door mogelijk kwaadaardige inhoud zoals JavaScript en ingesloten bestanden te verwijderen, en voegt vervolgens wachtwoordbeveiliging toe om ongeautoriseerde toegang te voorkomen. Wachtwoord is standaard ingesteld op 'password'." processImages = "Afbeeldingen verwerken" processImagesDesc = "Converteert meerdere afbeeldingsbestanden naar één PDF-document en past vervolgens OCR toe om doorzoekbare tekst uit de afbeeldingen te extraheren." prePublishSanitization = "Opschonen vóór publicatie" -prePublishSanitizationDesc = "Opschoonworkflow die alle verborgen metadata, JavaScript, ingesloten bestanden en aantekeningen verwijdert en formulieren afvlakt om datalekken te voorkomen voordat PDF's online worden gepubliceerd." +prePublishSanitizationDesc = "Opschoonworkflow die alle verborgen metadata, JavaScript, ingesloten bestanden en aantekeningen verwijdert en formulieren afvlakt om datalekken te voorkomen voordat PDF-en online worden gepubliceerd." [colorPicker] title = "Kies kleur" @@ -5069,6 +5120,7 @@ loading = "Laden..." back = "Terug" continue = "Doorgaan" error = "Fout" +save = "Opslaan" [config.overview] title = "Applicatieconfiguratie" @@ -5085,30 +5137,30 @@ integration = "Integratieconfiguratie" [config.account.overview] title = "Accountinstellingen" -manageAccountPreferences = "Beheer je accountvoorkeuren" -guestDescription = "Je bent aangemeld als gast. Overweeg je account hierboven te upgraden." +manageAccountPreferences = "Accountvoorkeuren beheren" +guestDescription = "U bent aangemeld als gast. Overweeg uw account hierboven op te waarderen." [config.account.upgrade] -title = "Gastaccount upgraden" -description = "Koppel je account om je geschiedenis te behouden en toegang te krijgen tot meer functies!" -socialLogin = "Upgraden met sociaal account" +title = "Gastaccount opwaarderen" +description = "Koppel uw account om uw geschiedenis te behouden en toegang te krijgen tot meer functies!" +socialLogin = "Opwaarderen met sociaal account" linkWith = "Koppelen met" -emailPassword = "of voer je e-mailadres en wachtwoord in" +emailPassword = "of voer uw e-mailadres en wachtwoord in" email = "E-mailadres" -emailPlaceholder = "Voer je e-mailadres in" +emailPlaceholder = "Voer uw e-mailadres in" password = "Wachtwoord (optioneel)" passwordPlaceholder = "Stel een wachtwoord in" passwordNote = "Laat leeg om alleen e-mailverificatie te gebruiken" -upgradeButton = "Account upgraden" +upgradeButton = "Account opwaarderen" [config.apiKeys] -intro = "Gebruik je API-sleutel om programmatisch toegang te krijgen tot Stirling PDF's verwerkingsmogelijkheden." +intro = "Gebruik uw API-sleutel om programmatisch toegang te krijgen tot Stirling PDF-en verwerkingsmogelijkheden." docsTitle = "API-documentatie" docsDescription = "Leer meer over integreren met Stirling PDF:" docsLink = "API-documentatie" schemaLink = "API-schemareferentie" usage = "Neem deze sleutel op in de X-API-KEY-header bij alle API-aanvragen." -description = "Je API-sleutel voor toegang tot Stirling's suite van PDF-tools. Kopieer hem naar je project of vernieuw om een nieuwe te genereren." +description = "Uw API-sleutel voor toegang tot Stirling's suite van PDF-tools. Kopieer hem naar uw project of vernieuw om een nieuwe te genereren." publicKeyAriaLabel = "Publieke API-sleutel" copyKeyAriaLabel = "API-sleutel kopiëren" refreshAriaLabel = "API-sleutel vernieuwen" @@ -5118,22 +5170,22 @@ totalCredits = "Totale credits" chartAriaLabel = "Creditsgebruik: inbegrepen {{includedUsed}} van {{includedTotal}}, aangekocht {{purchasedUsed}} van {{purchasedTotal}}" nextReset = "Volgende reset" lastApiUse = "Laatste API-gebruik" -overlayMessage = "Genereer een sleutel om je gebruik en beschikbare credits te zien" +overlayMessage = "Genereer een sleutel om uw gebruik en beschikbare credits te zien" label = "API-sleutel" -guestInfo = "Gasten ontvangen geen API-sleutel. Maak een account aan om een API-sleutel te krijgen die je in je toepassingen kunt gebruiken." +guestInfo = "Gasten ontvangen geen API-sleutel. Maak een account aan om een API-sleutel te krijgen die u in uw toepassingen kunt gebruiken." goToAccount = "Naar Account" -generateError = "We konden je API-sleutel niet genereren." +generateError = "We konden uw API-sleutel niet genereren." [config.apiKeys.refreshModal] title = "API-sleutels vernieuwen" -warning = "⚠️ Waarschuwing: deze actie genereert nieuwe API-sleutels en maakt je vorige sleutels ongeldig." -impact = "Eventuele applicaties of services die deze sleutels momenteel gebruiken, werken niet meer totdat je ze bijwerkt met de nieuwe sleutels." -confirmPrompt = "Weet je zeker dat je wilt doorgaan?" +warning = "⚠️ Waarschuwing: deze actie genereert nieuwe API-sleutels en maakt uw vorige sleutels ongeldig." +impact = "Eventuele applicaties of services die deze sleutels momenteel gebruiken, werken niet meer totdat u ze bijwerkt met de nieuwe sleutels." +confirmPrompt = "Weet u zeker dat u wilt doorgaan?" confirmCta = "Sleutels vernieuwen" [AddAttachmentsRequest] attachments = "Bijlagen selecteren" -info = "Selecteer bestanden om aan je PDF toe te voegen. Deze bestanden worden ingesloten en zijn toegankelijk via het bijlagenpaneel van de PDF." +info = "Selecteer bestanden om aan uw PDF toe te voegen. Deze bestanden worden ingesloten en zijn toegankelijk via het bijlagenpaneel van de PDF." selectFiles = "Bestanden selecteren om bij te voegen" placeholder = "Kies bestanden..." addMoreFiles = "Meer bestanden toevoegen..." @@ -5212,33 +5264,43 @@ noTools = "Geen tools beschikbaar" [onboarding] allTools = "This is the All Tools panel, where you can browse and select from all available PDF tools." -selectCropTool = "Laten we de tool Bijsnijden selecteren om te laten zien hoe je een van de tools gebruikt." -toolInterface = "Dit is de interface van de tool Bijsnijden. Zoals je ziet is er nog niet veel, omdat we nog geen PDF-bestanden hebben toegevoegd om mee te werken." -filesButton = "Met de knop Bestanden op de sneltoegangsbalk kun je PDF's uploaden om de tools op toe te passen." -fileSources = "Je kunt hier nieuwe bestanden uploaden of recente bestanden openen. Voor de rondleiding gebruiken we een voorbeeldbestand." -workbench = "Dit is de Werkbank - het hoofdgebied waar je je PDF's bekijkt en bewerkt." -viewSwitcher = "Gebruik deze bedieningselementen om te kiezen hoe je je PDF's wilt bekijken." -viewer = "Met de Viewer kun je je PDF's lezen en annoteren." -pageEditor = "De Pagina-editor laat je verschillende bewerkingen uitvoeren op de pagina's in je PDF's, zoals herordenen, roteren en verwijderen." -activeFiles = "De weergave Actieve bestanden toont alle PDF's die je in de tool hebt geladen en laat je kiezen welke je wilt verwerken." -fileCheckbox = "Door op een van de bestanden te klikken selecteer je het voor verwerking. Je kunt meerdere bestanden selecteren voor batchbewerkingen." -selectControls = "De rechterzijbalk bevat knoppen om snel al je actieve PDF's te selecteren/deselecteren, en knoppen om het thema of de taal van de app te wijzigen." +selectCropTool = "Laten we de tool Bijsnijden selecteren om te laten zien hoe u een van de tools gebruikt." +toolInterface = "Dit is de interface van de tool Bijsnijden. Zoals u ziet is er nog niet veel, omdat we nog geen PDF-bestanden hebben toegevoegd om mee te werken." +filesButton = "Met de knop Bestanden op de sneltoegangsbalk kunt u PDF-en uploaden om de tools op toe te passen." +fileSources = "U kunt hier nieuwe bestanden uploaden of recente bestanden openen. Voor de rondleiding gebruiken we een voorbeeldbestand." +workbench = "Dit is de Werkbank - het hoofdgebied waar u uw PDF-en bekijkt en bewerkt." +viewSwitcher = "Gebruik deze bedieningselementen om te kiezen hoe u uw PDF-en wilt bekijken." +viewer = "Met de Viewer kunt u uw PDF-en lezen en annoteren." +pageEditor = "De Pagina-editor laat u verschillende bewerkingen uitvoeren op de pagina's in uw PDF-en, zoals herordenen, roteren en verwijderen." +activeFiles = "De weergave Actieve bestanden toont alle PDF-en die u in de tool hebt geladen en laat u kiezen welke u wilt verwerken." +fileCheckbox = "Door op een van de bestanden te klikken selecteert u het voor verwerking. U kunt meerdere bestanden selecteren voor batchbewerkingen." +selectControls = "De rechterzijbalk bevat knoppen om snel al uw actieve PDF-en te selecteren/deselecteren, en knoppen om het thema of de taal van de app te wijzigen." cropSettings = "Nu we het bestand hebben geselecteerd dat we willen bijsnijden, kunnen we de tool Bijsnijden configureren om het gebied te kiezen waarnaar we de PDF willen bijsnijden." -runButton = "Zodra de tool is geconfigureerd, kun je met deze knop de tool uitvoeren op alle geselecteerde PDF's." -results = "Nadat de tool klaar is met uitvoeren, toont de stap Beoordeling een voorbeeld van de resultaten in dit paneel, en kun je de bewerking ongedaan maken of het bestand downloaden." -fileReplacement = "Het gewijzigde bestand vervangt automatisch het originele bestand in de Werkbank, zodat je het eenvoudig door meer tools kunt halen." -pinButton = "Je kunt de knop Vastzetten gebruiken als je wilt dat je bestanden actief blijven nadat er tools op zijn uitgevoerd." -wrapUp = "Je bent er klaar voor! Je hebt geleerd over de belangrijkste onderdelen van de app en hoe je ze gebruikt. Klik wanneer je wilt op de knop Help om deze rondleiding opnieuw te zien." +runButton = "Zodra de tool is geconfigureerd, kunt u met deze knop de tool uitvoeren op alle geselecteerde PDF-en." +results = "Nadat de tool klaar is met uitvoeren, toont de stap Beoordeling een voorbeeld van de resultaten in dit paneel, en kunt u de bewerking ongedaan maken of het bestand downloaden." +fileReplacement = "Het gewijzigde bestand vervangt automatisch het originele bestand in de Werkbank, zodat u het eenvoudig door meer tools kunt halen." +pinButton = "U kunt de knop Vastzetten gebruiken als u wilt dat uw bestanden actief blijven nadat er tools op zijn uitgevoerd." +wrapUp = "U bent er klaar voor! U hebt geleerd over de belangrijkste onderdelen van de app en hoe u ze gebruikt. Klik wanneer u wilt op de knop Help om deze rondleiding opnieuw te zien." previous = "Vorige" next = "Volgende" finish = "Voltooien" startTour = "Rondleiding starten" startTourDescription = "Volg een rondleiding langs de belangrijkste functies van Stirling PDF" +[onboarding.whatsNew] +quickAccess = "Begin met de balk Snelle toegang om te schakelen tussen Reader, Automate, uw bestanden en alle rondleidingen." +leftPanel = "Het linkerpaneel Hulpmiddelen toont alles wat u kunt doen. Blader door categorieën of zoek om snel een hulpmiddel te vinden." +fileUpload = "Gebruik de knop Bestanden om een PDF te uploaden of een recente te selecteren. We laden een voorbeeld zodat u de werkruimte kunt zien." +rightRail = "De Rechterbalk bevat snelle acties om bestanden te selecteren, het thema of de taal te wijzigen en resultaten te downloaden." +topBar = "De bovenste balk laat u schakelen tussen Viewer, Pagina Editor en Actieve bestanden." +pageEditorView = "Schakel over naar de Pagina-editor om pagina's te herschikken, te roteren of te verwijderen." +activeFilesView = "Gebruik Actieve Bestanden om alles te zien wat u open hebt en kies waar u aan wilt werken." +wrapUp = "Dit zijn de nieuwtjes in V2. Open het menu Tours wanneer u maar wilt om dit, de Tools-tour of de Admin-tour opnieuw te bekijken." + [onboarding.welcomeModal] title = "Welkom bij Stirling PDF!" -description = "Wil je een snelle rondleiding van 1 minuut volgen om de belangrijkste functies te leren en hoe je aan de slag gaat?" -helpHint = "Je kunt deze rondleiding altijd later openen via de knop Help linksonder." +description = "Wilt u een snelle rondleiding van 1 minuut volgen om de belangrijkste functies te leren en hoe u aan de slag gaat?" +helpHint = "U kunt deze rondleiding altijd later openen via de knop Help linksonder." startTour = "Rondleiding starten" maybeLater = "Misschien later" dontShowAgain = "Niet meer tonen" @@ -5255,40 +5317,44 @@ download = "Downloaden →" showMeAround = "Geef me een rondleiding" skipTheTour = "Rondleiding overslaan" +[onboarding.tourOverview] +title = "Touroverzicht" +body = "Stirling PDF V2 bevat tientallen tools en een vernieuwde lay-out. Maak een korte rondleiding om te zien wat er is veranderd en waar u de functionaliteiten kunt vinden die u nodig hebt." + [onboarding.serverLicense] skip = "Voor nu overslaan" -seePlans = "Plannen bekijken →" +seePlans = "Abonnementen bekijken →" upgrade = "Nu upgraden →" freeTitle = "Serverlicentie" overLimitTitle = "Serverlicentie vereist" -overLimitBody = "Onze licentie staat tot {{freeTierLimit}} gebruikers gratis per server toe. Je hebt {{overLimitUserCopy}} Stirling-gebruikers. Om zonder onderbreking door te gaan, upgrade naar het Stirling Server-plan - onbeperkte plaatsen, PDF-tekstbewerking en volledige admincontrole voor $99/server/maand." +overLimitBody = "Onze licentie staat tot {{freeTierLimit}} gebruikers gratis per server toe. U hebt {{overLimitUserCopy}} Stirling-gebruikers. Om zonder onderbreking door te gaan, upgrade naar het Stirling Server-abonnement - onbeperkte plaatsen, PDF-tekstbewerking en volledige admincontrole voor $99/server/maand." freeBody = "Onze Open-Core-licentie staat tot {{freeTierLimit}} gebruikers per server gratis toe. Om ononderbroken op te schalen, raden we het Stirling Server-abonnement aan - onbeperkte plaatsen en SSO-ondersteuning voor $99/server/maand." [onboarding.desktopInstall] title = "Downloaden" titleWithOs = "Downloaden voor {{osLabel}}" -body = "Stirling werkt het best als desktopapp. Je kunt het offline gebruiken, sneller documenten openen en lokaal op je computer bewerken." +body = "Stirling werkt het best als desktopapp. U kunt het offline gebruiken, sneller documenten openen en lokaal op uw computer bewerken." [onboarding.planOverview] adminTitle = "Admin-overzicht" -userTitle = "Planoverzicht" -adminBodyLoginEnabled = "Als admin kun je gebruikers beheren, instellingen configureren en de servergezondheid monitoren. De eerste {{freeTierLimit}} personen op je server gebruiken Stirling gratis." -adminBodyLoginDisabled = "Zodra je de loginmodus inschakelt, kun je gebruikers beheren, instellingen configureren en de servergezondheid monitoren. De eerste {{freeTierLimit}} personen op je server gebruiken Stirling gratis." -userBody = "Nodig teamgenoten uit, wijs rollen toe en houd je documenten georganiseerd in één veilige werkruimte. Schakel de loginmodus in wanneer je klaar bent om verder te groeien dan solo-gebruik." +userTitle = "Abonnementoverzicht" +adminBodyLoginEnabled = "Als admin kunt u gebruikers beheren, instellingen configureren en de servergezondheid monitoren. De eerste {{freeTierLimit}} personen op uw server gebruiken Stirling gratis." +adminBodyLoginDisabled = "Zodra u de loginmodus inschakelt, kunt u gebruikers beheren, instellingen configureren en de servergezondheid monitoren. De eerste {{freeTierLimit}} personen op uw server gebruiken Stirling gratis." +userBody = "Nodig teamgenoten uit, wijs rollen toe en houd uw documenten georganiseerd in één veilige werkruimte. Schakel de loginmodus in wanneer u klaar bent om verder te groeien dan solo-gebruik." [onboarding.securityCheck] -message = "De applicatie heeft recent belangrijke wijzigingen ondergaan. De aandacht van je serveradmin kan nodig zijn. Bevestig je rol om door te gaan." +message = "De applicatie heeft recent belangrijke wijzigingen ondergaan. De aandacht van uw serveradmin kan nodig zijn. Bevestig uw rol om door te gaan." [adminOnboarding] welcome = "Welkom bij de Beheerdersrondleiding! Laten we de krachtige enterprise-functies en instellingen voor systeembeheerders verkennen." configButton = "Klik op de knop Config om alle systeeminstellingen en beheerdersopties te openen." settingsOverview = "Dit is het instellingenpaneel. Beheerinstellingen zijn per categorie georganiseerd voor eenvoudige navigatie." -teamsAndUsers = "Beheer hier Teams en individuele gebruikers. Je kunt nieuwe gebruikers uitnodigen via e-mail, deelbare links, of zelf aangepaste accounts voor hen aanmaken." -systemCustomization = "We hebben uitgebreide manieren om de UI aan te passen: Systeeminstellingen laten je de app-naam en talen wijzigen, Functies maken servercertificaatbeheer mogelijk, en Endpoints laten je specifieke tools voor je gebruikers in- of uitschakelen." -databaseSection = "Voor geavanceerde productieomgevingen hebben we instellingen voor externe database-koppelingen zodat je kunt integreren met je bestaande infrastructuur." +teamsAndUsers = "Beheer hier Teams en individuele gebruikers. U kunt nieuwe gebruikers uitnodigen via e-mail, deelbare links, of zelf aangepaste accounts voor hen aanmaken." +systemCustomization = "We hebben uitgebreide manieren om de UI aan te passen: Systeeminstellingen laten u de app-naam en talen wijzigen, Functies maken servercertificaatbeheer mogelijk, en Endpoints laten uw specifieke tools voor uw gebruikers in- of uitschakelen." +databaseSection = "Voor geavanceerde productieomgevingen hebben we instellingen voor externe database-koppelingen zodat u kunt integreren met uw bestaande infrastructuur." connectionsSection = "De sectie Connections ondersteunt verschillende aanmeldmethoden, inclusief aangepaste SSO en SAML-providers zoals Google en GitHub, plus e-mailintegraties voor meldingen en communicatie." -adminTools = "Tot slot hebben we geavanceerde beheertools zoals Auditing om systeemactiviteit te volgen en Gebruiksanalyses om te monitoren hoe je gebruikers met het platform omgaan." -wrapUp = "Dat was de beheerdersrondleiding! Je hebt de enterprise-functies gezien die van Stirling PDF een krachtige, aanpasbare oplossing voor organisaties maken. Je kunt deze rondleiding altijd starten vanuit het Help-menu." +adminTools = "Tot slot hebben we geavanceerde beheertools zoals Auditing om systeemactiviteit te volgen en Gebruiksanalyses om te monitoren hoe u gebruikers met het platform omgaan." +wrapUp = "Dat was de beheerdersrondleiding! U hebt de enterprise-functies gezien die van Stirling PDF een krachtige, aanpasbare oplossing voor organisaties maken. U kunt deze rondleiding altijd starten vanuit het Help-menu." [workspace] title = "Werkruimte" @@ -5315,12 +5381,12 @@ disable = "Uitschakelen" deleteUser = "Gebruiker verwijderen" deleteUserSuccess = "Gebruiker succesvol verwijderd" deleteUserError = "Gebruiker verwijderen is mislukt" -confirmDelete = "Weet je zeker dat je deze gebruiker wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt." +confirmDelete = "Weet u zeker dat u deze gebruiker wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt." loginRequired = "Schakel eerst de loginmodus in" [workspace.people.inviteMembers] label = "Leden uitnodigen" -subtitle = "Typ of plak hieronder e-mailadressen, gescheiden door komma's. Je werkruimte wordt per lid gefactureerd." +subtitle = "Typ of plak hieronder e-mailadressen, gescheiden door komma's. Uw werkruimte wordt per lid gefactureerd." [workspace.people.actions] label = "Acties" @@ -5470,7 +5536,7 @@ cannotRemoveFromSystemTeam = "Kan niet uit het systeemteam verwijderen" renameTeamLabel = "Team hernoemen" deleteTeamLabel = "Team verwijderen" cannotDeleteInternal = "Kan het team Internal niet verwijderen" -confirmDelete = "Weet je zeker dat je dit team wilt verwijderen? Dit team moet leeg zijn om te kunnen verwijderen." +confirmDelete = "Weet u zeker dat u dit team wilt verwijderen? Dit team moet leeg zijn om te kunnen verwijderen." confirmRemove = "Gebruiker uit dit team verwijderen?" cannotRenameInternal = "Kan het team Internal niet hernoemen" cannotAddToInternal = "Kan geen leden toevoegen aan het team Internal" @@ -5544,18 +5610,18 @@ featureComparison = "Functievergelijking" from = "Vanaf" perMonth = "/maand" perSeat = "/plaats" -withServer = "+ Server-plan" +withServer = "+ Server-abonnement" licensedSeats = "Gelicenseerd: {{count}} plaatsen" -includedInCurrent = "Inbegrepen in je plan" -selectPlan = "Plan selecteren" +includedInCurrent = "Inbegrepen in uw abonnement" +selectPlan = "Abonnement selecteren" manage = "Beheren" [plan.manageSubscription] -description = "Beheer je abonnement, facturatie en betaalmethoden" +description = "Beheer uw abonnement, facturatie en betaalmethoden" [plan.activePlan] title = "Actief abonnement" -subtitle = "Details van je huidige abonnement" +subtitle = "Details van uw huidige abonnement" [plan.availablePlans] title = "Beschikbare abonnementen" @@ -5568,6 +5634,28 @@ contactSales = "Neem contact op met Sales" contactToUpgrade = "Neem contact met ons op om uw abonnement te upgraden of aan te passen" maxUsers = "Max. aantal gebruikers" upTo = "Tot" +getLicense = "Serverlicenties verkrijgen" +upgradeToEnterprise = "Opwaarderen naar Enterprise" +selectPeriod = "Selecteer factureringsperiode" +monthlyBilling = "Maandelijkse facturering" +yearlyBilling = "Jaarlijks facturering" +checkoutOpened = "Afrekening geopend" +checkoutInstructions = "Voltooi de aankoop op het Stripe-tabblad. Keer na betaling hier terug en vernieuw de pagina om uw licentie te activeren. U ontvangt ook een e-mail met uw licentiesleutel." +activateLicense = "Licentie activeren" + +[plan.static.licenseActivation] +checkoutOpened = "Afrekening geopend in nieuw tabblad" +instructions = "Voltooi de aankoop in het Stripe-tabblad. Zodra de betaling is voltooid, ontvangt u een e-mail met uw licentiesleutel." +enterKey = "Voer hieronder de licentiesleutel in om het abonnement te activeren:" +keyDescription = "Plak de licentiesleutel uit uw e-mail" +activate = "Licentie activeren" +doLater = "Dit doe ik later" +success = "Licentie geactiveerd!" +successMessage = "Uw licentie is met succes geactiveerd. U kunt dit venster nu sluiten." + +[plan.static.billingPortal] +title = "E-mailverificatie vereist" +message = "U moet uw e-mailadres verifiëren in het Stripe-betalingsportaal. Controleer uw e-mail voor een inloglink." [plan.period] month = "maand" @@ -5593,7 +5681,7 @@ highlight1 = "Aangepaste prijzen" highlight2 = "Toegewijde ondersteuning" highlight3 = "Nieuwste functies" requiresServer = "Server vereist" -requiresServerMessage = "Upgrade eerst naar het Server-plan voordat je naar Enterprise upgradet." +requiresServerMessage = "Upgrade eerst naar het Server-abonnement voordat u naar Enterprise opwaardeert." [plan.feature] title = "Functie" @@ -5606,9 +5694,9 @@ customPricing = "Aangepaste prijzen" [plan.licenseWarning] title = "Gratis self-hosted limiet bereikt" -body = "Je hebt {{total}} gebruikers, maar de gratis laag ondersteunt slechts {{limit}} per server. Upgrade om Stirling PDF soepel te laten draaien." +body = "U hebt {{total}} gebruikers, maar de gratis laag ondersteunt slechts {{limit}} per server. Upgrade om Stirling PDF soepel te laten draaien." overLimit = "meer dan {{limit}}" -cta = "Plannen bekijken" +cta = "Abonnementen bekijken" [subscription] renewsOn = "Wordt verlengd op {{date}}" @@ -5630,28 +5718,28 @@ currentSeats = "Huidige plaatsen" minimumSeats = "Minimum aantal plaatsen" basedOnUsers = "(huidige gebruikers)" newSeatCount = "Nieuw aantal plaatsen" -newSeatCountDescription = "Selecteer het aantal plaatsen voor je enterpriselicentie" +newSeatCountDescription = "Selecteer het aantal plaatsen voor uw enterpriselicentie" whatHappensNext = "Wat gebeurt er daarna?" -stripePortalRedirect = "Je wordt doorgestuurd naar de Stripe-facturatieportal om de wijziging in plaatsen te bekijken en te bevestigen. Het naar rato bedrag wordt automatisch berekend." +stripePortalRedirect = "U wordt doorgestuurd naar de Stripe-facturatieportal om de wijziging in plaatsen te bekijken en te bevestigen. Het naar rato bedrag wordt automatisch berekend." preparingUpdate = "Plaatsupdate voorbereiden..." seatCountTooLow = "Het aantal plaatsen moet minimaal {{minimum}} zijn (huidig aantal gebruikers)" seatCountUnchanged = "Selecteer een ander aantal plaatsen" seatsUpdated = "Plaatsen bijgewerkt" -seatsUpdatedMessage = "Je enterprise-plaatsen zijn bijgewerkt naar {{seats}}" +seatsUpdatedMessage = "Uw enterprise-plaatsen zijn bijgewerkt naar {{seats}}" updateProcessing = "Update verwerken" -updateProcessingMessage = "Je plaatsupdate wordt verwerkt. Vernieuw over enkele ogenblikken." +updateProcessingMessage = "Uw plaatsupdate wordt verwerkt. Vernieuw over enkele ogenblikken." notEnterprise = "Plaatsbeheer is alleen beschikbaar voor enterpriselicenties" [billing.portal] error = "Kon facturatieportaal niet openen" [upgradeBanner] -title = "Upgraden naar Server-plan" +title = "Upgraden naar Server-abonnement" message = "Haal het meeste uit Stirling PDF met onbeperkte gebruikers en geavanceerde functies" upgradeButton = "Nu upgraden" dismiss = "Banner sluiten" attentionTitle = "Deze server heeft aandacht van een admin nodig" -attentionBody = "Je admin moet inloggen om meer info te zien. Neem direct contact met hen op." +attentionBody = "Uw admin moet inloggen om meer info te zien. Neem direct contact met hen op." attentionBodyAdmin = "Controleer de licentievereisten om deze server compliant te houden." seeInfo = "Info bekijken" @@ -5663,45 +5751,45 @@ success = "Betaling geslaagd!" successMessage = "Uw abonnement is succesvol geactiveerd. U ontvangt binnenkort een bevestigingsmail." autoClose = "Dit venster wordt automatisch gesloten..." error = "Betalingsfout" -upgradeSuccess = "Betaling geslaagd! Je abonnement is geüpgraded. De licentie is op je server bijgewerkt. Je ontvangt binnenkort een bevestigingsmail." +upgradeSuccess = "Betaling geslaagd! Uw abonnement is geüpgraded. De licentie is op uw server bijgewerkt. U ontvangt binnenkort een bevestigingsmail." paymentSuccess = "Betaling geslaagd! Licentiesleutel ophalen..." -licenseActivated = "Licentie geactiveerd! Je licentiesleutel is opgeslagen. Er is een bevestigingsmail verzonden naar je geregistreerde e-mailadres." -licenseDelayed = "Betaling geslaagd! Je licentie wordt gegenereerd. Je ontvangt binnenkort een e-mail met je licentiesleutel. Als je deze niet binnen 10 minuten ontvangt, neem dan contact op met support." -licensePollingError = "Betaling geslaagd, maar we konden je licentiesleutel niet automatisch ophalen. Controleer je e-mail of neem contact op met support met je betalingsbevestiging." -licenseRetrievalError = "Betaling geslaagd, maar het ophalen van de licentie is mislukt. Je ontvangt je licentiesleutel via e-mail. Neem contact op met support als je deze niet binnen 10 minuten ontvangt." -syncError = "Betaling geslaagd, maar licentiesynchronisatie is mislukt. Je licentie wordt binnenkort bijgewerkt. Neem contact op met support als het probleem blijft." -licenseSaveError = "Opslaan van licentiesleutel mislukt. Neem contact op met support met je licentiesleutel om de activatie te voltooien." +licenseActivated = "Licentie geactiveerd! Uw licentiesleutel is opgeslagen. Er is een bevestigingsmail verzonden naar uw geregistreerde e-mailadres." +licenseDelayed = "Betaling geslaagd! Uw licentie wordt gegenereerd. U ontvangt binnenkort een e-mail met uw licentiesleutel. Als u deze niet binnen 10 minuten ontvangt, neem dan contact op met support." +licensePollingError = "Betaling geslaagd, maar we konden uw licentiesleutel niet automatisch ophalen. Controleer uw e-mail of neem contact op met support met uw betalingsbevestiging." +licenseRetrievalError = "Betaling geslaagd, maar het ophalen van de licentie is mislukt. U ontvangt uw licentiesleutel via e-mail. Neem contact op met support als u deze niet binnen 10 minuten ontvangt." +syncError = "Betaling geslaagd, maar licentiesynchronisatie is mislukt. Uw licentie wordt binnenkort bijgewerkt. Neem contact op met support als het probleem blijft." +licenseSaveError = "Opslaan van licentiesleutel mislukt. Neem contact op met support met uw licentiesleutel om de activatie te voltooien." paymentCanceled = "Betaling geannuleerd. Er zijn geen kosten in rekening gebracht." -syncingLicense = "Je geüpgradede licentie synchroniseren..." -generatingLicense = "Je licentiesleutel genereren..." +syncingLicense = "Uw geüpgradede licentie synchroniseren..." +generatingLicense = "Uw licentiesleutel genereren..." upgradeComplete = "Upgrade voltooid" -upgradeCompleteMessage = "Je abonnement is succesvol geüpgraded. Je bestaande licentiesleutel is bijgewerkt." +upgradeCompleteMessage = "Uw abonnement is succesvol geüpgraded. Uw bestaande licentiesleutel is bijgewerkt." stripeNotConfigured = "Stripe niet geconfigureerd" -stripeNotConfiguredMessage = "Stripe-betalintegratie is niet geconfigureerd. Neem contact op met je beheerder." +stripeNotConfiguredMessage = "Stripe-betalintegratie is niet geconfigureerd. Neem contact op met uw beheerder." monthly = "Maandelijks" yearly = "Jaarlijks" billingPeriod = "Facturatieperiode" enterpriseNote = "Plaatsen kunnen worden aangepast bij het afrekenen (1-1000)." installationId = "Installatie-ID" -licenseKey = "Je licentiesleutel" -licenseInstructions = "Dit is toegevoegd aan je installatie. Je ontvangt ook een kopie per e-mail." -canCloseWindow = "Je kunt dit venster nu sluiten." +licenseKey = "Uw licentiesleutel" +licenseInstructions = "Dit is toegevoegd aan uw installatie. U ontvangt ook een kopie per e-mail." +canCloseWindow = "U kunt dit venster nu sluiten." licenseKeyProcessing = "Licentiesleutel verwerken" -licenseDelayedMessage = "Je licentiesleutel wordt gegenereerd. Controleer binnenkort je e-mail of neem contact op met support." +licenseDelayedMessage = "Uw licentiesleutel wordt gegenereerd. Controleer binnenkort uw e-mail of neem contact op met support." perYear = "/jaar" perMonth = "/maand" emailInvalid = "Voer een geldig e-mailadres in" [payment.emailStage] -title = "Vul je e-mailadres in" -description = "We gebruiken dit om je licentiesleutel en kwitanties te sturen." +title = "Vul uw e-mailadres in" +description = "We gebruiken dit om uw licentiesleutel en kwitanties te sturen." emailLabel = "E-mailadres" emailPlaceholder = "your@email.com" continue = "Doorgaan" modalTitle = "Aan de slag - {{planName}}" [payment.planStage] -title = "Kies je facturatieperiode" +title = "Kies uw facturatieperiode" savingsNote = "Bespaar {{percent}}% met jaarlijkse facturatie" basePrice = "Basistarief" seatPrice = "Per plaats" @@ -5709,13 +5797,13 @@ totalForSeats = "Totaal ({{count}} plaatsen)" selectMonthly = "Maandelijks selecteren" selectYearly = "Jaarlijks selecteren" savePercent = "Bespaar {{percent}}%" -savingsAmount = "Je bespaart {{amount}}" +savingsAmount = "U bespaart {{amount}}" modalTitle = "Selecteer facturatieperiode - {{planName}}" billedYearly = "Jaarlijks gefactureerd à {{currency}}{{amount}}" [payment.paymentStage] -backToPlan = "Terug naar planselectie" -selectedPlan = "Geselecteerd plan" +backToPlan = "Terug naar abonnementselectie" +selectedPlan = "Geselecteerd abonnement" modalTitle = "Betaling voltooien - {{planName}}" [firstLogin] @@ -5771,6 +5859,8 @@ notAvailable = "Auditsysteem niet beschikbaar" notAvailableMessage = "Het auditsysteem is niet geconfigureerd of niet beschikbaar." disabled = "Auditlogging is uitgeschakeld" disabledMessage = "Schakel auditlogging in uw applicatieconfiguratie in om systeemevenementen te volgen." +enterpriseRequired = "Enterprise-licentie vereist" +enterpriseRequiredMessage = "Het audit-loggingsysteem is een functie voor ondernemingen. Upgrade naar een ondernemingslicentie om toegang te krijgen tot auditlogboeken en analyses." [audit.error] title = "Fout bij laden van auditsysteem" @@ -5870,11 +5960,11 @@ percentage = "Percentage" noData = "Geen gegevens beschikbaar" [backendHealth] -checking = "Backendstatus controleren..." -online = "Backend online" -offline = "Backend offline" -starting = "Backend wordt gestart..." -wait = "Wacht tot de backend is opgestart en probeer het opnieuw." +checking = "Back-endstatus controleren..." +online = "Back-end online" +offline = "Back-end offline" +starting = "Back-end wordt gestart..." +wait = "Wacht tot de back-end is opgestart en probeer het opnieuw." [encryptedPdfUnlock] unlockPrompt = "Ontgrendel PDF om door te gaan" @@ -5896,7 +5986,7 @@ placeholder = "Voer het PDF-wachtwoord in" [setup] welcome = "Welkom bij Stirling PDF" -description = "Begin door te kiezen hoe je Stirling PDF wilt gebruiken" +description = "Begin door te kiezen hoe u Stirling PDF wilt gebruiken" [setup.step1] label = "Kies modus" @@ -5908,28 +5998,28 @@ description = "Zelfgehoste server" [setup.step3] label = "Inloggen" -description = "Vul je gegevens in" +description = "Vul uw gegevens in" [setup.mode.saas] title = "Stirling Cloud" -description = "Log in met je Stirling-account" +description = "Log in met uw Stirling-account" [setup.mode.selfhosted] title = "Self-hosted server" -description = "Verbind met je eigen Stirling PDF-server" +description = "Verbind met uw eigen Stirling PDF-server" [setup.saas] title = "Inloggen bij Stirling" -subtitle = "Log in met je Stirling-account" +subtitle = "Log in met uw Stirling-account" [setup.selfhosted] title = "Inloggen bij server" -subtitle = "Vul je servergegevens in" +subtitle = "Vul uw servergegevens in" link = "of maak verbinding met een zelfgehost account" [setup.server] title = "Verbinden met server" -subtitle = "Vul de URL van je self-hosted server in" +subtitle = "Vul de URL van uw self-hosted server in" testing = "Verbinding testen..." [setup.server.type] @@ -5938,7 +6028,7 @@ selfhosted = "Zelfgehoste server" [setup.server.url] label = "Server-URL" -description = "Voer de volledige URL van je self-hosted Stirling PDF-server in" +description = "Voer de volledige URL van uw self-hosted Stirling PDF-server in" [setup.server.error] emptyUrl = "Voer een server-URL in" @@ -5955,7 +6045,7 @@ step3 = "Start de server opnieuw" [setup.login] title = "Inloggen" -subtitle = "Vul je gegevens in om door te gaan" +subtitle = "Vul uw gegevens in om door te gaan" connectingTo = "Verbinden met:" submit = "Inloggen" signInWith = "Inloggen met" @@ -5971,20 +6061,20 @@ instructionsRestart = "Start vervolgens uw server opnieuw zodat de wijzigingen v [setup.login.username] label = "Gebruikersnaam" -placeholder = "Voer je gebruikersnaam in" +placeholder = "Voer uw gebruikersnaam in" [setup.login.email] label = "E-mail" -placeholder = "Voer je e-mailadres in" +placeholder = "Voer uw e-mailadres in" [setup.login.password] label = "Wachtwoord" -placeholder = "Voer je wachtwoord in" +placeholder = "Voer uw wachtwoord in" [setup.login.error] -emptyUsername = "Vul je gebruikersnaam in" -emptyEmail = "Vul je e-mailadres in" -emptyPassword = "Vul je wachtwoord in" +emptyUsername = "Vul uw gebruikersnaam in" +emptyEmail = "Vul uw e-mailadres in" +emptyPassword = "Vul uw wachtwoord in" oauthFailed = "OAuth-login mislukt. Probeer het opnieuw." [oauth.success] @@ -6070,11 +6160,11 @@ title = "Welkom bij PDF-teksteditor (Early Access)" experimental = "Dit is een experimentele functie in actieve ontwikkeling. Reken op enige instabiliteit en problemen tijdens het gebruik." howItWorks = "Deze tool zet uw PDF om naar een bewerkbaar formaat waarin u tekst kunt wijzigen en afbeeldingen kunt herpositioneren. Wijzigingen worden opgeslagen als een nieuwe PDF." bestFor = "Werkt het best met:" -bestFor1 = "Eenvoudige PDF's met vooral tekst en afbeeldingen" +bestFor1 = "Eenvoudige PDF-en met vooral tekst en afbeeldingen" bestFor2 = "Documenten met standaard alineavormatting" bestFor3 = "Brieven, essays, rapporten en eenvoudige documenten" notIdealFor = "Niet ideaal voor:" -notIdealFor1 = "PDF's met speciale opmaak zoals opsommingstekens, tabellen of lay-outs met meerdere kolommen" +notIdealFor1 = "PDF-en met speciale opmaak zoals opsommingstekens, tabellen of lay-outs met meerdere kolommen" notIdealFor2 = "Magazines, brochures of sterk vormgegeven documenten" notIdealFor3 = "Handleidingen met complexe lay-outs" limitations = "Huidige beperkingen:" @@ -6084,7 +6174,7 @@ limitation3 = "Grote bestanden kunnen tijd kosten om te converteren en te verwer knownIssues = "Bekende problemen (worden verholpen):" issue1 = "Tekstkleur wordt momenteel niet behouden (wordt binnenkort toegevoegd)" issue2 = "Alineamodus heeft meer problemen met uitlijning en afstand - Enkele-regelmodus aanbevolen" -issue3 = "Het voorbeeld wijkt af van de geëxporteerde PDF - geëxporteerde PDF's liggen dichter bij het origineel" +issue3 = "Het voorbeeld wijkt af van de geëxporteerde PDF - geëxporteerde PDF-en liggen dichter bij het origineel" issue4 = "Uitlijning van gedraaide tekst kan handmatige aanpassing vereisen" issue5 = "Transparantie- en laageffecten kunnen afwijken van het origineel" feedback = "Dit is een functie in vroege toegang. Meld eventuele problemen zodat we kunnen verbeteren!" @@ -6126,7 +6216,7 @@ insufficientPermissions = "U hebt geen toestemming om deze actie uit te voeren." [addText] title = "Tekst toevoegen" -header = "Tekst toevoegen aan PDF's" +header = "Tekst toevoegen aan PDF-en" tags = "tekst,annotatie,label" applySignatures = "Tekst toepassen" diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 9719752dc..2689b6b98 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -589,6 +589,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -716,6 +736,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -908,6 +934,15 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1626,6 +1661,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" @@ -2820,6 +2861,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3651,6 +3702,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.39.0" @@ -4256,6 +4317,7 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-log", @@ -4615,6 +4677,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-fs" version = "2.4.4" @@ -4735,6 +4818,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.17", "tracing", "windows-sys 0.60.2", @@ -4954,6 +5038,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny_http" version = "0.12.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index dc84ad8a2..455436cdf 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -29,9 +29,10 @@ tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2.1.0" tauri-plugin-fs = "2.4.4" tauri-plugin-http = "2.4.4" -tauri-plugin-single-instance = "2.0.1" +tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] } tauri-plugin-store = "2.1.0" tauri-plugin-opener = "2.0.0" +tauri-plugin-deep-link = "2.4.5" keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] } tokio = { version = "1.0", features = ["time", "sync"] } reqwest = { version = "0.11", features = ["json"] } diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index b992b3221..445e1d30a 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -19,6 +19,8 @@ { "identifier": "fs:allow-read-file", "allow": [{ "path": "**" }] - } + }, + "opener:default", + "shell:allow-open" ] } diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index a08587cc1..61cbd6d43 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,4 +1,4 @@ -use tauri::{Manager, RunEvent, WindowEvent, Emitter}; +use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent}; mod utils; mod commands; @@ -28,6 +28,17 @@ use commands::{ }; use state::connection_state::AppConnectionState; use utils::{add_log, get_tauri_logs}; +use tauri_plugin_deep_link::DeepLinkExt; + +fn dispatch_deep_link(app: &AppHandle, url: &str) { + add_log(format!("🔗 Dispatching deep link: {}", url)); + let _ = app.emit("deep-link", url.to_string()); + + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + let _ = window.unminimize(); + } +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -42,6 +53,7 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_deep_link::init()) .manage(AppConnectionState::default()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { // This callback runs when a second instance tries to start @@ -78,6 +90,29 @@ pub fn run() { } } + { + let app_handle = app.handle(); + // On macOS the plugin registers schemes via bundle metadata, so runtime registration is required only on Windows/Linux + #[cfg(any(target_os = "linux", target_os = "windows"))] + if let Err(err) = app_handle.deep_link().register_all() { + add_log(format!("⚠️ Failed to register deep link handler: {}", err)); + } + + if let Ok(Some(urls)) = app_handle.deep_link().get_current() { + let initial_handle = app_handle.clone(); + for url in urls { + dispatch_deep_link(&initial_handle, url.as_str()); + } + } + + let event_app_handle = app_handle.clone(); + app_handle.deep_link().on_open_url(move |event| { + for url in event.urls() { + dispatch_deep_link(&event_app_handle, url.as_str()); + } + }); + } + // Start backend immediately, non-blocking let app_handle = app.handle().clone(); diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index a7c3355d8..43546413c 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,74 +1,89 @@ { - "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", - "productName": "Stirling-PDF", - "version": "2.0.0", - "identifier": "stirling.pdf.dev", - "build": { - "frontendDist": "../dist", - "devUrl": "http://localhost:5173", - "beforeDevCommand": "npm run dev -- --mode desktop", - "beforeBuildCommand": "npm run build -- --mode desktop" - }, - "app": { - "windows": [ - { - "title": "Stirling-PDF", - "width": 1280, - "height": 800, - "resizable": true, - "fullscreen": false - } - ] - }, - "bundle": { - "active": true, - "targets": ["deb", "rpm", "dmg", "app", "msi"], - "icon": [ - "icons/icon.png", - "icons/icon.icns", - "icons/icon.ico", - "icons/16x16.png", - "icons/32x32.png", - "icons/64x64.png", - "icons/128x128.png", - "icons/192x192.png" - ], - "resources": [ - "libs/*.jar", - "runtime/jre/**/*" - ], - "fileAssociations": [ - { - "ext": ["pdf"], - "name": "PDF Document", - "description": "Open PDF files with Stirling-PDF", - "role": "Editor", - "mimeType": "application/pdf" - } - ], - "linux": { - "deb": { - "desktopTemplate": "stirling-pdf.desktop" - } + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Stirling-PDF", + "version": "2.1.3", + "identifier": "stirling.pdf.dev", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "npm run dev -- --mode desktop", + "beforeBuildCommand": "npm run build -- --mode desktop" }, - "windows": { - "certificateThumbprint": null, - "digestAlgorithm": "sha256", - "timestampUrl": "http://timestamp.digicert.com" + "app": { + "windows": [ + { + "title": "Stirling-PDF", + "width": 1280, + "height": 800, + "resizable": true, + "fullscreen": false + } + ] }, - "macOS": { - "minimumSystemVersion": "10.15", - "signingIdentity": null, - "entitlements": null, - "providerShortName": null + "bundle": { + "active": true, + "targets": [ + "deb", + "rpm", + "dmg", + "app", + "msi" + ], + "icon": [ + "icons/icon.png", + "icons/icon.icns", + "icons/icon.ico", + "icons/16x16.png", + "icons/32x32.png", + "icons/64x64.png", + "icons/128x128.png", + "icons/192x192.png" + ], + "resources": [ + "libs/*.jar", + "runtime/jre/**/*" + ], + "fileAssociations": [ + { + "ext": [ + "pdf" + ], + "name": "PDF Document", + "description": "Open PDF files with Stirling-PDF", + "role": "Editor", + "mimeType": "application/pdf" + } + ], + "linux": { + "deb": { + "desktopTemplate": "stirling-pdf.desktop" + } + }, + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "http://timestamp.digicert.com" + }, + "macOS": { + "minimumSystemVersion": "10.15", + "signingIdentity": null, + "entitlements": null, + "providerShortName": null + } + }, + "plugins": { + "shell": { + "open": true + }, + "fs": { + "requireLiteralLeadingDot": false + }, + "deep-link": { + "desktop": { + "schemes": [ + "stirlingpdf" + ] + } + } } - }, - "plugins": { - "shell": { - "open": true - }, - "fs": { - "requireLiteralLeadingDot": false - } - } } diff --git a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index c93a89be0..f3fecba7e 100644 --- a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from 'react'; import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; interface TextInputWithFontProps { @@ -13,8 +12,12 @@ interface TextInputWithFontProps { textColor?: string; onTextColorChange?: (color: string) => void; disabled?: boolean; - label?: string; - placeholder?: string; + label: string; + placeholder: string; + fontLabel: string; + fontSizeLabel: string; + fontSizePlaceholder: string; + colorLabel?: string; onAnyChange?: () => void; } @@ -30,9 +33,12 @@ export const TextInputWithFont: React.FC = ({ disabled = false, label, placeholder, + fontLabel, + fontSizeLabel, + fontSizePlaceholder, + colorLabel, onAnyChange }) => { - const { t } = useTranslation(); const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString()); const fontSizeCombobox = useCombobox(); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); @@ -66,8 +72,8 @@ export const TextInputWithFont: React.FC = ({ return ( { onTextChange(e.target.value); @@ -79,7 +85,7 @@ export const TextInputWithFont: React.FC = ({ {/* Font Selection */}