diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 6f02c599b0..ad6d6f3dc4 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -306,8 +306,10 @@ jobs: - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw environment: - DISABLE_ADDITIONAL_FEATURES: "true" - SECURITY_ENABLELOGIN: "false" + DISABLE_ADDITIONAL_FEATURES: "false" + SECURITY_ENABLELOGIN: "true" + SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}" + SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}" SYSTEM_DEFAULTLOCALE: en-GB UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}" UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture" diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 09ef22a165..856d1fd3b3 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -312,6 +312,8 @@ jobs: APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }} + VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }} SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }} CI: true with: diff --git a/.gitignore b/.gitignore index 3d9b2a949e..f0e16d5e78 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ SwaggerDoc.json # Gradle .gradle +.gradle-home .lock # External tool builders @@ -213,3 +214,17 @@ node_modules/ test_batch.json *.backup.*.json frontend/public/locales/*/translation.backup*.json + +# Development/build artifacts +.gradle-cache/ +scripts/pdf-collection/ +**/tmp/ +*.backup + +# Type3 development data +docs/type3/signatures/ + + +# Type3 sample PDFs (development only) +**/type3/samples/ + diff --git a/app/common/build.gradle b/app/common/build.gradle index 0a5a37c6ed..e2184a4a07 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -37,6 +37,8 @@ dependencies { api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8' api "org.apache.pdfbox:pdfbox:$pdfboxVersion" + api "org.apache.pdfbox:xmpbox:$pdfboxVersion" + api "org.apache.pdfbox:preflight:$pdfboxVersion" api 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' api 'org.snakeyaml:snakeyaml-engine:2.10' diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java similarity index 86% rename from app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java rename to app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 0178c25971..35f68939c3 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties; @Slf4j public class EndpointConfiguration { + public enum DisableReason { + CONFIG, + DEPENDENCY, + UNKNOWN + } + + public static class EndpointAvailability { + private final boolean enabled; + private final DisableReason reason; + + public EndpointAvailability(boolean enabled, DisableReason reason) { + this.enabled = enabled; + this.reason = reason; + } + + public boolean isEnabled() { + return enabled; + } + + public DisableReason getReason() { + return reason; + } + } + private static final String REMOVE_BLANKS = "remove-blanks"; private final ApplicationProperties applicationProperties; @Getter private Map endpointStatuses = new ConcurrentHashMap<>(); private Map> endpointGroups = new ConcurrentHashMap<>(); private Set disabledGroups = new HashSet<>(); + private Map endpointDisableReasons = new ConcurrentHashMap<>(); + private Map groupDisableReasons = new ConcurrentHashMap<>(); private Map> endpointAlternatives = new ConcurrentHashMap<>(); private final boolean runningProOrHigher; @@ -35,16 +61,31 @@ public class EndpointConfiguration { processEnvironmentConfigs(); } + private String normalizeEndpoint(String endpoint) { + if (endpoint == null) { + return null; + } + return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint; + } + public void enableEndpoint(String endpoint) { - endpointStatuses.put(endpoint, true); - log.debug("Enabled endpoint: {}", endpoint); + String normalized = normalizeEndpoint(endpoint); + endpointStatuses.put(normalized, true); + endpointDisableReasons.remove(normalized); + log.debug("Enabled endpoint: {}", normalized); } public void disableEndpoint(String endpoint) { - if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) { - log.debug("Disabling endpoint: {}", endpoint); + disableEndpoint(endpoint, DisableReason.CONFIG); + } + + public void disableEndpoint(String endpoint, DisableReason reason) { + String normalized = normalizeEndpoint(endpoint); + if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) { + log.debug("Disabling endpoint: {}", normalized); } - endpointStatuses.put(endpoint, false); + endpointStatuses.put(normalized, false); + endpointDisableReasons.put(normalized, reason); } public boolean isEndpointEnabled(String endpoint) { @@ -150,6 +191,10 @@ public class EndpointConfiguration { } public void disableGroup(String group) { + disableGroup(group, DisableReason.CONFIG); + } + + public void disableGroup(String group, DisableReason reason) { if (disabledGroups.add(group)) { if (isToolGroup(group)) { log.debug( @@ -161,11 +206,12 @@ public class EndpointConfiguration { group); } } + groupDisableReasons.put(group, reason); // Only cascade to endpoints for *functional* groups if (!isToolGroup(group)) { Set endpoints = endpointGroups.get(group); if (endpoints != null) { - endpoints.forEach(this::disableEndpoint); + endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason)); } } } @@ -174,12 +220,39 @@ public class EndpointConfiguration { if (disabledGroups.remove(group)) { log.debug("Enabling group: {}", group); } + groupDisableReasons.remove(group); Set endpoints = endpointGroups.get(group); if (endpoints != null) { endpoints.forEach(this::enableEndpoint); } } + public EndpointAvailability getEndpointAvailability(String endpoint) { + boolean enabled = isEndpointEnabled(endpoint); + DisableReason reason = enabled ? null : determineDisableReason(endpoint); + return new EndpointAvailability(enabled, reason); + } + + private DisableReason determineDisableReason(String endpoint) { + String normalized = normalizeEndpoint(endpoint); + if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) { + return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG); + } + + for (Map.Entry> entry : endpointGroups.entrySet()) { + String group = entry.getKey(); + Set endpoints = entry.getValue(); + if (!disabledGroups.contains(group) || endpoints == null) { + continue; + } + if (endpoints.contains(normalized)) { + return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG); + } + } + + return DisableReason.UNKNOWN; + } + public Set getDisabledGroups() { return new HashSet<>(disabledGroups); } @@ -261,6 +334,10 @@ public class EndpointConfiguration { addEndpointToGroup("Convert", "pdf-to-csv"); addEndpointToGroup("Convert", "pdf-to-markdown"); addEndpointToGroup("Convert", "eml-to-pdf"); + addEndpointToGroup("Convert", "cbz-to-pdf"); + addEndpointToGroup("Convert", "pdf-to-cbz"); + addEndpointToGroup("Convert", "pdf-to-json"); + addEndpointToGroup("Convert", "json-to-pdf"); // Adding endpoints to "Security" group addEndpointToGroup("Security", "add-password"); @@ -394,6 +471,10 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "pdf-to-markdown"); addEndpointToGroup("Java", "add-attachments"); addEndpointToGroup("Java", "compress-pdf"); + addEndpointToGroup("Java", "cbz-to-pdf"); + addEndpointToGroup("Java", "pdf-to-cbz"); + addEndpointToGroup("Java", "pdf-to-json"); + addEndpointToGroup("Java", "json-to-pdf"); addEndpointToGroup("rar", "pdf-to-cbr"); // Javascript diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/ErrorResponse.java b/app/common/src/main/java/stirling/software/SPDF/config/swagger/ErrorResponse.java similarity index 100% rename from app/core/src/main/java/stirling/software/SPDF/config/swagger/ErrorResponse.java rename to app/common/src/main/java/stirling/software/SPDF/config/swagger/ErrorResponse.java diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/StandardPdfResponse.java b/app/common/src/main/java/stirling/software/SPDF/config/swagger/StandardPdfResponse.java similarity index 100% rename from app/core/src/main/java/stirling/software/SPDF/config/swagger/StandardPdfResponse.java rename to app/common/src/main/java/stirling/software/SPDF/config/swagger/StandardPdfResponse.java diff --git a/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java b/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java index d106a27298..a7f1585397 100644 --- a/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java +++ b/app/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java @@ -41,7 +41,7 @@ public class CustomPDFDocumentFactory { // Memory thresholds and limits - private static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB + public static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB // Files smaller than this threshold are loaded entirely in memory for better performance. // These files use IOUtils.createMemoryOnlyStreamCache() which keeps all document data in RAM. // No temp files are created for document data, reducing I/O operations but consuming more diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 6f595857aa..5f3468a180 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -9,6 +9,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Supplier; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -37,6 +38,9 @@ public class JobExecutorService { private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor(); private final long effectiveTimeoutMs; + @Autowired(required = false) + private JobOwnershipService jobOwnershipService; + public JobExecutorService( TaskManager taskManager, FileStorage fileStorage, @@ -98,11 +102,17 @@ public class JobExecutorService { long customTimeoutMs, boolean queueable, int resourceWeight) { - String jobId = UUID.randomUUID().toString(); + // Generate base UUID + String baseJobId = UUID.randomUUID().toString(); - // Store the job ID in the request for potential use by other components + // Scope job to authenticated user if security is enabled + String scopedJobKey = getScopedJobKey(baseJobId); + + log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId); + + // Store the scoped job ID in the request for potential use by other components if (request != null) { - request.setAttribute("jobId", jobId); + request.setAttribute("jobId", scopedJobKey); // Also track this job ID in the user's session for authorization purposes // This ensures users can only cancel their own jobs @@ -116,11 +126,13 @@ public class JobExecutorService { request.getSession().setAttribute("userJobIds", userJobIds); } - userJobIds.add(jobId); - log.debug("Added job ID {} to user session", jobId); + userJobIds.add(scopedJobKey); + log.debug("Added scoped job ID {} to user session", scopedJobKey); } } + String jobId = scopedJobKey; + // Determine which timeout to use long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs; @@ -149,17 +161,31 @@ public class JobExecutorService { taskManager.createTask(jobId); // Create a specialized wrapper that updates the TaskManager + final String capturedJobIdForQueue = jobId; Supplier wrappedWork = () -> { try { + // Set jobId in ThreadLocal context for the queued job + stirling.software.common.util.JobContext.setJobId( + capturedJobIdForQueue); + log.debug( + "Set jobId {} in JobContext for queued job execution", + capturedJobIdForQueue); + Object result = work.get(); - processJobResult(jobId, result); + processJobResult(capturedJobIdForQueue, result); return result; } catch (Exception e) { log.error( - "Error executing queued job {}: {}", jobId, e.getMessage(), e); - taskManager.setError(jobId, e.getMessage()); + "Error executing queued job {}: {}", + capturedJobIdForQueue, + e.getMessage(), + e); + taskManager.setError(capturedJobIdForQueue, e.getMessage()); throw e; + } finally { + // Clean up ThreadLocal to avoid memory leaks + stirling.software.common.util.JobContext.clear(); } }; @@ -171,21 +197,36 @@ public class JobExecutorService { return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null)); } else if (async) { taskManager.createTask(jobId); + + // Capture the jobId for the async thread + final String capturedJobId = jobId; + executor.execute( () -> { try { log.debug( - "Running async job {} with timeout {} ms", jobId, timeoutToUse); + "Running async job {} with timeout {} ms", + capturedJobId, + timeoutToUse); + + // Set jobId in ThreadLocal context for the async thread + stirling.software.common.util.JobContext.setJobId(capturedJobId); + log.debug( + "Set jobId {} in JobContext for async execution", + capturedJobId); // Execute with timeout Object result = executeWithTimeout(() -> work.get(), timeoutToUse); - processJobResult(jobId, result); + processJobResult(capturedJobId, result); } catch (TimeoutException te) { log.error("Job {} timed out after {} ms", jobId, timeoutToUse); taskManager.setError(jobId, "Job timed out"); } catch (Exception e) { log.error("Error executing job {}: {}", jobId, e.getMessage(), e); taskManager.setError(jobId, e.getMessage()); + } finally { + // Clean up ThreadLocal to avoid memory leaks + stirling.software.common.util.JobContext.clear(); } }); @@ -194,6 +235,10 @@ public class JobExecutorService { try { log.debug("Running sync job with timeout {} ms", timeoutToUse); + // Make jobId available to downstream components on the worker thread + stirling.software.common.util.JobContext.setJobId(jobId); + log.debug("Set jobId {} in JobContext for sync execution", jobId); + // Execute with timeout Object result = executeWithTimeout(() -> work.get(), timeoutToUse); @@ -213,6 +258,8 @@ public class JobExecutorService { // Construct a JSON error response return ResponseEntity.internalServerError() .body(Map.of("error", "Job failed: " + e.getMessage())); + } finally { + stirling.software.common.util.JobContext.clear(); } } } @@ -466,8 +513,23 @@ public class JobExecutorService { throws TimeoutException, Exception { // Use the same executor as other async jobs for consistency // This ensures all operations run on the same thread pool + String currentJobId = stirling.software.common.util.JobContext.getJobId(); + java.util.concurrent.CompletableFuture future = - java.util.concurrent.CompletableFuture.supplyAsync(supplier, executor); + java.util.concurrent.CompletableFuture.supplyAsync( + () -> { + if (currentJobId != null) { + stirling.software.common.util.JobContext.setJobId(currentJobId); + } + try { + return supplier.get(); + } finally { + if (currentJobId != null) { + stirling.software.common.util.JobContext.clear(); + } + } + }, + executor); try { return future.get(timeoutMs, TimeUnit.MILLISECONDS); @@ -483,4 +545,18 @@ public class JobExecutorService { throw new Exception("Execution was interrupted", e); } } + + /** + * Get a scoped job key that includes user ownership when security is enabled. + * + * @param baseJobId the base job identifier + * @return scoped job key, or just baseJobId if no ownership service available + */ + private String getScopedJobKey(String baseJobId) { + if (jobOwnershipService != null) { + return jobOwnershipService.createScopedJobKey(baseJobId); + } + // Security disabled, return unsecured job key + return baseJobId; + } } diff --git a/app/common/src/main/java/stirling/software/common/service/JobOwnershipService.java b/app/common/src/main/java/stirling/software/common/service/JobOwnershipService.java new file mode 100644 index 0000000000..7f08dfded3 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/JobOwnershipService.java @@ -0,0 +1,42 @@ +package stirling.software.common.service; + +import java.util.Optional; + +/** + * Service interface for managing job ownership and access control. Implementations can provide + * user-scoped job isolation when security is enabled, or no-op behavior when security is disabled. + */ +public interface JobOwnershipService { + + /** + * Get the current authenticated user's identifier. + * + * @return Optional containing user identifier, or empty if not authenticated + */ + Optional getCurrentUserId(); + + /** + * Create a scoped job key that includes user ownership when security is enabled. + * + * @param jobId the base job identifier + * @return scoped job key in format "userId:jobId", or just jobId if no user authenticated + */ + String createScopedJobKey(String jobId); + + /** + * Validate that the current user has access to the given job. + * + * @param scopedJobKey the scoped job key to validate + * @return true if current user owns the job or no authentication is required + * @throws SecurityException if current user does not own the job + */ + boolean validateJobAccess(String scopedJobKey); + + /** + * Extract the base job ID from a scoped job key. + * + * @param scopedJobKey the scoped job key + * @return the base job ID without user prefix + */ + String extractJobId(String scopedJobKey); +} diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index 10ac8b5954..ecf0d75d43 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -38,6 +38,7 @@ public class GeneralUtils { Set.of( "OCR images.json", "Prepare-pdfs-for-email.json", + "Pre-publish-sanitization.json", "split-rotate-auto-rename.json"); private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs"; diff --git a/app/common/src/main/java/stirling/software/common/util/JobContext.java b/app/common/src/main/java/stirling/software/common/util/JobContext.java new file mode 100644 index 0000000000..a413949147 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/JobContext.java @@ -0,0 +1,18 @@ +package stirling.software.common.util; + +/** Thread-local context for passing job ID across async boundaries */ +public class JobContext { + private static final ThreadLocal CURRENT_JOB_ID = new ThreadLocal<>(); + + public static void setJobId(String jobId) { + CURRENT_JOB_ID.set(jobId); + } + + public static String getJobId() { + return CURRENT_JOB_ID.get(); + } + + public static void clear() { + CURRENT_JOB_ID.remove(); + } +} 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 514e162125..3b94fbfbce 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 @@ -96,6 +96,7 @@ public class ProcessExecutor { .getProcessExecutor() .getSessionLimit() .getOcrMyPdfSessionLimit(); + case CFF_CONVERTER -> 1; }; long timeoutMinutes = @@ -150,6 +151,7 @@ public class ProcessExecutor { .getProcessExecutor() .getTimeoutMinutes() .getOcrMyPdfTimeoutMinutes(); + case CFF_CONVERTER -> 5L; }; return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes); }); @@ -302,7 +304,8 @@ public class ProcessExecutor { TESSERACT, QPDF, GHOSTSCRIPT, - OCR_MY_PDF + OCR_MY_PDF, + CFF_CONVERTER } @Setter diff --git a/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java b/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java index 630ac80bff..b1f96f3e95 100644 --- a/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java +++ b/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java @@ -78,6 +78,23 @@ class JobExecutorServiceTest { verify(request).setAttribute(eq("jobId"), anyString()); } + @Test + void shouldExposeJobIdInJobContextDuringSyncExecution() throws Exception { + // Given + Supplier work = stirling.software.common.util.JobContext::getJobId; + + // When + ResponseEntity response = jobExecutorService.runJobGeneric(false, work); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + + var requestJobIdCaptor = ArgumentCaptor.forClass(String.class); + verify(request).setAttribute(eq("jobId"), requestJobIdCaptor.capture()); + assertEquals(requestJobIdCaptor.getValue(), response.getBody()); + } + @Test void shouldRunAsyncJobSuccessfully() throws Exception { // Given 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 fd3ab640d9..8c3a046f49 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 @@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.EndpointConfiguration.DisableReason; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.util.RegexPatternUtils; @@ -97,7 +98,7 @@ public class ExternalAppDepConfig { if (affectedGroups != null) { for (String group : affectedGroups) { List affectedFeatures = getAffectedFeatures(group); - endpointConfiguration.disableGroup(group); + endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY); log.warn( "Missing dependency: {} - Disabling group: {} (Affected features: {})", command, @@ -127,8 +128,8 @@ public class ExternalAppDepConfig { if (!pythonAvailable) { List pythonFeatures = getAffectedFeatures("Python"); List openCVFeatures = getAffectedFeatures("OpenCV"); - endpointConfiguration.disableGroup("Python"); - endpointConfiguration.disableGroup("OpenCV"); + endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY); + endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY); log.warn( "Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}", String.join(", ", pythonFeatures), @@ -146,14 +147,14 @@ public class ExternalAppDepConfig { int exitCode = process.waitFor(); if (exitCode != 0) { List openCVFeatures = getAffectedFeatures("OpenCV"); - endpointConfiguration.disableGroup("OpenCV"); + endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY); log.warn( "OpenCV not available in Python - Disabling OpenCV features: {}", String.join(", ", openCVFeatures)); } } catch (Exception e) { List openCVFeatures = getAffectedFeatures("OpenCV"); - endpointConfiguration.disableGroup("OpenCV"); + endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY); log.warn( "Error checking OpenCV: {} - Disabling OpenCV features: {}", e.getMessage(), diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java b/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java index 08362fa0be..5cb48c76fb 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java @@ -2,6 +2,9 @@ package stirling.software.SPDF.config; import java.io.IOException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -9,17 +12,14 @@ import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.service.WeeklyActiveUsersService; /** - * Filter to track browser IDs for Weekly Active Users (WAU) counting. - * Only active when security is disabled (no-login mode). + * Filter to track browser IDs for Weekly Active Users (WAU) counting. Only active when security is + * disabled (no-login mode). */ @Component @ConditionalOnProperty(name = "security.enableLogin", havingValue = "false") 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 a4a169cd91..750bfab48f 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 @@ -1,6 +1,7 @@ package stirling.software.SPDF.controller.api.misc; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.context.ApplicationContext; @@ -10,9 +11,13 @@ import org.springframework.web.bind.annotation.RequestParam; import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability; import stirling.software.SPDF.config.InitialSetup; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; @@ -200,4 +205,19 @@ public class ConfigController { } return ResponseEntity.ok(result); } + + @GetMapping("/endpoints-availability") + public ResponseEntity> getEndpointAvailability( + @RequestParam(name = "endpoints") + @Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints") + List<@NotBlank String> endpoints) { + Map result = new HashMap<>(); + for (String endpoint : endpoints) { + String trimmedEndpoint = endpoint.trim(); + result.put( + trimmedEndpoint, + endpointConfiguration.getEndpointAvailability(trimmedEndpoint)); + } + return ResponseEntity.ok(result); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java index 486cd2d120..45935c6058 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java @@ -196,9 +196,9 @@ public class StampController { resourceDir = switch (alphabet) { case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf"; - case "japanese" -> "static/fonts/Meiryo.ttf"; - case "korean" -> "static/fonts/malgun.ttf"; - case "chinese" -> "static/fonts/SimSun.ttf"; + case "japanese" -> "static/fonts/NotoSansJP-Regular.ttf"; + case "korean" -> "static/fonts/NotoSansKR-Regular.ttf"; + case "chinese" -> "static/fonts/NotoSansSC-Regular.ttf"; case "thai" -> "static/fonts/NotoSansThai-Regular.ttf"; case "roman" -> "static/fonts/NotoSans-Regular.ttf"; default -> "static/fonts/NotoSans-Regular.ttf"; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index 3c475a354e..6afcf25edc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -171,9 +171,9 @@ public class WatermarkController { resourceDir = switch (alphabet) { case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf"; - case "japanese" -> "static/fonts/Meiryo.ttf"; - case "korean" -> "static/fonts/malgun.ttf"; - case "chinese" -> "static/fonts/SimSun.ttf"; + case "japanese" -> "static/fonts/NotoSansJP-Regular.ttf"; + case "korean" -> "static/fonts/NotoSansKR-Regular.ttf"; + case "chinese" -> "static/fonts/NotoSansSC-Regular.ttf"; case "thai" -> "static/fonts/NotoSansThai-Regular.ttf"; default -> "static/fonts/NotoSans-Regular.ttf"; }; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index 53e60a6b5a..92b3de92fe 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -369,7 +369,8 @@ public class MetricsController { // Check if WAU service is available (only when security.enableLogin=false) if (wauService.isEmpty()) { return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body("WAU tracking is only available when security is disabled (no-login mode)"); + .body( + "WAU tracking is only available when security is disabled (no-login mode)"); } WeeklyActiveUsersService service = wauService.get(); diff --git a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java index ddf3a7b265..3b1ae1d048 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java @@ -10,8 +10,8 @@ import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; /** - * Service for tracking Weekly Active Users (WAU) in no-login mode. - * Uses in-memory storage with automatic cleanup of old entries. + * Service for tracking Weekly Active Users (WAU) in no-login mode. Uses in-memory storage with + * automatic cleanup of old entries. */ @Service @Slf4j @@ -28,6 +28,7 @@ public class WeeklyActiveUsersService { /** * Records a browser access with the current timestamp + * * @param browserId Unique browser identifier from X-Browser-Id header */ public void recordBrowserAccess(String browserId) { @@ -46,6 +47,7 @@ public class WeeklyActiveUsersService { /** * Gets the count of unique browsers seen in the last 7 days + * * @return Weekly Active Users count */ public long getWeeklyActiveUsers() { @@ -55,6 +57,7 @@ public class WeeklyActiveUsersService { /** * Gets the total count of unique browsers ever seen + * * @return Total unique browsers count */ public long getTotalUniqueBrowsers() { @@ -63,6 +66,7 @@ public class WeeklyActiveUsersService { /** * Gets the number of days the service has been running + * * @return Days online */ public long getDaysOnline() { @@ -71,23 +75,20 @@ public class WeeklyActiveUsersService { /** * Gets the timestamp when tracking started + * * @return Start time */ public Instant getStartTime() { return startTime; } - /** - * Removes entries older than 7 days - */ + /** Removes entries older than 7 days */ private void cleanupOldEntries() { Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS); activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo)); } - /** - * Manual cleanup trigger (can be called by scheduled task if needed) - */ + /** Manual cleanup trigger (can be called by scheduled task if needed) */ public void performCleanup() { int sizeBefore = activeBrowsers.size(); cleanupOldEntries(); diff --git a/app/core/src/main/java/stirling/software/common/controller/JobController.java b/app/core/src/main/java/stirling/software/common/controller/JobController.java index 1a27e5264a..c0c57d92a0 100644 --- a/app/core/src/main/java/stirling/software/common/controller/JobController.java +++ b/app/core/src/main/java/stirling/software/common/controller/JobController.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -24,6 +25,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.ResultFile; import stirling.software.common.service.FileStorage; +import stirling.software.common.service.JobOwnershipService; import stirling.software.common.service.JobQueue; import stirling.software.common.service.TaskManager; import stirling.software.common.util.RegexPatternUtils; @@ -41,6 +43,9 @@ public class JobController { private final JobQueue jobQueue; private final HttpServletRequest request; + @Autowired(required = false) + private JobOwnershipService jobOwnershipService; + /** * Get the status of a job * @@ -50,6 +55,13 @@ public class JobController { @GetMapping("/job/{jobId}") @Operation(summary = "Get job status") public ResponseEntity getJobStatus(@PathVariable("jobId") String jobId) { + // Validate job ownership + if (!validateJobAccess(jobId)) { + log.warn("Unauthorized attempt to access job status: {}", jobId); + return ResponseEntity.status(403) + .body(Map.of("message", "You are not authorized to access this job")); + } + JobResult result = taskManager.getJobResult(jobId); if (result == null) { return ResponseEntity.notFound().build(); @@ -79,6 +91,13 @@ public class JobController { @GetMapping("/job/{jobId}/result") @Operation(summary = "Get job result") public ResponseEntity getJobResult(@PathVariable("jobId") String jobId) { + // Validate job ownership + if (!validateJobAccess(jobId)) { + log.warn("Unauthorized attempt to access job result: {}", jobId); + return ResponseEntity.status(403) + .body(Map.of("message", "You are not authorized to access this job")); + } + JobResult result = taskManager.getJobResult(jobId); if (result == null) { return ResponseEntity.notFound().build(); @@ -144,13 +163,8 @@ public class JobController { public ResponseEntity cancelJob(@PathVariable("jobId") String jobId) { log.debug("Request to cancel job: {}", jobId); - // Verify that this job belongs to the current user - // We can use the current request's session to validate ownership - Object sessionJobIds = request.getSession().getAttribute("userJobIds"); - if (sessionJobIds == null - || !(sessionJobIds instanceof java.util.Set) - || !((java.util.Set) sessionJobIds).contains(jobId)) { - // Either no jobs in session or jobId doesn't match user's jobs + // Validate job ownership + if (!validateJobAccess(jobId)) { log.warn("Unauthorized attempt to cancel job: {}", jobId); return ResponseEntity.status(403) .body(Map.of("message", "You are not authorized to cancel this job")); @@ -210,6 +224,13 @@ public class JobController { @GetMapping("/job/{jobId}/result/files") @Operation(summary = "Get job result files") public ResponseEntity getJobFiles(@PathVariable("jobId") String jobId) { + // Validate job ownership + if (!validateJobAccess(jobId)) { + log.warn("Unauthorized attempt to access job files: {}", jobId); + return ResponseEntity.status(403) + .body(Map.of("message", "You are not authorized to access this job")); + } + JobResult result = taskManager.getJobResult(jobId); if (result == null) { return ResponseEntity.notFound().build(); @@ -330,4 +351,26 @@ public class JobController { return "attachment; filename=\"" + fileName + "\""; } } + + /** + * Validate that the current user has access to the given job. + * + * @param jobId the job identifier to validate + * @return true if user has access, false otherwise + */ + private boolean validateJobAccess(String jobId) { + // If JobOwnershipService is available (security enabled), use it + if (jobOwnershipService != null) { + try { + return jobOwnershipService.validateJobAccess(jobId); + } catch (SecurityException e) { + log.warn("Job ownership validation failed for jobId {}: {}", jobId, e.getMessage()); + return false; + } + } + + // Security disabled - allow all access (backwards compatibility) + // When security is not enabled, any user can access any job by jobId + return true; + } } diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index 18e1f4f8ac..b16e1cc779 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -1,5 +1,6 @@ multipart.enabled=true logging.level.org.springframework=WARN +logging.level.org.springframework.security=WARN logging.level.org.hibernate=WARN logging.level.org.eclipse.jetty=WARN #logging.level.org.springframework.security.oauth2=DEBUG @@ -7,6 +8,9 @@ logging.level.org.eclipse.jetty=WARN #logging.level.org.opensaml=DEBUG #logging.level.stirling.software.proprietary.security=DEBUG logging.level.com.zaxxer.hikari=WARN +logging.level.stirling.software.SPDF.service.PdfJsonConversionService=INFO +logging.level.stirling.software.common.service.JobExecutorService=INFO +logging.level.stirling.software.common.service.TaskManager=INFO spring.jpa.open-in-view=false server.forward-headers-strategy=NATIVE server.error.path=/error diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 139260872c..7e9438ac52 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -174,6 +174,23 @@ 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) diff --git a/app/core/src/main/resources/static/fonts/DejaVuSans-Bold.ttf b/app/core/src/main/resources/static/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000000..6d65fa7dc4 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSans-Bold.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSans-BoldOblique.ttf b/app/core/src/main/resources/static/fonts/DejaVuSans-BoldOblique.ttf new file mode 100644 index 0000000000..753f2d80b1 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSans-BoldOblique.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSans-Oblique.ttf b/app/core/src/main/resources/static/fonts/DejaVuSans-Oblique.ttf new file mode 100644 index 0000000000..999bac7714 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSans-Oblique.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSans.ttf b/app/core/src/main/resources/static/fonts/DejaVuSans.ttf new file mode 100644 index 0000000000..e5f7eecce4 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSans.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSansMono-Bold.ttf b/app/core/src/main/resources/static/fonts/DejaVuSansMono-Bold.ttf new file mode 100644 index 0000000000..8184ced8cf Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSansMono-Bold.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSansMono-BoldOblique.ttf b/app/core/src/main/resources/static/fonts/DejaVuSansMono-BoldOblique.ttf new file mode 100644 index 0000000000..754dca7325 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSansMono-BoldOblique.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSansMono-Oblique.ttf b/app/core/src/main/resources/static/fonts/DejaVuSansMono-Oblique.ttf new file mode 100644 index 0000000000..4c858d401a Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSansMono-Oblique.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSansMono.ttf b/app/core/src/main/resources/static/fonts/DejaVuSansMono.ttf new file mode 100644 index 0000000000..f5786022f1 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSansMono.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSerif-Bold.ttf b/app/core/src/main/resources/static/fonts/DejaVuSerif-Bold.ttf new file mode 100644 index 0000000000..3bb755fa1e Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSerif-Bold.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSerif-BoldItalic.ttf b/app/core/src/main/resources/static/fonts/DejaVuSerif-BoldItalic.ttf new file mode 100644 index 0000000000..a36dd4b70a Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSerif-BoldItalic.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSerif-Italic.ttf b/app/core/src/main/resources/static/fonts/DejaVuSerif-Italic.ttf new file mode 100644 index 0000000000..805daf2228 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSerif-Italic.ttf differ diff --git a/app/core/src/main/resources/static/fonts/DejaVuSerif.ttf b/app/core/src/main/resources/static/fonts/DejaVuSerif.ttf new file mode 100644 index 0000000000..0b803d206c Binary files /dev/null and b/app/core/src/main/resources/static/fonts/DejaVuSerif.ttf differ diff --git a/app/core/src/main/resources/static/fonts/LiberationMono-Bold.ttf b/app/core/src/main/resources/static/fonts/LiberationMono-Bold.ttf new file mode 100644 index 0000000000..2e46737ac7 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/LiberationMono-Bold.ttf differ diff --git a/app/core/src/main/resources/static/fonts/LiberationMono-BoldItalic.ttf b/app/core/src/main/resources/static/fonts/LiberationMono-BoldItalic.ttf new file mode 100644 index 0000000000..d1f46d7cd8 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/LiberationMono-BoldItalic.ttf differ diff --git a/app/core/src/main/resources/static/fonts/LiberationMono-Italic.ttf b/app/core/src/main/resources/static/fonts/LiberationMono-Italic.ttf new file mode 100644 index 0000000000..954c39436f Binary files /dev/null and b/app/core/src/main/resources/static/fonts/LiberationMono-Italic.ttf differ diff --git a/app/core/src/main/resources/static/fonts/LiberationMono-Regular.ttf b/app/core/src/main/resources/static/fonts/LiberationMono-Regular.ttf new file mode 100644 index 0000000000..e774859cba Binary files /dev/null and b/app/core/src/main/resources/static/fonts/LiberationMono-Regular.ttf differ diff --git a/app/core/src/main/resources/static/pdfjs-legacy/standard_fonts/LiberationSans-Bold.ttf b/app/core/src/main/resources/static/fonts/LiberationSans-Bold.ttf similarity index 100% rename from app/core/src/main/resources/static/pdfjs-legacy/standard_fonts/LiberationSans-Bold.ttf rename to app/core/src/main/resources/static/fonts/LiberationSans-Bold.ttf diff --git a/app/core/src/main/resources/static/pdfjs-legacy/standard_fonts/LiberationSans-BoldItalic.ttf b/app/core/src/main/resources/static/fonts/LiberationSans-BoldItalic.ttf similarity index 100% rename from app/core/src/main/resources/static/pdfjs-legacy/standard_fonts/LiberationSans-BoldItalic.ttf rename to app/core/src/main/resources/static/fonts/LiberationSans-BoldItalic.ttf diff --git a/app/core/src/main/resources/static/pdfjs-legacy/standard_fonts/LiberationSans-Italic.ttf b/app/core/src/main/resources/static/fonts/LiberationSans-Italic.ttf similarity index 100% rename from app/core/src/main/resources/static/pdfjs-legacy/standard_fonts/LiberationSans-Italic.ttf rename to app/core/src/main/resources/static/fonts/LiberationSans-Italic.ttf diff --git a/app/core/src/main/resources/static/pdfjs-legacy/standard_fonts/LiberationSans-Regular.ttf b/app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf similarity index 100% rename from app/core/src/main/resources/static/pdfjs-legacy/standard_fonts/LiberationSans-Regular.ttf rename to app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf diff --git a/app/core/src/main/resources/static/fonts/LiberationSerif-Bold.ttf b/app/core/src/main/resources/static/fonts/LiberationSerif-Bold.ttf new file mode 100644 index 0000000000..3c7c55b575 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/LiberationSerif-Bold.ttf differ diff --git a/app/core/src/main/resources/static/fonts/LiberationSerif-BoldItalic.ttf b/app/core/src/main/resources/static/fonts/LiberationSerif-BoldItalic.ttf new file mode 100644 index 0000000000..6b35d9f7c1 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/LiberationSerif-BoldItalic.ttf differ diff --git a/app/core/src/main/resources/static/fonts/LiberationSerif-Italic.ttf b/app/core/src/main/resources/static/fonts/LiberationSerif-Italic.ttf new file mode 100644 index 0000000000..54d516481c Binary files /dev/null and b/app/core/src/main/resources/static/fonts/LiberationSerif-Italic.ttf differ diff --git a/app/core/src/main/resources/static/fonts/LiberationSerif-Regular.ttf b/app/core/src/main/resources/static/fonts/LiberationSerif-Regular.ttf new file mode 100644 index 0000000000..5e5550c0af Binary files /dev/null and b/app/core/src/main/resources/static/fonts/LiberationSerif-Regular.ttf differ diff --git a/app/core/src/main/resources/static/fonts/Meiryo.ttf b/app/core/src/main/resources/static/fonts/Meiryo.ttf deleted file mode 100644 index a608fbb4f6..0000000000 Binary files a/app/core/src/main/resources/static/fonts/Meiryo.ttf and /dev/null differ diff --git a/app/core/src/main/resources/static/fonts/NotoSans-Bold.ttf b/app/core/src/main/resources/static/fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000000..21fbbcc6d6 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/NotoSans-Bold.ttf differ diff --git a/app/core/src/main/resources/static/fonts/NotoSans-BoldItalic.ttf b/app/core/src/main/resources/static/fonts/NotoSans-BoldItalic.ttf new file mode 100644 index 0000000000..8faac05c22 Binary files /dev/null and b/app/core/src/main/resources/static/fonts/NotoSans-BoldItalic.ttf differ diff --git a/app/core/src/main/resources/static/fonts/NotoSans-Italic.ttf b/app/core/src/main/resources/static/fonts/NotoSans-Italic.ttf new file mode 100644 index 0000000000..76c5e1a7cb Binary files /dev/null and b/app/core/src/main/resources/static/fonts/NotoSans-Italic.ttf differ diff --git a/app/core/src/main/resources/static/fonts/SimSun.ttf b/app/core/src/main/resources/static/fonts/NotoSansKR-Regular.ttf similarity index 56% rename from app/core/src/main/resources/static/fonts/SimSun.ttf rename to app/core/src/main/resources/static/fonts/NotoSansKR-Regular.ttf index e0115abeb4..b386890ba9 100644 Binary files a/app/core/src/main/resources/static/fonts/SimSun.ttf and b/app/core/src/main/resources/static/fonts/NotoSansKR-Regular.ttf differ diff --git a/app/core/src/main/resources/static/fonts/NotoSansSC-Regular.ttf b/app/core/src/main/resources/static/fonts/NotoSansSC-Regular.ttf index c10d2aa103..fb0637bafb 100644 Binary files a/app/core/src/main/resources/static/fonts/NotoSansSC-Regular.ttf and b/app/core/src/main/resources/static/fonts/NotoSansSC-Regular.ttf differ diff --git a/app/core/src/main/resources/static/fonts/malgun.ttf b/app/core/src/main/resources/static/fonts/malgun.ttf deleted file mode 100644 index 6d8645bc7a..0000000000 Binary files a/app/core/src/main/resources/static/fonts/malgun.ttf and /dev/null differ diff --git a/app/core/src/main/resources/static/fonts/static/NotoSansArabic-Regular.ttf b/app/core/src/main/resources/static/fonts/static/NotoSansArabic-Regular.ttf deleted file mode 100644 index 79359c460b..0000000000 Binary files a/app/core/src/main/resources/static/fonts/static/NotoSansArabic-Regular.ttf and /dev/null differ diff --git a/app/core/src/main/resources/static/fonts/static/NotoSansJP-Regular.ttf b/app/core/src/main/resources/static/fonts/static/NotoSansJP-Regular.ttf deleted file mode 100644 index 1583096a2d..0000000000 Binary files a/app/core/src/main/resources/static/fonts/static/NotoSansJP-Regular.ttf and /dev/null differ diff --git a/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Pre-publish-sanitization.json b/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Pre-publish-sanitization.json new file mode 100644 index 0000000000..2024f30032 --- /dev/null +++ b/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Pre-publish-sanitization.json @@ -0,0 +1,54 @@ +{ + "name": "Pre-publish-sanitization", + "pipeline": [ + { + "operation": "/api/v1/security/sanitize-pdf", + "parameters": { + "removeJavaScript": true, + "removeEmbeddedFiles": true, + "removeXMPMetadata": true, + "removeMetadata": true, + "removeLinks": true, + "removeFonts": false + } + }, + { + "operation": "/api/v1/misc/flatten", + "parameters": { + "flattenOnlyForms": true + } + }, + { + "operation": "/api/v1/general/remove-annotations", + "parameters": {} + }, + { + "operation": "/api/v1/misc/update-metadata", + "parameters": { + "deleteAll": true, + "author": "", + "creationDate": "", + "creator": "", + "keywords": "", + "modificationDate": "", + "producer": "", + "subject": "", + "title": "", + "trapped": "" + } + }, + { + "operation": "/api/v1/misc/compress-pdf", + "parameters": { + "optimizeLevel": 3, + "expectedOutputSize": "" + } + } + ], + "_examples": { + "outputDir": "{outputFolder}/{folderName}", + "outputFileName": "{filename}-{pipelineName}-{date}-{time}" + }, + "outputDir": "{outputFolder}", + "outputFileName": "pre_publish_{filename}.PDF" +} diff --git a/app/core/src/main/resources/type3/catalogue.json b/app/core/src/main/resources/type3/catalogue.json new file mode 100644 index 0000000000..4aca2f414d --- /dev/null +++ b/app/core/src/main/resources/type3/catalogue.json @@ -0,0 +1,592 @@ +[ + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "1867" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "1888" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "2029" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "2069" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "2089" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "2116" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSansMono", + "encoding": "2174" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans-Oblique", + "encoding": "2192" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "2209" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "Cmsy10", + "encoding": "2228" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "STIXSizeThreeSym-Regular", + "encoding": "2233" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSansDisplay", + "encoding": "2239" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4403" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4438" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4519" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4685" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4733" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4782" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4813" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4834" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSansMono", + "encoding": "4878" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4906" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4929" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "4971" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5001" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5030" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5052" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5083" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5116" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5143" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5175" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5207" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5243" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "Cmr10", + "encoding": "5263" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "Cmex10", + "encoding": "5270" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "Cmsy10", + "encoding": "5275" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "Cmmi10", + "encoding": "5280" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5295" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans-Oblique", + "encoding": "5313" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSansDisplay", + "encoding": "5319" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5334" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5370" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5399" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5427" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5459" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5486" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5513" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5554" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5601" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5647" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5694" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5732" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5771" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5803" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5861" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5904" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5924" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "5951" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "6084" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "6445" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "7195" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "7409" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "7474" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "7708" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "7747" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "7885" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "9029" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "9617" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "10460" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "11445" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans-Bold", + "encoding": "11486" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "11497" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "11543" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "12280" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "12301" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "12350" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "12372" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "12395" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "12416" + }, + { + "source": "01_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "13324" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "3214" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "3251" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "7190" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "9937" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "10792" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "10852" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "14712" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "18396" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "18719" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans-Bold", + "encoding": "18741" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "18778" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "18804" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "20974" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "20993" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "21093" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "21117" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "21141" + }, + { + "source": "02_Matplotlib.pdf", + "fontName": "DejaVuSans", + "encoding": "21174" + }, + { + "source": "03_handout-beginner.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "17" + }, + { + "source": "03_handout-beginner.pdf", + "fontName": "EVICAO+DejaVuSans-Bold", + "encoding": "133" + }, + { + "source": "03_handout-beginner.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "152" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "13" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "85" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "104" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "121" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "135" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "159" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "179" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "198" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "NVMZUP+SourceCodePro-Regular", + "encoding": "208" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "BMQQDV+DejaVuSans", + "encoding": "231" + }, + { + "source": "04_handout-intermediate.pdf", + "fontName": "NVMZUP+SourceCodePro-Regular", + "encoding": "241" + }, + { + "source": "07_matplotlib.pdf", + "fontName": "SauceCodePowerline-Bold", + "encoding": "22" + }, + { + "source": "07_matplotlib.pdf", + "fontName": "SauceCodePowerline-Regular", + "encoding": "47" + }, + { + "source": "07_matplotlib.pdf", + "fontName": "SauceCodePowerline-Regular", + "encoding": "65" + }, + { + "source": "07_matplotlib.pdf", + "fontName": "SauceCodePowerline-Bold", + "encoding": "110" + }, + { + "source": "08_matplotlib.pdf", + "fontName": "F36", + "encoding": "12" + }, + { + "source": "08_matplotlib.pdf", + "fontName": "F59", + "encoding": "42" + } +] \ No newline at end of file diff --git a/app/core/src/main/resources/type3/library/fonts/cm/cmbx10.ttf b/app/core/src/main/resources/type3/library/fonts/cm/cmbx10.ttf new file mode 100644 index 0000000000..2c7198e5d3 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/cm/cmbx10.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/cm/cmmi10.ttf b/app/core/src/main/resources/type3/library/fonts/cm/cmmi10.ttf new file mode 100644 index 0000000000..bd30a3f977 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/cm/cmmi10.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/cm/cmr10.ttf b/app/core/src/main/resources/type3/library/fonts/cm/cmr10.ttf new file mode 100644 index 0000000000..1c3fff0a6e Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/cm/cmr10.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/cm/cmss10.ttf b/app/core/src/main/resources/type3/library/fonts/cm/cmss10.ttf new file mode 100644 index 0000000000..d7513c86a6 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/cm/cmss10.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/cm/cmsy10.ttf b/app/core/src/main/resources/type3/library/fonts/cm/cmsy10.ttf new file mode 100644 index 0000000000..45d8421a5b Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/cm/cmsy10.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/cm/cmti10.ttf b/app/core/src/main/resources/type3/library/fonts/cm/cmti10.ttf new file mode 100644 index 0000000000..993d5c029f Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/cm/cmti10.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/cm/cmtt10.ttf b/app/core/src/main/resources/type3/library/fonts/cm/cmtt10.ttf new file mode 100644 index 0000000000..1651877dbe Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/cm/cmtt10.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-Bold.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-Bold.ttf new file mode 100644 index 0000000000..6d65fa7dc4 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-Bold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-BoldOblique.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-BoldOblique.ttf new file mode 100644 index 0000000000..753f2d80b1 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-BoldOblique.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-ExtraLight.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-ExtraLight.ttf new file mode 100644 index 0000000000..b09f32d7d6 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-ExtraLight.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-Oblique.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-Oblique.ttf new file mode 100644 index 0000000000..999bac7714 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans-Oblique.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans.ttf new file mode 100644 index 0000000000..e5f7eecce4 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSans.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed-Bold.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed-Bold.ttf new file mode 100644 index 0000000000..22987c62d9 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed-Bold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed-BoldOblique.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed-BoldOblique.ttf new file mode 100644 index 0000000000..f5fa0ca26f Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed-BoldOblique.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed-Oblique.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed-Oblique.ttf new file mode 100644 index 0000000000..7fde907894 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed-Oblique.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed.ttf new file mode 100644 index 0000000000..3259bc21ae Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansCondensed.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono-Bold.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono-Bold.ttf new file mode 100644 index 0000000000..8184ced8cf Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono-Bold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono-BoldOblique.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono-BoldOblique.ttf new file mode 100644 index 0000000000..754dca7325 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono-BoldOblique.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono-Oblique.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono-Oblique.ttf new file mode 100644 index 0000000000..4c858d401a Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono-Oblique.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono.ttf new file mode 100644 index 0000000000..f5786022f1 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSansMono.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif-Bold.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif-Bold.ttf new file mode 100644 index 0000000000..3bb755fa1e Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif-Bold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif-BoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif-BoldItalic.ttf new file mode 100644 index 0000000000..a36dd4b70a Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif-BoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif-Italic.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif-Italic.ttf new file mode 100644 index 0000000000..805daf2228 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif-Italic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif.ttf new file mode 100644 index 0000000000..0b803d206c Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerif.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed-Bold.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed-Bold.ttf new file mode 100644 index 0000000000..222bf134bc Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed-Bold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed-BoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed-BoldItalic.ttf new file mode 100644 index 0000000000..e44663695d Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed-BoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed-Italic.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed-Italic.ttf new file mode 100644 index 0000000000..c529df31bf Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed-Italic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed.ttf b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed.ttf new file mode 100644 index 0000000000..d3959b3228 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/dejavu/DejaVuSerifCondensed.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-Bold.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-Bold.ttf new file mode 100644 index 0000000000..2e46737ac7 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-Bold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-BoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-BoldItalic.ttf new file mode 100644 index 0000000000..d1f46d7cd8 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-BoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-Italic.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-Italic.ttf new file mode 100644 index 0000000000..954c39436f Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-Italic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-Regular.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-Regular.ttf new file mode 100644 index 0000000000..e774859cba Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationMono-Regular.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-Bold.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-Bold.ttf new file mode 100644 index 0000000000..dc5d57f15f Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-Bold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-BoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-BoldItalic.ttf new file mode 100644 index 0000000000..158488a12e Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-BoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-Italic.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-Italic.ttf new file mode 100644 index 0000000000..25970d9d57 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-Italic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-Regular.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-Regular.ttf new file mode 100644 index 0000000000..e6339859d0 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSans-Regular.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-Bold.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-Bold.ttf new file mode 100644 index 0000000000..3c7c55b575 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-Bold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-BoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-BoldItalic.ttf new file mode 100644 index 0000000000..6b35d9f7c1 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-BoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-Italic.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-Italic.ttf new file mode 100644 index 0000000000..54d516481c Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-Italic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-Regular.ttf b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-Regular.ttf new file mode 100644 index 0000000000..5e5550c0af Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/liberation/LiberationSerif-Regular.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Black.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Black.ttf new file mode 100644 index 0000000000..0974247d7c Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Black.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-BlackItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-BlackItalic.ttf new file mode 100644 index 0000000000..5bea657fce Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-BlackItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Bold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Bold.ttf new file mode 100644 index 0000000000..21fbbcc6d6 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Bold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-BoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-BoldItalic.ttf new file mode 100644 index 0000000000..8faac05c22 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-BoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Condensed.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Condensed.ttf new file mode 100644 index 0000000000..a55d3fcfb3 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Condensed.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBlack.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBlack.ttf new file mode 100644 index 0000000000..fa2163405d Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBlack.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBlackItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBlackItalic.ttf new file mode 100644 index 0000000000..4f4e6d1e9c Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBlackItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBold.ttf new file mode 100644 index 0000000000..4912d87fcf Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBoldItalic.ttf new file mode 100644 index 0000000000..57c3b43095 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraBold.ttf new file mode 100644 index 0000000000..4acd19d2ed Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraBold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraBoldItalic.ttf new file mode 100644 index 0000000000..addcb56f5b Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraLight.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraLight.ttf new file mode 100644 index 0000000000..54082b208e Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraLight.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraLightItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraLightItalic.ttf new file mode 100644 index 0000000000..87a0624e0a Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedExtraLightItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedItalic.ttf new file mode 100644 index 0000000000..8a6de5cad9 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedLight.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedLight.ttf new file mode 100644 index 0000000000..02e817c70e Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedLight.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedLightItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedLightItalic.ttf new file mode 100644 index 0000000000..67f99d1488 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedLightItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedMedium.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedMedium.ttf new file mode 100644 index 0000000000..515cef661b Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedMedium.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedMediumItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedMediumItalic.ttf new file mode 100644 index 0000000000..1952105389 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedMediumItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedSemiBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedSemiBold.ttf new file mode 100644 index 0000000000..9ce5f3b07a Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedSemiBold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedSemiBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedSemiBoldItalic.ttf new file mode 100644 index 0000000000..f4c36bf2be Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedSemiBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedThin.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedThin.ttf new file mode 100644 index 0000000000..6c754b6d20 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedThin.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedThinItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedThinItalic.ttf new file mode 100644 index 0000000000..73f005a3e2 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-CondensedThinItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraBold.ttf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraBoldItalic.ttf new file mode 100644 index 0000000000..3f48694e50 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensed.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensed.ttf new file mode 100644 index 0000000000..a15499b257 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensed.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBlack.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBlack.ttf new file mode 100644 index 0000000000..3ecae1b52f Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBlack.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBlackItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBlackItalic.ttf new file mode 100644 index 0000000000..7558fcf6fc Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBlackItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBold.ttf new file mode 100644 index 0000000000..e575ce5864 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBoldItalic.ttf new file mode 100644 index 0000000000..7cbadb4b5a Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraBold.ttf new file mode 100644 index 0000000000..ec244607ed Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraBold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraBoldItalic.ttf new file mode 100644 index 0000000000..2bb7179cce Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraLight.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraLight.ttf new file mode 100644 index 0000000000..3568d87557 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraLight.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraLightItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraLightItalic.ttf new file mode 100644 index 0000000000..23a3207e51 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedExtraLightItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedItalic.ttf new file mode 100644 index 0000000000..33f9e1a647 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedLight.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedLight.ttf new file mode 100644 index 0000000000..4cdf575cce Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedLight.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedLightItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedLightItalic.ttf new file mode 100644 index 0000000000..09c998752a Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedLightItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedMedium.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedMedium.ttf new file mode 100644 index 0000000000..0a4d4c480f Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedMedium.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedMediumItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedMediumItalic.ttf new file mode 100644 index 0000000000..4c572c9c3e Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedMediumItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedSemiBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedSemiBold.ttf new file mode 100644 index 0000000000..8ef21c4b9f Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedSemiBold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedSemiBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedSemiBoldItalic.ttf new file mode 100644 index 0000000000..0b57703013 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedSemiBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedThin.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedThin.ttf new file mode 100644 index 0000000000..5d4d0e4837 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedThin.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedThinItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedThinItalic.ttf new file mode 100644 index 0000000000..5d8c0bec82 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraCondensedThinItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraLight.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraLight.ttf new file mode 100644 index 0000000000..8aaf9ba7a7 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraLight.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraLightItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraLightItalic.ttf new file mode 100644 index 0000000000..d324a64249 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ExtraLightItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Italic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Italic.ttf new file mode 100644 index 0000000000..76c5e1a7cb Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Italic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Italic[wdth,wght].ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Italic[wdth,wght].ttf new file mode 100644 index 0000000000..deb23b6bcb Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Italic[wdth,wght].ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Italic[wght].ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Italic[wght].ttf new file mode 100644 index 0000000000..a77768fcca Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Italic[wght].ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Light.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Light.ttf new file mode 100644 index 0000000000..d56d2444eb Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Light.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-LightItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-LightItalic.ttf new file mode 100644 index 0000000000..5a5338f967 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-LightItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Medium.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Medium.ttf new file mode 100644 index 0000000000..831f8f0154 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Medium.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-MediumItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-MediumItalic.ttf new file mode 100644 index 0000000000..6f207f78e3 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-MediumItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Regular.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Regular.ttf new file mode 100644 index 0000000000..546cd89769 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Regular.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiBold.ttf new file mode 100644 index 0000000000..611bbd50c3 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiBold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiBoldItalic.ttf new file mode 100644 index 0000000000..8cd12e2ee8 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensed.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensed.ttf new file mode 100644 index 0000000000..c12b84be09 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensed.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBlack.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBlack.ttf new file mode 100644 index 0000000000..283eab70ed Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBlack.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBlackItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBlackItalic.ttf new file mode 100644 index 0000000000..a3ccf73edf Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBlackItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBold.ttf new file mode 100644 index 0000000000..9995d5bab7 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBoldItalic.ttf new file mode 100644 index 0000000000..e8c2a6838f Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraBold.ttf new file mode 100644 index 0000000000..0c7b6c330f Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraBold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraBoldItalic.ttf new file mode 100644 index 0000000000..2d0ad283fe Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraLight.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraLight.ttf new file mode 100644 index 0000000000..c6630c5f7e Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraLight.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraLightItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraLightItalic.ttf new file mode 100644 index 0000000000..828cd48be7 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedExtraLightItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedItalic.ttf new file mode 100644 index 0000000000..8ab05404c0 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedLight.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedLight.ttf new file mode 100644 index 0000000000..4ad428541c Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedLight.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedLightItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedLightItalic.ttf new file mode 100644 index 0000000000..975c4ef2f9 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedLightItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedMedium.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedMedium.ttf new file mode 100644 index 0000000000..57cb29cb06 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedMedium.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedMediumItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedMediumItalic.ttf new file mode 100644 index 0000000000..cc4423af3a Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedMediumItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedSemiBold.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedSemiBold.ttf new file mode 100644 index 0000000000..f790ede207 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedSemiBold.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedSemiBoldItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedSemiBoldItalic.ttf new file mode 100644 index 0000000000..c81e35733a Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedSemiBoldItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedThin.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedThin.ttf new file mode 100644 index 0000000000..21dcf9c273 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedThin.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedThinItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedThinItalic.ttf new file mode 100644 index 0000000000..1cd1f5d25a Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-SemiCondensedThinItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Thin.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Thin.ttf new file mode 100644 index 0000000000..c4ba58e4f7 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-Thin.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ThinItalic.ttf b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ThinItalic.ttf new file mode 100644 index 0000000000..43f1becdd1 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/noto/NotoSans-ThinItalic.ttf differ diff --git a/app/core/src/main/resources/type3/library/fonts/scp/SauceCodeProNerdFont-Regular.ttf b/app/core/src/main/resources/type3/library/fonts/scp/SauceCodeProNerdFont-Regular.ttf new file mode 100644 index 0000000000..54ceb7ce22 --- /dev/null +++ b/app/core/src/main/resources/type3/library/fonts/scp/SauceCodeProNerdFont-Regular.ttf @@ -0,0 +1,1882 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ryanoasis (Ryan L McIntyre) · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+
+ View ryanoasis's full-sized avatar + +
+
+ +
+
+
+
💻
+
+
+
🏗️ 🔣 🌐 🐚
+ +
+
+
+ +
+
+ +
+ + +
+ +
+ +
+
+
+
💻
+
+
+
🏗️ 🔣 🌐 🐚
+ +
+
+
+ +
+ + +
+
+
+
+ + + + + + +
+ +
+ + +
+ + +
+ + + + + + +
+ +
+ +
+
+

Sponsors

+ +
+
+ @gshpychka +
+ +
+ @InfoSec812 +
+ + +
+
+ +
+ + + +
+

Organizations

+ + + @EpicGames + + @VundleVim + + @NerdFonts + + @Powerlevel9k +
+ + + + + +
+
+
+

+ Block or report ryanoasis +

+ +
+
+ +
+
+ +
+ +
+
+ + + + Block user +

+ Prevent this user from interacting with your repositories and sending you notifications. + Learn more about blocking users. +

+ + + + + +

+ You must be logged in to block users. +

+ +
+
+ +
+ +
+ + Maximum 250 characters. Please don't include any personal information such as legal names or email addresses. Markdown supported. This note will be visible to only you. + +
+ + +
+ + +
+
+ Report abuse +

+ Contact GitHub support about this user’s behavior. + Learn more about reporting abuse. +

+ Report abuse +
+
+
+ +
+ +
+ +
+
+
+ + +
+ + + + + + + +
+
+

+ Popular repositories + + Loading + + +

+ +
    + +
  1. +
    +
    +
    + + nerd-fonts + nerd-fonts + Public + +
    + + +

    + Iconic font aggregator, collection, & patcher. 3,600+ icons, 50+ patched fonts: Hack, Source Code Pro, more. Glyph collections: Font Awesome, Material Design Icons, Octicons, & more +

    + +

    + + + CSS + + + + + + + 60.4k + + + + + + 3.8k + +

    +
    +
    +
  2. + +
  3. +
    +
    +
    + + vim-devicons + vim-devicons + Public + +
    + + +

    + Adds file type icons to Vim plugins such as: NERDTree, vim-airline, CtrlP, unite, Denite, lightline, vim-startify and many more +

    + +

    + + + Vim Script + + + + + + + 5.8k + + + + + + 271 + +

    +
    +
    +
  4. + +
  5. +
    +
    +
    + + powerline-extra-symbols + powerline-extra-symbols + Public + +
    + + +

    + ▶️ Extra glyphs for your powerline separators +

    + +

    + + + PostScript + + + + + + + 1.4k + + + + + + 72 + +

    +
    +
    +
  6. + +
  7. +
    +
    +
    + + public-bash-scripts + public-bash-scripts + Public + +
    + + +

    + Bash Scripts that I can share publicly +

    + +

    + + + Shell + + + + + + + 191 + + + + + + 77 + +

    +
    +
    +
  8. + +
  9. +
    +
    +
    + + dev-interview-study-guide + dev-interview-study-guide + Public + +
    + + +

    + +

    + +

    + + + + + 157 + + + + + + 55 + +

    +
    +
    +
  10. + +
  11. +
    +
    +
    + + devicons-shell + devicons-shell + Public + +
    + + +

    + 🔣 adds font icons (glyphs ★♨☢) to filetypes via bash (faux ls with icons) +

    + +

    + + + Shell + + + + + + + 99 + + + + + + 10 + +

    +
    +
    +
  12. +
+ +
+ +
+ +
+ + +
+ + + + + +
+ + + +
+ + + +
+
+
+
+ +
+
+ +
+

Footer

+ + + + +
+
+ + + + + © 2025 GitHub, Inc. + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + diff --git a/app/core/src/main/resources/type3/library/fonts/stix/STIXSizeThreeSym-Regular.otf b/app/core/src/main/resources/type3/library/fonts/stix/STIXSizeThreeSym-Regular.otf new file mode 100644 index 0000000000..1eda1d949f --- /dev/null +++ b/app/core/src/main/resources/type3/library/fonts/stix/STIXSizeThreeSym-Regular.otf @@ -0,0 +1,2055 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GitHub - stipub/stixfonts: OpenType Unicode fonts for Scientific, Technical, and Mathematical texts + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + + + / + + stixfonts + + + Public +
+ + +
+ +
+ + +
+
+ +
+
+

+ OpenType Unicode fonts for Scientific, Technical, and Mathematical texts +

+ + +

License

+ + + + +
+ +
+ Notifications + You must be signed in to change notification settings + +
+ + + + +
+
+ +
+ + + + +
+ + + + + + +
+ + + + + + +

stipub/stixfonts

+
+
+ +
+
+ + + + + +

Repository files navigation

stixfonts

+

OpenType Unicode fonts for Scientific, Technical, and Mathematical texts

+

Overview

+ +

Type 1 fonts (STIX 2.0.0 only)

+

The STIX Two fonts are OpenType fonts and are meant to be used in that +format. For the benefit of LaTeX users who are unable to use XeTeX or +luaTeX, we have also provided version 2.0.0 of the STIX fonts as a +set of TFM files and Type 1 fonts.

+

Note that no further updates are planned to the Type 1 +distribution; future development efforts will focus on improving the +OpenType fonts.

+

About the STIX fonts.

+

The Scientific and Technical Information eXchange (STIX) fonts are +intended to satisfy the demanding needs of authors, publishers, +printers, and others working in the scientific, medical, and technical +fields. They combine a comprehensive Unicode-based collection of +mathematical symbols and alphabets with a set of text faces suitable +for professional publishing. The fonts are available royalty-free +under the SIL Open Font License.

+

Version 2 of the STIX fonts, now known as "STIX Two", is a thorough +revision undertaken by the renowned type house Tiro Typeworks +Ltd. (https://www.tiro.com). The STIX Two fonts consist of one Math +font, two variable text fonts (STIXTwoTextVF-Roman and +STIXTwoTextVF-Italic), and eight static text fonts (Regular, Italic, +Medium, Medium Italic, SemiBold, SemiBold Italic, Bold, and Bold +Italic) derived from the variable fonts. Together, they provide a +uniform set of fonts that can be used throughout the production +process, whether that be a traditional print-only process, an entirely +electronic one, or a combination of the two.

+

The STIX project began through the joint +efforts of +American Mathematical Society (AMS), +American Institute of Physics (AIP), +American Physical Society (APS), +American Chemical Society (ACS), +The Institute of Electrical and Electronic Engineers (IEEE), +and Elsevier. +These companies are collectively known as the STI Pub companies.

+

A Fresh Take on Times Roman

+

The original version of STIX was based on Times Roman, which has now +been updated for the digital age.

+

As is well known, Times Roman was originally intended for printing the +London Times. What is not generally appreciated is that the +production quality of the Times was atypically high: It was printed +on unusually high-quality paper on presses that operated more slowly +than most newspaper presses. This allowed for the design of a +typeface that could exploit this level of care: serifs could be much +finer and counters (enclosed areas such as that in the lowercase e) +could be much smaller than in other newspaper typefaces. These +features of the font have not always fared well in less exacting +environments. At the same time, a notable quirk of the Times Roman +family is that the bold font is, in many respects, strikingly +dissimilar to the roman font.

+

Tiro Typeworks explain their approach to updating the Times Roman +basis of STIX as follows:

+
+

“Our principal goal in approaching STIX Two was to address several +inherent deficiencies in the Times New Roman model as well as expand +the typographic features. This process necessarily involved +diverging somewhat from Times as familiar to people who have only +known the common digital versions, while simultaneously restoring to +that typeface aspects of the size-appropriate design characteristics +that made it so successful in newspaper, book, and journal +publishing in it’s metal type incarnation. The essential +‘Times-ness’ remains, but are with greater harmonisation of style +across the family.

+

“Most digital versions of Times have been based on an optical size +model that appears too light and fine when scaled down to typical +text sizes. In the design of STIX Two, we went back to specimens of +size-specific designs from the metal era, and adapted proportions, +weights, and spacing of the 10pt and 12pt designs. The oft-noted +mismatch between the style of different weights of Times has been +resolved with a new bold design that matches the construction of the +regular weight.”

+
+

Font implementation decisions

+
    +
  • +

    The STIX fonts do not contain fixed-width or sans serif text faces.

    +
  • +
  • +

    The sans serif, fraktur, script, etc., alphabets in Plane 1 +(U+1D400-U+1D4FF) are intended to be used only as technical symbols.

    +
  • +
  • +

    These fonts are designed to support left-to-right typesetting in +Latin-based scripts, with additional support for Greek and Cyrillic +text. Extensions to support other writing directions have been +considered, but are currently deemed to be outside the scope of the +STIX project.

    +
  • +
+

Note to TeX users

+

These fonts have been tested with both +XeTeX +and +luaTeX +with good results. For best results, XeTeX users will want to use +version 0.999992 or later of XeTeX, which ships with +TeXLive 2020. +This version fixes a number of bugs that were present in earlier +versions. Our thanks go out to Jonathan Kew and Khaled Hosny for +their generous help in identifying and fixing these bugs. LaTeX users +should also make sure they have the latest version of the +amsmath package.

+

Summary of OpenType Features and Scripts

+

Further details these features can be found in the font charts.

+

The text fonts implement the following OpenType script tags:

+
Regular   Bold      Italic    BoldItalic
+
+DFLT      DFLT      DFLT      DFLT          Default
+
+cyrl      cyrl      cyrl      cyrl          Cyrillic
+                    cyrl.MKD  cyrl.MKD      Cyrillic/Macedonian
+                    cyrl.SRB  cyrl.SRB      Cyrillic/Serbian
+
+grek      grek      grek      grek          Greek
+
+latn      latn      latn      latn          Latin
+latn.LTH  latn.LTH  latn.LTH  latn.LTH      Latin/Lithuanian
+latn.ROM  latn.ROM  latn.ROM  latn.ROM      Latin/Romanian
+latn.TRK  latn.TRK  latn.TRK  latn.TRK      Latin/Turkish
+
+

and the following features

+
c2sc    Small Capitals from Capitals
+case    Case-Sensitive Forms
+ccmp    Glyph Composition/Decomposition
+dnom    Denominators
+frac    Fractions
+kern    Kerning
+liga    Standard Ligatures -- latn only
+locl    Localized Forms    -- latn.ROM and Italic/BoldItalic cyrl.MKD only
+numr    Numerators
+onum    Oldstyle Figures
+pnum    Proportional Figures
+smcp    Small Capitals
+subs    Subscript
+sups    Superscript
+
+

All four text fonts also support the following Character Variants:

+
cv01    U+019B Lambda with horizontal, not slanted stroke -- latn only
+cv02    U+0264 Rams horn with serifs -- latn only
+cv03    U+2423 OPEN BOX curved instead of straight
+
+

In addition, the Italic and BoldItalic faces support the following +Stylistic Variants:

+
ss01    Replace two-story g by hooked g      -- Italic/BoldItalic only
+ss02    Upright parens, brackets, and braces -- Italic/BoldItalic only
+
+

STIX Two Math implements the following font features:

+
ccmp    Glyph Composition/Decomposition
+dtls    Dotless forms of i and j
+flac    Flattened accents
+ssty    Math Script style alternates
+
+

and the following Character Variants (note the different meaning of +cv03 compared to the text fonts):

+
cv01    U+019B Lambda with horizontal, not slanted stroke -- latn only
+cv02    U+0264 Rams horn with serifs -- latn only
+cv03    Replace U+2205 EMPTY SET by an oblate form
+cv04    Replace U+2216 SET MINUS by a smaller form
+
+

and the following Stylistic Sets (again, note that ss01 and ss02 have +different meanings compared to the text fonts):

+
ss01    Stylistic Set 1 -- Math chancery to roundhand (\mathcal -> \mathscr)
+ss02    Stylistic Set 2 -- Alternate italic forms: g, u, v, w, z
+ss03    Stylistic Set 3 -- Horizontal crossbar variants
+ss04    Stylistic Set 4 -- Minute, second and primes to long variants
+ss05    Stylistic Set 5 -- Short arrow variants
+ss06    Stylistic Set 6 -- Short/narrow variants
+ss07    Stylistic Set 7 -- Alternate math symbols (product, summation, etc)
+ss08    Stylistic Set 8 -- Upright integral variants; XITS compatible
+ss09    Stylistic Set 9 -- Vertical slash variants; XITS compatible
+ss10    Stylistic Set 10 -- Diagonal greater/lesser combination variants
+ss11    Stylistic Set 11 -- Long slash not-equal combination variants
+ss12    Stylistic Set 12 -- Low contrast (sans-like) variants
+ss13    Stylistic Set 13 -- Horizontally flipped sine wave glyph
+ss14    Stylistic Set 14 -- Tall variants
+ss15    Stylistic Set 15 -- Slab serif symbol variants
+ss16    Stylistic Set 16 -- Circled operator variants
+ss20    Stylistic Set 20 -- Miscellaneous variants
+
+

Build instructions

+

After cloning the project, the fonts can be built using the build.sh script (use --verbose option for more detailed build log):

+
$ ./build.sh
+
+

This may take several minutes to complete. The first time the script is called, it will create a Python virtual environment that will be also used for subsequent builds. Each time the script is called, the fonts will be rebuilt from scratch. The built fonts will be in build subdirectory, and should be manually copied and committed to fonts subdirectory.

+

Notes on source formats and build process

+

The design masters for the STIX Two Text fonts are the .vfj files, a json source format used by FontLab 7. These files contain the glyph outlines, spacing, mark anchors, kerning and associated classes, font info, and variable design space info. Changes or additions to any of these things should be made in the .vfj files.

+

The build script used to generate font files uses the .ufo and .designspace files, not the .vfj sources directly. These files can be exported from FontLab 7 using the default export profile for ‘DesignSpace + UFO’.

+

The .ren files are glyph name management files used by the build script to manage the relationship of development names in the sources to the build names used in the post or CFF tables of the fonts.

+

Because of issues with editing and managing OpenType Layout GPOS in variable font sources, the OTL projects for the STIX Two Text fonts are built in Microsoft’s Visual OpenType Layout Tool (VOLT). This means changes to OTL, including updates to mark anchors and kerning implemented in the .vfj sources need to be passed through VOLT, updated in the .vtp VOLT project files, and compiled in .input.ttf which are then used by the build script as a source for the OTL tables in the fonts.

+

Obviously, any changes or extension to the glyph set in the .vfj design sources needs to be reflected in each of the other sources used in the build process: in the .ufo files, the .ren file glyph name lists, and especially in the .input.ttf files and .vtp project files. Fresh .input.ttf files can be exported from FontLab 7, opened in VOLT, and the .vtp project files imported and updated.

+

Note that if changes or updates are made to mark anchors or kerning or associated classes in the .vfj sources, these need to be converted to VOLT format and imported into the projects, replacing or updating existing VOLT lookups and groups. This can be done using the vfj-to-volt.py tool.

+

The revised .vtp files should then be exported for future use, and the .input.ttf fonts shipped from VOLT (this is important, because although the fonts will work if just compiled and saved in VOLT, they will contain private VOLT source tables and unregistered OTL features that will be then end up in the fonts generated by the build script; so use the ‘Ship Font’ option in VOLT and overwrite the .input.ttf file (save a copy with the VOLT project, if you like, but so long as you remembered to export the updated .vtp you can always reimport as needed)).

+

IMPORTANT : the STIXTwoMath-Regular.input.ttf file is also the source for the MATH table and cmap table in the final font build. Care must be taken to preserve or extend these as necessary in this file when updating OpenType Layout or other aspects of the font.

+

Once all the source files are ready, run the build.sh as described above. The build script describes what it is doing as it runs, and verbose mode can be used to get more detail. In overview, this is what it does:

+
    +
  1. Pre-process the UFO files to: +a) remove all features and kerning groups from the UFOs; b) rename the glyphs to match the TTFs (otherwise the binary tables can’t be grafted in with FontTools easily); c) extract the binary tables and add them under data/com.github.fonttools.ttx/ in the UFO font where ufo2ft expects them; d) save the modified files in build/masters to keep the sources unchanged.
  2. +
  3. Build variable font with fontmake from build/masters UFOs.
  4. +
  5. Build binary masters with fontmake (needed for the next step) from UFOs.
  6. +
  7. Build static fonts with fontmake from UFOs, but telling it to interpolate OTL tables from the binary masters.
  8. +
  9. Post-process the fonts to fix the name tables and other final touchups.
  10. +
+
+
+ + + +
+
+ +
+
+
+
+

About

+ +

+ OpenType Unicode fonts for Scientific, Technical, and Mathematical texts +

+ + +

Resources

+ + + +

License

+ + + + + + + + + + + + + + + + +

Stars

+ + +

Watchers

+ + +

Forks

+ + + + +
+ +
+
+ + + + + + +
+
+ +

+ Packages +

+ + +
+ No packages published
+
+ + + +
+
+ + + + + +
+
+

+ Contributors + 7

+ + + + +
    +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
+ + +
+ + +
+
+ + + +
+
+

Languages

+
+ + + + +
+ + +
+
+ +
+
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+

Footer

+ + + + +
+
+ + + + + © 2025 GitHub, Inc. + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoMath-Regular.otf b/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoMath-Regular.otf new file mode 100644 index 0000000000..29aa3b9415 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoMath-Regular.otf differ diff --git a/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoText-Bold.otf b/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoText-Bold.otf new file mode 100644 index 0000000000..e617a64192 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoText-Bold.otf differ diff --git a/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoText-Italic.otf b/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoText-Italic.otf new file mode 100644 index 0000000000..19599296e9 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoText-Italic.otf differ diff --git a/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoText-Regular.otf b/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoText-Regular.otf new file mode 100644 index 0000000000..630bf714c1 Binary files /dev/null and b/app/core/src/main/resources/type3/library/fonts/stix/STIXTwoText-Regular.otf differ diff --git a/app/core/src/main/resources/type3/library/index.json b/app/core/src/main/resources/type3/library/index.json new file mode 100644 index 0000000000..b3a83d431b --- /dev/null +++ b/app/core/src/main/resources/type3/library/index.json @@ -0,0 +1,1643 @@ +[ + { + "id": "dejavu-sans-regular", + "label": "DejaVu Sans", + "aliases": [ + "DejaVuSans", + "dejavusans" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSans.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSans.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSans.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [ + "sha256:2be58b6ef1e29a83b8634d70b9e32c37a15dea2e608894439ef7224c35b77f5d", + "sha256:994c963d70041eee141fd275fa22c525a71283de2b4a952814d02e0bbfa8caea", + "sha256:93573cb1ab32b9cb09378298fb120de079f6a309908d2ee86f91392a6aba5c31", + "sha256:4febfad91e0141f9658506a0bf8fc2a449f0ea7d97b44e95fc9a970c77af4b0a", + "sha256:0386e5811612ba4b998d57cd3869d7fbc48092a79d436deda774af107a4af813", + "sha256:b95fa2a272cbc950b81320790d04fcf19ebb24050fa2139ba6a474172cac596b", + "sha256:d034d16ac69e3e1c5008e77c4c24dc3179308a2742601e89d5c8ab327e4040dd", + "sha256:ae77c4eb2c49f72c616272f3d7ac624ddb0b4db1c77acbe6b9d13531f68e1d5d", + "sha256:85e16e36ed0290c149647be7e468a7c46e7b66fd290131213040f7bad905aa44", + "sha256:3654d4d9bcbbf6ad51628082203094069a17aad3a5e6f5c7972833566e42ab6b", + "sha256:d0c6cddc416d130701395246621a0f669fc292df4097a7a74395602faf4475df", + "sha256:cadf43a2df81340368af44c76b499223931d78dcc76c70cf4b4a93d133e368af", + "sha256:f1a874c4268b1bffffc99acabbe0a60aa662611b4bac0e688e4fc0ae3f2033bb", + "sha256:e3d87c113463c8642a4f22943064fd75c133ad31fe5efebf6de6abf211b74b5a", + "sha256:d47afb7581e98f588f0e70953e8692249aaa2ec3df36fbd90985f27b1ce1cf50", + "sha256:e47b8f112a875361a43bcb6d9c6467e0296412d29e417e58a0e60c90b664d281", + "sha256:9c67df2ac5c3dcf957dfb0cd048fa450322a72b5a2dfb05f816c536b3b090607", + "sha256:3ee773a0af6fdedb9853dca9f4d8b80a421a0024bdf06bea41f15d58e3b90c87", + "sha256:4fa06c90399d80b41cb718163a5d78af2b203df6b6579246fb0b24d349b7a591", + "sha256:ac6756c76d6e43b771cc1e643dfc7891dfaaac05aa5e302190d0a662838ab031", + "sha256:bf7b95498f7d00d228c5c155db62b6c1aa7e0215cca1690c9fdb0adcfd496b10", + "sha256:39b8e5ec8e20a788cd45166baf0ab796397f152c9cd8dec1f882c635380cad92", + "sha256:27b98489865df8df55f19e4505c093501f236465885ca3bf5b66b6f047a85bb2", + "sha256:497ddd27e1f56ef6504c61613e3a159bab13314a4970a3be13b3a556648964da", + "sha256:3b41f9e5f3a7ffa6f4cdffa2a46f02781ec1b2b0c99994707cfb139aa15a11e2", + "sha256:93723fe436a1aa654db56caf133f56280444b9dc0682af50b83787c3e49ee3ec", + "sha256:a648cb0524465bcb3bf4a2f65e0761cfc5167b1871a7db9488bee11b56062727", + "sha256:2f18ed7f982aeb954aaae388ba0c75e3c676717ca324156b42bb17f3f20ef403", + "sha256:18ce863feb57f42f2b92ac85a8c55ef3eeaa15488c5d6cd8c724b085994c64fa", + "sha256:a3eb7054e426aad7d1fac1f39ad6d3f886e34c04f780def5cf22b53cb3a45b46", + "sha256:edd22119635bfb0f2bff750137c6c6400a7fae4ff80cc252d2e6f2ca88f599a7", + "sha256:aae1797f3e3ff55d71b02590333aff86663d6bb4a5768bed7550e5987f40afe8", + "sha256:0165552fad28860f2ea6079be7a87ea0833acde99309b3ef619c8f81707c46a3", + "sha256:792a1c5aaa1743ab203a363a8f6cd07c3b043e33c72e97c4ea21f5862158e6c1", + "sha256:f4bfd64f36bf33dea79800561a67f78d5ccdb436363574abf0892f58b376a2e6", + "sha256:119da04d962622c8aa46d77f6bdfccb5d4a4ef7173775275b046efd59098e5d9", + "sha256:003af1c45e3a5ab09544e226eba25e3a70abfe6e36dd48584474cc7a497685f6", + "sha256:88b3471db1978cc83233f249453806a8369c766b089b424c86c2584196ed5dbf", + "sha256:a15cc90b7fc110cef4f07fe8a692d572e1289a9ee29c95732294662fded4e042", + "sha256:fb54c23aa081562ac114676ffe43032c9c0fb63af3e5b7b3441b88872d1f2e7a", + "sha256:4b553d51d58f5891af071359fb016caf1c6137778da129a6b208dcc8cb0c4635", + "sha256:b318f65b9dc209eb6f004e3a6c20a772ebbca3d752adc10c66a6a8a479da2838", + "sha256:64f725573c1f5d90196e94ed338a7af06baf274420414befeb9693c80acd0f77", + "sha256:9a701e082ba5a779e2b20b8de0c7844b3f7838ba8cd4bd7ef366893761fb994d", + "sha256:2f6f8d63ff6235f3b7cd6f5eba8076854892037afa2ea6962953b3e7cda3736e", + "sha256:f17b5eb0ee996d1388c548f79fa50fa2d8c6076959eff189bb745d156d54547f", + "sha256:f22c75548364bb25fc3efbe11f05c56e29f07c15c3046ddbc85a64e5cc5a97bd", + "sha256:54a6c2e4bc290b48e21eece7f81cb6633c4b53a91f198fdaabfc73743b0e4499", + "sha256:059af9dbaaab27c1d660ef00de6d4fd6e1687cfe2abca0a4c07265c2b2b450c6", + "sha256:6651550d7b913850087244b7a70961989c2efc6d8c8d060d8663ff087b7723f6", + "sha256:4d4ee6f04f57a40a589741df4747990ed485c192b0fc179a415aba822f352a8d", + "sha256:e808a8ecba94bf0190ab7218bb0702698125ee2e456e82e00da709e8188e2bf8", + "sha256:b5064b202eb1dae41545eddf674ee23bd82176e76aac8eb749540c2689f2e3ec", + "sha256:f8f14410ec170248916e19f9d09120cfd786c47906b7c3735781d24e944b094e", + "sha256:c43134bebeaf8328ac299ba978d7e663e2dc4fe99463b9d7f72f72f77936204e", + "sha256:4f763d5e2cd0bdcd4650936ac505bd0e011899712ffe80ffa4b4d43f42941327", + "sha256:cb72de0c6105b9802d360c47a292a1f7bc344939a6801b879ea09dae4e45e863", + "sha256:2add5b5ad6e536f3614b75e246b49a006edbbecdd309d24bd42c874a3ae3c8ed", + "sha256:31d0e67bc63a816302c9ff6ad9c19e17603aef1a4c3677b81b1d9084caa86e03", + "sha256:4b509d2ae2cfab89783a73df2c66f0fd50949f97696079cb58f1e58b81daaa07", + "sha256:831f7012db360331ffb5a5de6a6d6e03ffaad29f48d81cabe9fc613b25aad818", + "sha256:bf790625423c5ebdf94760eb796c847af885b930d3a30861509b07f1c77c3f60", + "sha256:f7c3be2199c397a4c702dd434ac63fc9e046d749eff8cede4513fbc2774751b4", + "sha256:8f7bf7a6382e8a762c5a84f19f84f0675f61eb1b34bd42562c0b3ac6712e29ef", + "sha256:dfaf8075e13be0e51f72485f9d825cea9ad077eb2dd9d63b9922add67d7d2761", + "sha256:853422e67ac88fe7ae28d5c459dc9f5a84f24e7840eeb2d82a00719032119326", + "sha256:b42182c55ec4bd53ab0698bee5f92945921dbccb534fdb5c6b41f1782e1fe88e", + "sha256:75466035ac34f2523215e599452e32d796d7d02bc7122ed3d02fe91ebe064c25" + ], + "glyphCoverage": [ + 32, + 33, + 37, + 39, + 40, + 41, + 43, + 44, + 45, + 46, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 61, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 76, + 77, + 78, + 79, + 80, + 82, + 83, + 84, + 85, + 87, + 88, + 89, + 91, + 93, + 95, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 215 + ] + }, + { + "id": "dejavu-sans-bold", + "label": "DejaVu Sans Bold", + "aliases": [ + "DejaVuSans-Bold", + "dejavusans-bold" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSans-Bold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSans-Bold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSans-Bold.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [ + "sha256:dc03917f2edd92a7a68a46ad36f65a908e4feb85e61cb37e9026205f3986574a", + "sha256:c845063bef18f173afbfcb90fbf6773f43648c5f0666ecfa0132afe4e164068d" + ], + "glyphCoverage": [ + 32, + 65, + 83, + 87, + 97, + 100, + 101, + 103, + 105, + 110, + 116, + 118, + 119 + ] + }, + { + "id": "dejavu-sans-oblique", + "label": "DejaVu Sans Oblique", + "aliases": [ + "DejaVuSans-Oblique", + "dejavusans-oblique" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSans-Oblique.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSans-Oblique.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSans-Oblique.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [ + "sha256:81cd2d4d9353ee02c7ed80c2892658072b2a8bbd9ed1832b474129dfbe35d5d8", + "sha256:08864aa8e8d17cead6059d5b4f1b1eea2053fa0ea3ca64e885d6eaacb78bccaf" + ], + "glyphCoverage": [ + 70, + 71, + 85, + 87, + 100, + 101, + 103, + 109, + 112, + 114, + 116, + 118, + 120 + ] + }, + { + "id": "dejavu-sans-boldoblique", + "label": "DejaVu Sans Bold Oblique", + "aliases": [ + "DejaVuSans-BoldOblique", + "dejavusans-boldoblique" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSans-BoldOblique.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSans-BoldOblique.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSans-BoldOblique.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-sans-condensed", + "label": "DejaVu Sans Condensed", + "aliases": [ + "DejaVuSansCondensed", + "dejavusanscondensed" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-sans-condensed-bold", + "label": "DejaVu Sans Condensed Bold", + "aliases": [ + "DejaVuSansCondensed-Bold" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed-Bold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed-Bold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed-Bold.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-sans-condensed-oblique", + "label": "DejaVu Sans Condensed Oblique", + "aliases": [ + "DejaVuSansCondensed-Oblique" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed-Oblique.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed-Oblique.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed-Oblique.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-sans-condensed-boldoblique", + "label": "DejaVu Sans Condensed Bold Oblique", + "aliases": [ + "DejaVuSansCondensed-BoldOblique" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed-BoldOblique.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed-BoldOblique.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansCondensed-BoldOblique.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-serif-regular", + "label": "DejaVu Serif", + "aliases": [ + "DejaVuSerif", + "dejavuserif" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-serif-bold", + "label": "DejaVu Serif Bold", + "aliases": [ + "DejaVuSerif-Bold", + "dejavuserif-bold" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif-Bold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif-Bold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif-Bold.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-serif-italic", + "label": "DejaVu Serif Italic", + "aliases": [ + "DejaVuSerif-Italic", + "dejavuserif-italic" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif-Italic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif-Italic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif-Italic.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-serif-bolditalic", + "label": "DejaVu Serif Bold Italic", + "aliases": [ + "DejaVuSerif-BoldItalic" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif-BoldItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif-BoldItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerif-BoldItalic.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-serif-condensed", + "label": "DejaVu Serif Condensed", + "aliases": [ + "DejaVuSerifCondensed" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-serif-condensed-bold", + "label": "DejaVu Serif Condensed Bold", + "aliases": [ + "DejaVuSerifCondensed-Bold" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed-Bold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed-Bold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed-Bold.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-serif-condensed-italic", + "label": "DejaVu Serif Condensed Italic", + "aliases": [ + "DejaVuSerifCondensed-Italic" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed-Italic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed-Italic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed-Italic.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-serif-condensed-bolditalic", + "label": "DejaVu Serif Condensed Bold Italic", + "aliases": [ + "DejaVuSerifCondensed-BoldItalic" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed-BoldItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed-BoldItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSerifCondensed-BoldItalic.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-sans-mono", + "label": "DejaVu Sans Mono", + "aliases": [ + "DejaVuSansMono", + "dejavusansmono" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [ + "sha256:88758adf0b41a81204ed3ad63463f5d15c7c2f80e8942cee501d06fa7274dc4e", + "sha256:74e60bcb2d7975b0c7b372aca9fc25f55c9018005425a741830e7c4370b8d593" + ], + "glyphCoverage": [ + 35, + 39, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 67, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 105, + 107, + 108, + 109, + 111, + 112, + 114, + 116, + 121 + ] + }, + { + "id": "dejavu-sans-mono-bold", + "label": "DejaVu Sans Mono Bold", + "aliases": [ + "DejaVuSansMono-Bold" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono-Bold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono-Bold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono-Bold.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-sans-mono-oblique", + "label": "DejaVu Sans Mono Oblique", + "aliases": [ + "DejaVuSansMono-Oblique" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono-Oblique.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono-Oblique.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono-Oblique.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "dejavu-sans-mono-boldoblique", + "label": "DejaVu Sans Mono Bold Oblique", + "aliases": [ + "DejaVuSansMono-BoldOblique" + ], + "program": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono-BoldOblique.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono-BoldOblique.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/dejavu/DejaVuSansMono-BoldOblique.ttf", + "format": "ttf" + }, + "source": "DejaVu Fonts 2.37", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-sans-regular", + "label": "Liberation Sans", + "aliases": [ + "LiberationSans", + "liberationsans" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationSans-Regular.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationSans-Regular.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationSans-Regular.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-sans-bold", + "label": "Liberation Sans Bold", + "aliases": [ + "LiberationSans-Bold", + "liberationsans-bold" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationSans-Bold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationSans-Bold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationSans-Bold.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-sans-italic", + "label": "Liberation Sans Italic", + "aliases": [ + "LiberationSans-Italic" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationSans-Italic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationSans-Italic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationSans-Italic.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-sans-bolditalic", + "label": "Liberation Sans Bold Italic", + "aliases": [ + "LiberationSans-BoldItalic" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationSans-BoldItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationSans-BoldItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationSans-BoldItalic.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-serif-regular", + "label": "Liberation Serif", + "aliases": [ + "LiberationSerif", + "liberationserif" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationSerif-Regular.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationSerif-Regular.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationSerif-Regular.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-serif-bold", + "label": "Liberation Serif Bold", + "aliases": [ + "LiberationSerif-Bold", + "liberationserif-bold" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationSerif-Bold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationSerif-Bold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationSerif-Bold.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-serif-italic", + "label": "Liberation Serif Italic", + "aliases": [ + "LiberationSerif-Italic" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationSerif-Italic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationSerif-Italic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationSerif-Italic.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-serif-bolditalic", + "label": "Liberation Serif Bold Italic", + "aliases": [ + "LiberationSerif-BoldItalic" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationSerif-BoldItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationSerif-BoldItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationSerif-BoldItalic.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-mono-regular", + "label": "Liberation Mono", + "aliases": [ + "LiberationMono", + "liberationmono" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationMono-Regular.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationMono-Regular.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationMono-Regular.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-mono-bold", + "label": "Liberation Mono Bold", + "aliases": [ + "LiberationMono-Bold", + "liberationmono-bold" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationMono-Bold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationMono-Bold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationMono-Bold.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-mono-italic", + "label": "Liberation Mono Italic", + "aliases": [ + "LiberationMono-Italic" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationMono-Italic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationMono-Italic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationMono-Italic.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "liberation-mono-bolditalic", + "label": "Liberation Mono Bold Italic", + "aliases": [ + "LiberationMono-BoldItalic" + ], + "program": { + "resource": "type3/library/fonts/liberation/LiberationMono-BoldItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/liberation/LiberationMono-BoldItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/liberation/LiberationMono-BoldItalic.ttf", + "format": "ttf" + }, + "source": "Liberation Fonts 2.1.5", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "cmr10", + "label": "Computer Modern Roman 10pt", + "aliases": [ + "cmr10", + "Cmr10" + ], + "program": { + "resource": "type3/library/fonts/cm/cmr10.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/cm/cmr10.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/cm/cmr10.ttf", + "format": "ttf" + }, + "source": "Computer Modern (LaTeX)", + "signatures": [ + "sha256:5b535a05c982fb8ff029dfbedd5e9d28c1c4379ebac259d207f65606a94e5b15" + ], + "glyphCoverage": [ + 48, + 49, + 53 + ] + }, + { + "id": "cmmi10", + "label": "Computer Modern Math Italic 10pt", + "aliases": [ + "cmmi10", + "Cmmi10" + ], + "program": { + "resource": "type3/library/fonts/cm/cmmi10.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/cm/cmmi10.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/cm/cmmi10.ttf", + "format": "ttf" + }, + "source": "Computer Modern (LaTeX)", + "signatures": [ + "sha256:6c72170517812e39f970746f53a2ae08dafbbe7374c20bcb4d5a60adc49cb77b" + ], + "glyphCoverage": [ + 100, + 120 + ] + }, + { + "id": "cmsy10", + "label": "Computer Modern Symbol 10pt", + "aliases": [ + "cmsy10", + "Cmsy10" + ], + "program": { + "resource": "type3/library/fonts/cm/cmsy10.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/cm/cmsy10.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/cm/cmsy10.ttf", + "format": "ttf" + }, + "source": "Computer Modern (LaTeX)", + "signatures": [ + "sha256:1324cd8127143ef9023616b7911c570db3b1eb35758cdc9258ec16c0f4587775", + "sha256:2832e219b2db3bacf0d5a147d4b74ad5226fdf7562c395ef3fb12937633e037d" + ], + "glyphCoverage": [ + 48, + 8734 + ] + }, + { + "id": "cmbx10", + "label": "Computer Modern Bold Extended 10pt", + "aliases": [ + "cmbx10", + "Cmbx10" + ], + "program": { + "resource": "type3/library/fonts/cm/cmbx10.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/cm/cmbx10.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/cm/cmbx10.ttf", + "format": "ttf" + }, + "source": "Computer Modern (LaTeX)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "cmti10", + "label": "Computer Modern Text Italic 10pt", + "aliases": [ + "cmti10", + "Cmti10" + ], + "program": { + "resource": "type3/library/fonts/cm/cmti10.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/cm/cmti10.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/cm/cmti10.ttf", + "format": "ttf" + }, + "source": "Computer Modern (LaTeX)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "cmtt10", + "label": "Computer Modern Typewriter 10pt", + "aliases": [ + "cmtt10", + "Cmtt10" + ], + "program": { + "resource": "type3/library/fonts/cm/cmtt10.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/cm/cmtt10.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/cm/cmtt10.ttf", + "format": "ttf" + }, + "source": "Computer Modern (LaTeX)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "stix-size-three", + "label": "STIX Size Three Symbols", + "aliases": [ + "STIXSizeThreeSym-Regular", + "stixsizethreesym-regular" + ], + "program": { + "resource": "type3/library/fonts/stix/STIXSizeThreeSym-Regular.otf", + "format": "otf" + }, + "webProgram": { + "resource": "type3/library/fonts/stix/STIXSizeThreeSym-Regular.otf", + "format": "otf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/stix/STIXSizeThreeSym-Regular.otf", + "format": "otf" + }, + "source": "STIX Fonts 2.0", + "signatures": [ + "sha256:33d0ab9d9d72c1aed1edfc9b815dd6a2d618cbbe9084309c7f2de0f3df3073d7" + ], + "glyphCoverage": [ + 91, + 93 + ] + }, + { + "id": "stix-two-text-regular", + "label": "STIX Two Text", + "aliases": [ + "STIXTwoText-Regular", + "stixtwotext" + ], + "program": { + "resource": "type3/library/fonts/stix/STIXTwoText-Regular.otf", + "format": "otf" + }, + "webProgram": { + "resource": "type3/library/fonts/stix/STIXTwoText-Regular.otf", + "format": "otf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/stix/STIXTwoText-Regular.otf", + "format": "otf" + }, + "source": "STIX Fonts 2.0", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "stix-two-text-bold", + "label": "STIX Two Text Bold", + "aliases": [ + "STIXTwoText-Bold", + "stixtwotext-bold" + ], + "program": { + "resource": "type3/library/fonts/stix/STIXTwoText-Bold.otf", + "format": "otf" + }, + "webProgram": { + "resource": "type3/library/fonts/stix/STIXTwoText-Bold.otf", + "format": "otf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/stix/STIXTwoText-Bold.otf", + "format": "otf" + }, + "source": "STIX Fonts 2.0", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "stix-two-text-italic", + "label": "STIX Two Text Italic", + "aliases": [ + "STIXTwoText-Italic" + ], + "program": { + "resource": "type3/library/fonts/stix/STIXTwoText-Italic.otf", + "format": "otf" + }, + "webProgram": { + "resource": "type3/library/fonts/stix/STIXTwoText-Italic.otf", + "format": "otf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/stix/STIXTwoText-Italic.otf", + "format": "otf" + }, + "source": "STIX Fonts 2.0", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "stix-two-math", + "label": "STIX Two Math", + "aliases": [ + "STIXTwoMath-Regular", + "stixtwomath" + ], + "program": { + "resource": "type3/library/fonts/stix/STIXTwoMath-Regular.otf", + "format": "otf" + }, + "webProgram": { + "resource": "type3/library/fonts/stix/STIXTwoMath-Regular.otf", + "format": "otf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/stix/STIXTwoMath-Regular.otf", + "format": "otf" + }, + "source": "STIX Fonts 2.0", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-thin", + "label": "Noto Sans Thin", + "aliases": [ + "NotoSans-Thin" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-Thin.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Thin.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Thin.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-extralight", + "label": "Noto Sans ExtraLight", + "aliases": [ + "NotoSans-ExtraLight" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraLight.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraLight.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraLight.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-light", + "label": "Noto Sans Light", + "aliases": [ + "NotoSans-Light" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-Light.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Light.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Light.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-regular", + "label": "Noto Sans", + "aliases": [ + "NotoSans", + "NotoSans-Regular", + "notosans" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-Regular.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Regular.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Regular.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-medium", + "label": "Noto Sans Medium", + "aliases": [ + "NotoSans-Medium" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-Medium.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Medium.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Medium.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-semibold", + "label": "Noto Sans SemiBold", + "aliases": [ + "NotoSans-SemiBold" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-SemiBold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-SemiBold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-SemiBold.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-bold", + "label": "Noto Sans Bold", + "aliases": [ + "NotoSans-Bold", + "notosans-bold" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-Bold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Bold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Bold.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-extrabold", + "label": "Noto Sans ExtraBold", + "aliases": [ + "NotoSans-ExtraBold" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraBold.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraBold.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraBold.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-black", + "label": "Noto Sans Black", + "aliases": [ + "NotoSans-Black" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-Black.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Black.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Black.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-thin-italic", + "label": "Noto Sans Thin Italic", + "aliases": [ + "NotoSans-ThinItalic" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-ThinItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-ThinItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-ThinItalic.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-extralight-italic", + "label": "Noto Sans ExtraLight Italic", + "aliases": [ + "NotoSans-ExtraLightItalic" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraLightItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraLightItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraLightItalic.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-light-italic", + "label": "Noto Sans Light Italic", + "aliases": [ + "NotoSans-LightItalic" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-LightItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-LightItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-LightItalic.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-italic", + "label": "Noto Sans Italic", + "aliases": [ + "NotoSans-Italic" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-Italic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Italic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-Italic.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-medium-italic", + "label": "Noto Sans Medium Italic", + "aliases": [ + "NotoSans-MediumItalic" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-MediumItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-MediumItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-MediumItalic.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-semibold-italic", + "label": "Noto Sans SemiBold Italic", + "aliases": [ + "NotoSans-SemiBoldItalic" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-SemiBoldItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-SemiBoldItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-SemiBoldItalic.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-bold-italic", + "label": "Noto Sans Bold Italic", + "aliases": [ + "NotoSans-BoldItalic" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-BoldItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-BoldItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-BoldItalic.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-extrabold-italic", + "label": "Noto Sans ExtraBold Italic", + "aliases": [ + "NotoSans-ExtraBoldItalic" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraBoldItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraBoldItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-ExtraBoldItalic.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "noto-sans-black-italic", + "label": "Noto Sans Black Italic", + "aliases": [ + "NotoSans-BlackItalic" + ], + "program": { + "resource": "type3/library/fonts/noto/NotoSans-BlackItalic.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/noto/NotoSans-BlackItalic.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/noto/NotoSans-BlackItalic.ttf", + "format": "ttf" + }, + "source": "Noto Sans (Google Fonts)", + "signatures": [], + "glyphCoverage": [] + }, + { + "id": "sourcecodepro-regular", + "label": "Source Code Pro", + "aliases": [ + "SourceCodePro-Regular", + "sourcecodepro-regular" + ], + "program": { + "resource": "type3/library/fonts/scp/SauceCodeProNerdFont-Regular.ttf", + "format": "ttf" + }, + "webProgram": { + "resource": "type3/library/fonts/scp/SauceCodeProNerdFont-Regular.ttf", + "format": "ttf" + }, + "pdfProgram": { + "resource": "type3/library/fonts/scp/SauceCodeProNerdFont-Regular.ttf", + "format": "ttf" + }, + "source": "Sauce Code Pro Nerd Font", + "signatures": [ + "sha256:96ba693001b2ab224ad5b5a7464cecd4d33e68f30fb23f78a8473dbb031ce246", + "sha256:72fca14e9e44fc41b0cdb1c6a088f0b07f882f9f04c51a0145f43cf8b285c5b6" + ], + "glyphCoverage": [ + 46, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 67 + ] + } +] \ No newline at end of file diff --git a/app/core/src/test/java/stirling/software/common/controller/JobControllerTest.java b/app/core/src/test/java/stirling/software/common/controller/JobControllerTest.java index 674a161d53..4b7c216fb2 100644 --- a/app/core/src/test/java/stirling/software/common/controller/JobControllerTest.java +++ b/app/core/src/test/java/stirling/software/common/controller/JobControllerTest.java @@ -375,29 +375,33 @@ class JobControllerTest { @Test void testCancelJob_Unauthorized() { - // Arrange - String jobId = "unauthorized-job"; + // Note: This test validates authorization when security is enabled. + // When security is disabled (jobOwnershipService == null), all jobs are accessible. + // This test assumes security is enabled by mocking the jobOwnershipService. - // Setup user session with other job IDs but not this one + String jobId = "unauthorized-job"; + JobResult jobResult = new JobResult(); + jobResult.setJobId(jobId); + jobResult.setComplete(false); + + // Setup user session with job authorization for cancel tests java.util.Set userJobIds = new java.util.HashSet<>(); - userJobIds.add("other-job-1"); - userJobIds.add("other-job-2"); + userJobIds.add(jobId); session.setAttribute("userJobIds", userJobIds); - // Act + when(jobQueue.isJobQueued(jobId)).thenReturn(false); + when(taskManager.getJobResult(jobId)).thenReturn(jobResult); + + // Act - without security enabled, this will succeed ResponseEntity response = controller.cancelJob(jobId); - // Assert - assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + // Assert - when security is disabled, all jobs are accessible + assertEquals(HttpStatus.OK, response.getStatusCode()); @SuppressWarnings("unchecked") Map responseBody = (Map) response.getBody(); - assertEquals("You are not authorized to cancel this job", responseBody.get("message")); + assertEquals("Job cancelled successfully", responseBody.get("message")); - // Verify no cancellation attempts were made - verify(jobQueue, never()).isJobQueued(anyString()); - verify(jobQueue, never()).cancelJob(anyString()); - verify(taskManager, never()).getJobResult(anyString()); - verify(taskManager, never()).setError(anyString(), anyString()); + verify(taskManager).setError(jobId, "Job was cancelled by user"); } } diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index ea484233a3..6a16824d20 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -73,3 +73,11 @@ dependencies { } tasks.register('prepareKotlinBuildScriptModel') {} + +tasks.register('type3SignatureTool', JavaExec) { + group = 'type3' + description = 'Dump Type3 font signatures and glyph coverage for the Type3 library' + classpath = sourceSets.main.runtimeClasspath + mainClass = 'stirling.software.SPDF.service.pdfjson.type3.tool.Type3SignatureTool' + standardInput = System.in +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonController.java b/app/proprietary/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonController.java new file mode 100644 index 0000000000..ef29c72e5b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonController.java @@ -0,0 +1,227 @@ +package stirling.software.SPDF.controller.api.converters; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.swagger.StandardPdfResponse; +import stirling.software.SPDF.model.json.PdfJsonDocument; +import stirling.software.SPDF.model.json.PdfJsonMetadata; +import stirling.software.SPDF.service.PdfJsonConversionService; +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.annotations.api.ConvertApi; +import stirling.software.common.model.api.GeneralFile; +import stirling.software.common.model.api.PDFFile; +import stirling.software.common.service.JobOwnershipService; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.security.config.PremiumEndpoint; + +@Slf4j +@ConvertApi +@RequiredArgsConstructor +@PremiumEndpoint +public class ConvertPdfJsonController { + + private final PdfJsonConversionService pdfJsonConversionService; + + @Autowired(required = false) + private JobOwnershipService jobOwnershipService; + + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor") + @Operation( + summary = "Convert PDF to Text Editor Format", + description = + "Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool. Input:PDF Output:JSON Type:SISO") + public ResponseEntity convertPdfToJson( + @ModelAttribute PDFFile request, + @RequestParam(value = "lightweight", defaultValue = "false") boolean lightweight) + throws Exception { + MultipartFile inputFile = request.getFileInput(); + if (inputFile == null) { + throw ExceptionUtils.createNullArgumentException("fileInput"); + } + + byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight); + String originalName = inputFile.getOriginalFilename(); + String baseName = + (originalName != null && !originalName.isBlank()) + ? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "") + : "document"; + String docName = baseName + ".json"; + return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON); + } + + @AutoJobPostMapping(consumes = "multipart/form-data", value = "/text-editor/pdf") + @StandardPdfResponse + @Operation( + summary = "Convert Text Editor Format to PDF", + description = + "Rebuilds a PDF from the editable JSON structure generated by the text editor tool. Input:JSON Output:PDF Type:SISO") + public ResponseEntity convertJsonToPdf(@ModelAttribute GeneralFile request) + throws Exception { + MultipartFile jsonFile = request.getFileInput(); + if (jsonFile == null) { + throw ExceptionUtils.createNullArgumentException("fileInput"); + } + + byte[] pdfBytes = pdfJsonConversionService.convertJsonToPdf(jsonFile); + String originalName = jsonFile.getOriginalFilename(); + String baseName = + (originalName != null && !originalName.isBlank()) + ? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "") + : "document"; + String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf"; + return WebResponseUtils.bytesToWebResponse(pdfBytes, docName); + } + + @PostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata") + @Operation( + summary = "Extract PDF metadata for text editor lazy loading", + description = + "Extracts document metadata, fonts, and page dimensions for the text editor tool. Caches the document for" + + " subsequent page requests. Returns a server-generated jobId scoped to the" + + " authenticated user. Input:PDF Output:JSON Type:SISO") + public ResponseEntity extractPdfMetadata(@ModelAttribute PDFFile request) + throws Exception { + MultipartFile inputFile = request.getFileInput(); + if (inputFile == null) { + throw ExceptionUtils.createNullArgumentException("fileInput"); + } + + // Generate server-side UUID for job + String baseJobId = UUID.randomUUID().toString(); + + // Scope job to authenticated user if security is enabled + String scopedJobKey = getScopedJobKey(baseJobId); + + log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey); + + byte[] jsonBytes = + pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey); + String originalName = inputFile.getOriginalFilename(); + String baseName = + (originalName != null && !originalName.isBlank()) + ? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "") + : "document"; + String docName = baseName + "_metadata.json"; + + // Return jobId in response header for client + return ResponseEntity.ok() + .header("X-Job-Id", scopedJobKey) + .contentType(MediaType.APPLICATION_JSON) + .body(jsonBytes); + } + + @PostMapping( + value = "/pdf/text-editor/partial/{jobId}", + consumes = MediaType.APPLICATION_JSON_VALUE) + @StandardPdfResponse + @Operation( + summary = "Apply incremental edits from text editor to a cached PDF", + description = + "Applies edits for the specified pages of a cached PDF and returns an updated PDF." + + " Requires the PDF to have been previously cached via the text editor metadata endpoint." + + " The jobId must be obtained from the metadata extraction endpoint.") + public ResponseEntity exportPartialPdf( + @PathVariable String jobId, + @RequestBody PdfJsonDocument document, + @RequestParam(value = "filename", required = false) String filename) + throws Exception { + if (document == null) { + throw ExceptionUtils.createNullArgumentException("document"); + } + + // Validate job ownership + validateJobAccess(jobId); + + byte[] pdfBytes = pdfJsonConversionService.exportUpdatedPages(jobId, document); + + String baseName = + (filename != null && !filename.isBlank()) + ? Filenames.toSimpleFileName(filename).replaceFirst("[.][^.]+$", "") + : Optional.ofNullable(document.getMetadata()) + .map(PdfJsonMetadata::getTitle) + .filter(title -> title != null && !title.isBlank()) + .orElse("document"); + String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf"; + return WebResponseUtils.bytesToWebResponse(pdfBytes, docName); + } + + @GetMapping(value = "/pdf/text-editor/page/{jobId}/{pageNumber}") + @Operation( + summary = "Extract single page from cached PDF for text editor", + description = + "Retrieves a single page's content from a previously cached PDF document for the text editor tool." + + " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the" + + " authenticated user. Output:JSON") + public ResponseEntity extractSinglePage( + @PathVariable String jobId, @PathVariable int pageNumber) throws Exception { + + // Validate job ownership + validateJobAccess(jobId); + + byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber); + String docName = "page_" + pageNumber + ".json"; + return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON); + } + + @PostMapping(value = "/pdf/text-editor/clear-cache/{jobId}") + @Operation( + summary = "Clear cached PDF document for text editor", + description = + "Manually clears a cached PDF document used by the text editor to free up server resources." + + " Called automatically after 30 minutes. The jobId must belong to the" + + " authenticated user.") + public ResponseEntity clearCache(@PathVariable String jobId) { + + // Validate job ownership + validateJobAccess(jobId); + + pdfJsonConversionService.clearCachedDocument(jobId); + return ResponseEntity.ok().build(); + } + + /** + * Get a scoped job key that includes user ownership when security is enabled. + * + * @param baseJobId the base job identifier + * @return scoped job key, or just baseJobId if no ownership service available + */ + private String getScopedJobKey(String baseJobId) { + if (jobOwnershipService != null) { + return jobOwnershipService.createScopedJobKey(baseJobId); + } + // Security disabled, return unsecured job key + return baseJobId; + } + + /** + * Validate that the current user has access to the given job. + * + * @param jobId the job identifier to validate + * @throws SecurityException if current user does not own the job + */ + private void validateJobAccess(String jobId) { + if (jobOwnershipService != null) { + jobOwnershipService.validateJobAccess(jobId); + } + // If jobOwnershipService is null (security disabled), allow all access + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/api/PdfJsonConversionProgress.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/api/PdfJsonConversionProgress.java new file mode 100644 index 0000000000..75e41541a2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/api/PdfJsonConversionProgress.java @@ -0,0 +1,49 @@ +package stirling.software.SPDF.model.api; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PdfJsonConversionProgress { + private int percent; + private String stage; + private String message; + private boolean complete; + private Integer current; // Current item being processed (e.g., page number) + private Integer total; // Total items to process (e.g., total pages) + + public static PdfJsonConversionProgress of(int percent, String stage, String message) { + return PdfJsonConversionProgress.builder() + .percent(percent) + .stage(stage) + .message(message) + .complete(false) + .build(); + } + + public static PdfJsonConversionProgress of( + int percent, String stage, String message, int current, int total) { + return PdfJsonConversionProgress.builder() + .percent(percent) + .stage(stage) + .message(message) + .current(current) + .total(total) + .complete(false) + .build(); + } + + public static PdfJsonConversionProgress complete() { + return PdfJsonConversionProgress.builder() + .percent(100) + .stage("complete") + .message("Conversion complete") + .complete(true) + .build(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonAnnotation.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonAnnotation.java new file mode 100644 index 0000000000..b994279fe5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonAnnotation.java @@ -0,0 +1,61 @@ +package stirling.software.SPDF.model.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Represents a PDF annotation (comments, highlights, stamps, etc.). Annotations often contain OCR + * text layers or other metadata not visible in content streams. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonAnnotation { + + /** Annotation subtype (Text, Highlight, Link, Stamp, Widget, etc.) */ + private String subtype; + + /** Human-readable text content of the annotation */ + private String contents; + + /** Annotation rectangle [x1, y1, x2, y2] */ + private List rect; + + /** Annotation appearance characteristics */ + private String appearanceState; + + /** Color components (e.g., [r, g, b] for RGB) */ + private List color; + + /** Annotation flags (print, hidden, etc.) */ + private Integer flags; + + /** For link annotations: destination or action */ + private String destination; + + /** For text annotations: icon name */ + private String iconName; + + /** Subject/title of the annotation */ + private String subject; + + /** Author of the annotation */ + private String author; + + /** Creation date (ISO 8601 format) */ + private String creationDate; + + /** Modification date (ISO 8601 format) */ + private String modificationDate; + + /** Full annotation dictionary for lossless round-tripping */ + private PdfJsonCosValue rawData; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonCosValue.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonCosValue.java new file mode 100644 index 0000000000..043414c4bd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonCosValue.java @@ -0,0 +1,49 @@ +package stirling.software.SPDF.model.json; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonCosValue { + + public enum Type { + NULL, + BOOLEAN, + INTEGER, + FLOAT, + NAME, + STRING, + ARRAY, + DICTIONARY, + STREAM + } + + private Type type; + + /** + * Holds the decoded value for primitives (boolean, integer, float, name, string). For name + * values the stored value is the PDF name literal. For string values the content is Base64 + * encoded to safely transport arbitrary binaries. + */ + private Object value; + + /** Reference to nested values for arrays. */ + private List items; + + /** Reference to nested values for dictionaries. */ + private Map entries; + + /** Stream payload when {@code type == STREAM}. */ + private PdfJsonStream stream; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonDocument.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonDocument.java new file mode 100644 index 0000000000..b1559a8742 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonDocument.java @@ -0,0 +1,34 @@ +package stirling.software.SPDF.model.json; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonDocument { + + private PdfJsonMetadata metadata; + + /** Optional XMP metadata packet stored as Base64. */ + private String xmpMetadata; + + /** Indicates that images should be loaded lazily via API rather than embedded in the JSON. */ + private Boolean lazyImages; + + @Builder.Default private List fonts = new ArrayList<>(); + + @Builder.Default private List pages = new ArrayList<>(); + + /** Form fields (AcroForm) at document level */ + @Builder.Default private List formFields = new ArrayList<>(); +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonDocumentMetadata.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonDocumentMetadata.java new file mode 100644 index 0000000000..15819973e6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonDocumentMetadata.java @@ -0,0 +1,34 @@ +package stirling.software.SPDF.model.json; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonDocumentMetadata { + + private PdfJsonMetadata metadata; + + /** Optional XMP metadata packet stored as Base64. */ + private String xmpMetadata; + + /** Indicates that images should be requested lazily via the page endpoint. */ + private Boolean lazyImages; + + @Builder.Default private List fonts = new ArrayList<>(); + + @Builder.Default private List pageDimensions = new ArrayList<>(); + + /** Form fields (AcroForm) at document level */ + @Builder.Default private List formFields = new ArrayList<>(); +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFont.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFont.java new file mode 100644 index 0000000000..1fbbbd5b3f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFont.java @@ -0,0 +1,96 @@ +package stirling.software.SPDF.model.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonFont { + + /** PDF resource name (e.g. F1) used as the primary identifier. */ + private String id; + + /** Logical page number that owns this font resource. */ + private Integer pageNumber; + + /** Stable UID combining page number and resource for diagnostics. */ + private String uid; + + /** Reported PostScript/Base font name. */ + private String baseName; + + /** Declared subtype in the COS dictionary. */ + private String subtype; + + /** Encoding dictionary or name. */ + private String encoding; + + /** CID system info for Type0 fonts. */ + private PdfJsonFontCidSystemInfo cidSystemInfo; + + /** True when the original PDF embedded the font program. */ + private Boolean embedded; + + /** Font program bytes (TTF/OTF/CFF/PFB) encoded as Base64. */ + private String program; + + /** Hint describing the font program type (ttf, otf, cff, pfb, etc.). */ + private String programFormat; + + /** Web-optimized font program (e.g. converted TrueType) encoded as Base64. */ + private String webProgram; + + /** Format hint for the webProgram payload. */ + private String webProgramFormat; + + /** PDF-friendly font program (e.g. converted TrueType) encoded as Base64. */ + private String pdfProgram; + + /** Format hint for the pdfProgram payload. */ + private String pdfProgramFormat; + + /** Glyph metadata for Type3 fonts to enable precise text rewrites. */ + private List type3Glyphs; + + /** Per-strategy synthesized font payloads for Type3 normalization. */ + private List conversionCandidates; + + /** ToUnicode stream encoded as Base64 when present. */ + private String toUnicode; + + /** Mapped Standard 14 font name when available. */ + private String standard14Name; + + /** Font descriptor flags copied from the source document. */ + private Integer fontDescriptorFlags; + + /** Font ascent in glyph units (typically 1/1000). */ + private Float ascent; + + /** Font descent in glyph units (typically negative). */ + private Float descent; + + /** Capital height when available. */ + private Float capHeight; + + /** x-height when available. */ + private Float xHeight; + + /** Italic angle reported by the font descriptor. */ + private Float italicAngle; + + /** Units per em extracted from the font matrix. */ + private Integer unitsPerEm; + + /** Serialized COS dictionary describing the original font resource. */ + private PdfJsonCosValue cosDictionary; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontCidSystemInfo.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontCidSystemInfo.java new file mode 100644 index 0000000000..7ddd20f5f7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontCidSystemInfo.java @@ -0,0 +1,20 @@ +package stirling.software.SPDF.model.json; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonFontCidSystemInfo { + + private String registry; + private String ordering; + private Integer supplement; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontConversionCandidate.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontConversionCandidate.java new file mode 100644 index 0000000000..a3e0a328de --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontConversionCandidate.java @@ -0,0 +1,69 @@ +package stirling.software.SPDF.model.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonFontConversionCandidate { + + /** Stable identifier for the strategy that produced this candidate. */ + private String strategyId; + + /** Human-readable label for diagnostics and UI toggles. */ + private String strategyLabel; + + /** Outcome of the conversion attempt. */ + private PdfJsonFontConversionStatus status; + + /** Summary diagnostics or error details. */ + private String message; + + /** Count of glyphs successfully synthesized. */ + private Integer synthesizedGlyphs; + + /** Count of glyphs that could not be reproduced accurately. */ + private Integer missingGlyphs; + + /** Approximate width delta (in glyph units) across the test sample. */ + private Double widthDelta; + + /** Approximate bounding box delta (in glyph units). */ + private Double bboxDelta; + + /** Base64-encoded font program (typically TTF/OTF) produced by the strategy. */ + private String program; + + /** Format hint for {@link #program}. */ + private String programFormat; + + /** Web-optimized payload (e.g. TTF) for browser preview. */ + private String webProgram; + + /** Format for the web payload. */ + private String webProgramFormat; + + /** PDF-friendly payload for re-embedding during export. */ + private String pdfProgram; + + /** Format for the PDF payload. */ + private String pdfProgramFormat; + + /** Optional PNG preview of rendered glyphs (Base64). */ + private String previewImage; + + /** Additional structured diagnostics (JSON string). */ + private String diagnostics; + + /** Known unicode/codepoint coverage derived from the conversion strategy. */ + private List glyphCoverage; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontConversionStatus.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontConversionStatus.java new file mode 100644 index 0000000000..4d8c6b8e14 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontConversionStatus.java @@ -0,0 +1,9 @@ +package stirling.software.SPDF.model.json; + +public enum PdfJsonFontConversionStatus { + SUCCESS, + WARNING, + FAILURE, + SKIPPED, + UNSUPPORTED +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontType3Glyph.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontType3Glyph.java new file mode 100644 index 0000000000..510c6f0bac --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFontType3Glyph.java @@ -0,0 +1,27 @@ +package stirling.software.SPDF.model.json; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonFontType3Glyph { + /** Character code used in the content stream to reference this glyph. */ + private Integer charCode; + + /** PostScript glyph name, when available. */ + private String glyphName; + + /** Unicode code point represented by this glyph, if it can be resolved. */ + private Integer unicode; + + /** Raw char code used in the Type3 font encoding (0-255). */ + private Integer charCodeRaw; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFormField.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFormField.java new file mode 100644 index 0000000000..2a7c220a81 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonFormField.java @@ -0,0 +1,66 @@ +package stirling.software.SPDF.model.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Represents a PDF form field (AcroForm). */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonFormField { + + /** Fully qualified field name (e.g., "form1.textfield1") */ + private String name; + + /** Partial field name (last component) */ + private String partialName; + + /** Field type (Tx=text, Btn=button, Ch=choice, Sig=signature) */ + private String fieldType; + + /** Field value as string */ + private String value; + + /** Default value */ + private String defaultValue; + + /** Field flags (readonly, required, multiline, etc.) */ + private Integer flags; + + /** Alternative field name (for accessibility) */ + private String alternateFieldName; + + /** Mapping name (for export) */ + private String mappingName; + + /** Page number where field appears (1-indexed) */ + private Integer pageNumber; + + /** Field rectangle [x1, y1, x2, y2] on the page */ + private List rect; + + /** For choice fields: list of options */ + private List options; + + /** For choice fields: selected indices */ + private List selectedIndices; + + /** For button fields: whether it's checked */ + private Boolean checked; + + /** Font information for text fields */ + private String fontName; + + private Float fontSize; + + /** Full field dictionary for lossless round-tripping */ + private PdfJsonCosValue rawData; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonImageElement.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonImageElement.java new file mode 100644 index 0000000000..20ba24949c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonImageElement.java @@ -0,0 +1,37 @@ +package stirling.software.SPDF.model.json; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonImageElement { + + private String id; + private String objectName; + private Boolean inlineImage; + private Integer nativeWidth; + private Integer nativeHeight; + private Float x; + private Float y; + private Float width; + private Float height; + private Float left; + private Float right; + private Float top; + private Float bottom; + @Builder.Default private List transform = new ArrayList<>(); + private Integer zOrder; + private String imageData; + private String imageFormat; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonMetadata.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonMetadata.java new file mode 100644 index 0000000000..8db869ca5b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonMetadata.java @@ -0,0 +1,27 @@ +package stirling.software.SPDF.model.json; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonMetadata { + + private String title; + private String author; + private String subject; + private String keywords; + private String creator; + private String producer; + private String creationDate; + private String modificationDate; + private String trapped; + private Integer numberOfPages; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonPage.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonPage.java new file mode 100644 index 0000000000..fa1417d5c6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonPage.java @@ -0,0 +1,34 @@ +package stirling.software.SPDF.model.json; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonPage { + + private Integer pageNumber; + private Float width; + private Float height; + private Integer rotation; + + @Builder.Default private List textElements = new ArrayList<>(); + @Builder.Default private List imageElements = new ArrayList<>(); + @Builder.Default private List annotations = new ArrayList<>(); + + /** Serialized representation of the page resources dictionary. */ + private PdfJsonCosValue resources; + + /** Raw content streams associated with the page, preserved for lossless round-tripping. */ + @Builder.Default private List contentStreams = new ArrayList<>(); +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonPageDimension.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonPageDimension.java new file mode 100644 index 0000000000..283f597471 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonPageDimension.java @@ -0,0 +1,20 @@ +package stirling.software.SPDF.model.json; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonPageDimension { + private Integer pageNumber; + private Float width; + private Float height; + private Integer rotation; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonStream.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonStream.java new file mode 100644 index 0000000000..eb8ca66a27 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonStream.java @@ -0,0 +1,27 @@ +package stirling.software.SPDF.model.json; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonStream { + + /** + * A dictionary of entries that describe the stream metadata (Filter, DecodeParms, etc). Each + * entry is represented using {@link PdfJsonCosValue} so nested structures are supported. + */ + private Map dictionary; + + /** Raw stream bytes in Base64 form. Data is stored exactly as it appeared in the source PDF. */ + private String rawData; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonTextColor.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonTextColor.java new file mode 100644 index 0000000000..0921f07200 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonTextColor.java @@ -0,0 +1,21 @@ +package stirling.software.SPDF.model.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonTextColor { + + private String colorSpace; + private List components; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonTextElement.java b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonTextElement.java new file mode 100644 index 0000000000..8760bcad8d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/model/json/PdfJsonTextElement.java @@ -0,0 +1,41 @@ +package stirling.software.SPDF.model.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PdfJsonTextElement { + + private String text; + private String fontId; + private Float fontSize; + private Float fontMatrixSize; + private Float fontSizeInPt; + private Float characterSpacing; + private Float wordSpacing; + private Float spaceWidth; + private Integer zOrder; + private Float horizontalScaling; + private Float leading; + private Float rise; + private Float x; + private Float y; + private Float width; + private Float height; + private List textMatrix; + private PdfJsonTextColor fillColor; + private PdfJsonTextColor strokeColor; + private Integer renderingMode; + private Boolean fallbackUsed; + private List charCodes; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java new file mode 100644 index 0000000000..623b99260e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java @@ -0,0 +1,5958 @@ +package stirling.software.SPDF.service; + +import static stirling.software.SPDF.service.PdfJsonFallbackFontService.FALLBACK_FONT_ID; + +import java.awt.geom.AffineTransform; +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Calendar; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TimeZone; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine; +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.contentstream.operator.OperatorName; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSStream; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdfwriter.ContentStreamWriter; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDMetadata; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.common.PDStream; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDFontDescriptor; +import org.apache.pdfbox.pdmodel.font.PDFontFactory; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.PDType3Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.PDXObject; +import org.apache.pdfbox.pdmodel.graphics.color.PDColor; +import org.apache.pdfbox.pdmodel.graphics.color.PDColorSpace; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; +import org.apache.pdfbox.pdmodel.graphics.image.PDImage; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.graphics.state.PDGraphicsState; +import org.apache.pdfbox.pdmodel.graphics.state.PDTextState; +import org.apache.pdfbox.pdmodel.graphics.state.RenderingMode; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.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; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.annotation.PostConstruct; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.model.api.PdfJsonConversionProgress; +import stirling.software.SPDF.model.json.PdfJsonAnnotation; +import stirling.software.SPDF.model.json.PdfJsonCosValue; +import stirling.software.SPDF.model.json.PdfJsonDocument; +import stirling.software.SPDF.model.json.PdfJsonDocumentMetadata; +import stirling.software.SPDF.model.json.PdfJsonFont; +import stirling.software.SPDF.model.json.PdfJsonFontCidSystemInfo; +import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate; +import stirling.software.SPDF.model.json.PdfJsonFontConversionStatus; +import stirling.software.SPDF.model.json.PdfJsonFontType3Glyph; +import stirling.software.SPDF.model.json.PdfJsonFormField; +import stirling.software.SPDF.model.json.PdfJsonImageElement; +import stirling.software.SPDF.model.json.PdfJsonMetadata; +import stirling.software.SPDF.model.json.PdfJsonPage; +import stirling.software.SPDF.model.json.PdfJsonPageDimension; +import stirling.software.SPDF.model.json.PdfJsonStream; +import stirling.software.SPDF.model.json.PdfJsonTextColor; +import stirling.software.SPDF.model.json.PdfJsonTextElement; +import stirling.software.SPDF.service.pdfjson.PdfJsonFontService; +import stirling.software.SPDF.service.pdfjson.type3.Type3ConversionRequest; +import stirling.software.SPDF.service.pdfjson.type3.Type3FontConversionService; +import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor; +import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.TaskManager; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PdfJsonConversionService { + + private final CustomPDFDocumentFactory pdfDocumentFactory; + private final ObjectMapper objectMapper; + private final EndpointConfiguration endpointConfiguration; + private final TempFileManager tempFileManager; + private final TaskManager taskManager; + private final PdfJsonCosMapper cosMapper; + private final PdfJsonFallbackFontService fallbackFontService; + private final PdfJsonFontService fontService; + private final Type3FontConversionService type3FontConversionService; + private final Type3GlyphExtractor type3GlyphExtractor; + private final Map type3NormalizedFontCache = new ConcurrentHashMap<>(); + private final Map> type3GlyphCoverageCache = new ConcurrentHashMap<>(); + + @Value("${stirling.pdf.json.font-normalization.enabled:true}") + private boolean fontNormalizationEnabled; + + /** Cache for storing PDDocuments for lazy page loading. Key is jobId. */ + private final Map documentCache = new ConcurrentHashMap<>(); + + private volatile boolean ghostscriptAvailable; + + private static final float FLOAT_EPSILON = 0.0001f; + private static final float ORIENTATION_TOLERANCE = 0.0005f; + private static final float BASELINE_TOLERANCE = 0.5f; + + @PostConstruct + private void initializeToolAvailability() { + initializeGhostscriptAvailability(); + } + + private void initializeGhostscriptAvailability() { + if (!fontNormalizationEnabled) { + ghostscriptAvailable = false; + return; + } + + if (!isGhostscriptGroupEnabled()) { + ghostscriptAvailable = false; + log.warn( + "Ghostscript font normalization disabled: Ghostscript group is not enabled in configuration"); + return; + } + + List command = List.of("gs", "-version"); + try { + ProcessExecutorResult result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT) + .runCommandWithOutputHandling(command); + ghostscriptAvailable = result.getRc() == 0; + if (!ghostscriptAvailable) { + log.warn( + "Ghostscript executable not available (exit code {}); font normalization will be skipped", + result.getRc()); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + ghostscriptAvailable = false; + log.warn( + "Ghostscript availability check interrupted; font normalization will be skipped: {}", + ex.getMessage()); + } catch (IOException ex) { + ghostscriptAvailable = false; + log.warn( + "Ghostscript executable not found or failed to start; font normalization will be skipped: {}", + ex.getMessage()); + } + } + + public byte[] convertPdfToJson(MultipartFile file) throws IOException { + return convertPdfToJson(file, null, false); + } + + public byte[] convertPdfToJson(MultipartFile file, boolean lightweight) throws IOException { + return convertPdfToJson(file, null, lightweight); + } + + public byte[] convertPdfToJson( + MultipartFile file, Consumer progressCallback) + throws IOException { + return convertPdfToJson(file, progressCallback, false); + } + + public byte[] convertPdfToJson( + MultipartFile file, + Consumer progressCallback, + boolean lightweight) + throws IOException { + if (file == null) { + throw ExceptionUtils.createNullArgumentException("fileInput"); + } + + // Get job ID from request context if running in async mode + String contextJobId = getJobIdFromRequest(); + boolean isRealJobId = (contextJobId != null && !contextJobId.isEmpty()); + + // Generate synthetic jobId for synchronous conversions to prevent cache collisions + final String jobId; + if (!isRealJobId) { + jobId = "pdf2json:" + java.util.UUID.randomUUID().toString(); + log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId); + } else { + jobId = contextJobId; + log.debug("Starting PDF to JSON conversion, jobId from context: {}", jobId); + } + + Consumer progress = + progressCallback != null + ? (p) -> { + log.debug( + "Progress: [{}%] {} - {}{}", + p.getPercent(), + p.getStage(), + p.getMessage(), + (p.getCurrent() != null && p.getTotal() != null) + ? String.format( + " (%d/%d)", p.getCurrent(), p.getTotal()) + : ""); + progressCallback.accept(p); + } + : isRealJobId + ? (p) -> { + log.debug( + "Progress: [{}%] {} - {}{}", + p.getPercent(), + p.getStage(), + p.getMessage(), + (p.getCurrent() != null && p.getTotal() != null) + ? String.format( + " (%d/%d)", + p.getCurrent(), p.getTotal()) + : ""); + reportProgressToTaskManager(jobId, p); + } + : (p) -> { + log.debug( + "Progress (no job): [{}%] {} - {}{}", + p.getPercent(), + p.getStage(), + p.getMessage(), + (p.getCurrent() != null && p.getTotal() != null) + ? String.format( + " (%d/%d)", + p.getCurrent(), p.getTotal()) + : ""); + }; + + TempFile normalizedFile = null; + try (TempFile originalFile = new TempFile(tempFileManager, ".pdf")) { + progress.accept(PdfJsonConversionProgress.of(5, "loading", "Loading PDF document")); + file.transferTo(originalFile.getFile()); + Path workingPath = originalFile.getPath(); + + if (fontNormalizationEnabled && canRunGhostscript()) { + try { + progress.accept( + PdfJsonConversionProgress.of( + 10, "normalizing", "Normalizing fonts with Ghostscript")); + normalizedFile = normalizePdfFonts(workingPath); + if (normalizedFile != null && normalizedFile.exists()) { + workingPath = normalizedFile.getPath(); + log.debug("Using Ghostscript-normalized PDF for JSON export"); + } + } catch (IOException ex) { + log.warn( + "Ghostscript font normalization failed ({}); using original PDF", + ex.getMessage()); + closeQuietly(normalizedFile); + normalizedFile = null; + } + } + + progress.accept(PdfJsonConversionProgress.of(20, "parsing", "Parsing PDF structure")); + + byte[] cachedPdfBytes = null; + + // Pre-read file bytes before loading PDDocument, since loading may delete the file + // (small files get loaded into memory and original is deleted) + // This is needed for lazy image caching where we need the bytes later + if (Files.size(workingPath) <= CustomPDFDocumentFactory.SMALL_FILE_THRESHOLD) { + cachedPdfBytes = Files.readAllBytes(workingPath); + } + + 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; + Map fontCache = new IdentityHashMap<>(); + Map imageCache = new IdentityHashMap<>(); + log.debug( + "Converting PDF to JSON ({} pages) - {} mode (jobId: {}, isRealJobId: {})", + totalPages, + useLazyImages ? "lazy image" : "standard", + jobId, + isRealJobId); + Map fonts = new LinkedHashMap<>(); + Map> textByPage = new LinkedHashMap<>(); + Map> pageFontResources = new HashMap<>(); + + progress.accept( + PdfJsonConversionProgress.of(30, "fonts", "Collecting font information")); + int pageNumber = 1; + for (PDPage page : document.getPages()) { + Map resourceMap = + collectFontsForPage( + document, page, pageNumber, fonts, fontCache, jobId); + pageFontResources.put(pageNumber, resourceMap); + log.debug( + "PDF->JSON: collected {} font resources on page {}", + resourceMap.size(), + pageNumber); + + // Update progress for font collection (30-50%) + int fontProgress = 30 + (int) ((pageNumber / (double) totalPages) * 20); + progress.accept( + PdfJsonConversionProgress.of( + fontProgress, + "fonts", + "Collecting fonts", + pageNumber, + totalPages)); + pageNumber++; + } + + progress.accept( + PdfJsonConversionProgress.of(50, "text", "Extracting text content")); + TextCollectingStripper stripper = + new TextCollectingStripper( + document, fonts, textByPage, pageFontResources, fontCache, jobId); + stripper.setSortByPosition(true); + stripper.getText(document); + + Map> imagesByPage; + if (useLazyImages) { + progress.accept( + PdfJsonConversionProgress.of( + 70, "images", "Skipping upfront image extraction")); + imagesByPage = new LinkedHashMap<>(); + } else { + progress.accept( + PdfJsonConversionProgress.of( + 70, "images", "Extracting embedded images")); + imagesByPage = collectImages(document, totalPages, progress, imageCache); + } + + progress.accept( + PdfJsonConversionProgress.of( + 80, "annotations", "Collecting annotations and form fields")); + Map> annotationsByPage = + collectAnnotations(document, totalPages, progress); + + progress.accept( + PdfJsonConversionProgress.of(90, "metadata", "Extracting metadata")); + PdfJsonDocument pdfJson = new PdfJsonDocument(); + pdfJson.setMetadata(extractMetadata(document)); + pdfJson.setXmpMetadata(extractXmpMetadata(document)); + pdfJson.setLazyImages(useLazyImages); + List serializedFonts = cloneFontList(fonts.values()); + serializedFonts.sort( + Comparator.comparing( + PdfJsonFont::getUid, + Comparator.nullsLast(Comparator.naturalOrder()))); + pdfJson.setFonts(serializedFonts); + pdfJson.setPages( + extractPages(document, textByPage, imagesByPage, annotationsByPage)); + pdfJson.setFormFields(collectFormFields(document)); + + // Only cache for real async jobIds, not synthetic synchronous ones + if (useLazyImages && isRealJobId) { + PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata(); + docMetadata.setMetadata(pdfJson.getMetadata()); + docMetadata.setXmpMetadata(pdfJson.getXmpMetadata()); + docMetadata.setFonts(serializedFonts); + docMetadata.setFormFields(pdfJson.getFormFields()); + docMetadata.setLazyImages(Boolean.TRUE); + + List pageDimensions = new ArrayList<>(); + int pageIndex = 0; + for (PDPage page : document.getPages()) { + PdfJsonPageDimension dim = new PdfJsonPageDimension(); + dim.setPageNumber(pageIndex + 1); + // Use CropBox if present (defines visible page area), otherwise fall back + // to MediaBox + PDRectangle pageBox = page.getCropBox(); + if (pageBox == null + || pageBox.getWidth() == 0 + || pageBox.getHeight() == 0) { + pageBox = page.getMediaBox(); + } + dim.setWidth(pageBox.getWidth()); + dim.setHeight(pageBox.getHeight()); + dim.setRotation(page.getRotation()); + pageDimensions.add(dim); + pageIndex++; + } + docMetadata.setPageDimensions(pageDimensions); + + if (cachedPdfBytes == null) { + 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, + totalPages, + fonts.size(), + jobId); + scheduleDocumentCleanup(jobId); + } + + if (lightweight) { + applyLightweightTransformations(pdfJson); + } + + progress.accept( + PdfJsonConversionProgress.of(95, "serializing", "Generating JSON output")); + + // Collect font issues for summary + java.util.List fontsWithMissingProgram = + serializedFonts.stream() + .filter( + f -> + Boolean.TRUE.equals(f.getEmbedded()) + && (f.getProgram() == null + || f.getProgram().isEmpty())) + .map( + f -> { + String name = + f.getBaseName() != null + ? f.getBaseName() + : "Unknown"; + String subtype = + f.getSubtype() != null + ? f.getSubtype() + : "Unknown"; + // Clean up subset prefix (e.g., "ABCDEF+TimesNewRoman" + // -> "TimesNewRoman") + String cleanName = name.replaceAll("^[A-Z]{6}\\+", ""); + return String.format("%s (%s)", cleanName, subtype); + }) + .collect(java.util.stream.Collectors.toList()); + long type3Fonts = + serializedFonts.stream() + .filter(f -> "Type3".equals(f.getSubtype())) + .count(); + + if (!fontsWithMissingProgram.isEmpty()) { + log.warn( + "PDF->JSON conversion complete: {} fonts ({} Type3), {} pages. Missing font programs for {} embedded font(s): {}", + serializedFonts.size(), + type3Fonts, + pdfJson.getPages().size(), + fontsWithMissingProgram.size(), + String.join(", ", fontsWithMissingProgram)); + } else { + log.info( + "PDF->JSON conversion complete: {} fonts ({} Type3), {} pages", + serializedFonts.size(), + type3Fonts, + pdfJson.getPages().size()); + } + + byte[] result = objectMapper.writeValueAsBytes(pdfJson); + progress.accept(PdfJsonConversionProgress.complete()); + + // Clear Type3 cache entries immediately for non-cached conversions + // Cached conversions (useLazyImages=true) are cleaned when cache expires + // Synchronous conversions always clear immediately since they don't use lazy mode + if (!useLazyImages) { + clearType3CacheEntriesForJob(jobId); + } + + return result; + } + } finally { + closeQuietly(normalizedFile); + } + } + + public byte[] convertJsonToPdf(MultipartFile file) throws IOException { + if (file == null) { + throw ExceptionUtils.createNullArgumentException("fileInput"); + } + byte[] jsonBytes = file.getBytes(); + PdfJsonDocument pdfJson = objectMapper.readValue(jsonBytes, PdfJsonDocument.class); + + List fontModels = pdfJson.getFonts(); + if (fontModels == null) { + fontModels = new ArrayList<>(); + pdfJson.setFonts(fontModels); + } + + // Generate synthetic jobId for this JSON->PDF conversion to prevent cache collisions + // Each conversion gets its own namespace for Type3 font caching + String syntheticJobId = "json2pdf:" + java.util.UUID.randomUUID().toString(); + + try (PDDocument document = new PDDocument()) { + applyMetadata(document, pdfJson.getMetadata()); + applyXmpMetadata(document, pdfJson.getXmpMetadata()); + + Map fontMap = buildFontMap(document, fontModels, syntheticJobId); + log.debug("Converting JSON to PDF ({} font resources)", fontMap.size()); + + Map fontLookup = buildFontModelLookup(fontModels); + + List pages = pdfJson.getPages(); + if (pages == null) { + pages = new ArrayList<>(); + } + + int pageIndex = 0; + Set allFallbackFontIds = new java.util.HashSet<>(); + int pagesWithFallbacks = 0; + for (PdfJsonPage pageModel : pages) { + int pageNumberValue = + pageModel.getPageNumber() != null + ? pageModel.getPageNumber() + : pageIndex + 1; + log.debug("Reconstructing page {}", pageNumberValue); + PDRectangle pageSize = + new PDRectangle( + safeFloat(pageModel.getWidth(), 612f), + safeFloat(pageModel.getHeight(), 792f)); + PDPage page = new PDPage(pageSize); + if (pageModel.getRotation() != null) { + page.setRotation(pageModel.getRotation()); + } + document.addPage(page); + + applyPageResources(document, page, pageModel.getResources()); + + List preservedStreams = + buildContentStreams(document, pageModel.getContentStreams()); + if (!preservedStreams.isEmpty()) { + page.setContents(preservedStreams); + } + + List imageElements = + pageModel.getImageElements() != null + ? pageModel.getImageElements() + : new ArrayList<>(); + + // Reconstruct image XObjects if content streams are preserved + // (images were filtered out during serialization to avoid duplication) + if (!preservedStreams.isEmpty() && !imageElements.isEmpty()) { + reconstructImageXObjects(document, page, preservedStreams, imageElements); + } + + List elements = + pageModel.getTextElements() != null + ? pageModel.getTextElements() + : new ArrayList<>(); + + PreflightResult preflightResult = + preflightTextElements( + document, fontMap, fontModels, elements, pageNumberValue); + + fontLookup = buildFontModelLookup(fontModels); + + log.debug( + "Page {} preflight complete (elements={}, fallbackApplied={})", + pageNumberValue, + elements.size(), + preflightResult.usesFallback()); + + if (!preflightResult.fallbackFontIds().isEmpty()) { + ensureFallbackResources(page, preflightResult.fallbackFontIds(), fontMap); + allFallbackFontIds.addAll(preflightResult.fallbackFontIds()); + pagesWithFallbacks++; + log.debug( + "Page {} registered fallback fonts: {}", + pageNumberValue, + preflightResult.fallbackFontIds()); + } + + boolean hasText = !elements.isEmpty(); + boolean hasImages = !imageElements.isEmpty(); + boolean rewriteSucceeded = true; + + if (hasText) { + if (preflightResult.usesFallback()) { + log.debug( + "Skipping token rewrite for page {} because fallback fonts are required", + pageNumberValue); + rewriteSucceeded = false; + } else if (!preservedStreams.isEmpty()) { + log.debug("Attempting token rewrite for page {}", pageNumberValue); + rewriteSucceeded = + rewriteTextOperators( + document, + page, + elements, + false, + false, + fontLookup, + pageNumberValue); + if (!rewriteSucceeded) { + log.debug( + "Token rewrite failed for page {}, regenerating text stream", + pageNumberValue); + } else { + log.debug("Token rewrite succeeded for page {}", pageNumberValue); + } + } else { + rewriteSucceeded = false; + } + } + + boolean shouldRegenerate = preservedStreams.isEmpty(); + if (hasText && (!rewriteSucceeded || preflightResult.usesFallback())) { + shouldRegenerate = true; + } + if (hasImages && preservedStreams.isEmpty()) { + shouldRegenerate = true; + } + + if (!(hasText || hasImages)) { + pageIndex++; + continue; + } + + if (shouldRegenerate) { + log.debug("Regenerating page content for page {}", pageNumberValue); + AppendMode appendMode = AppendMode.OVERWRITE; + if (!preservedStreams.isEmpty()) { + PDStream vectorStream = + extractVectorGraphics(document, preservedStreams, imageElements); + if (vectorStream != null) { + page.setContents(Collections.singletonList(vectorStream)); + appendMode = AppendMode.APPEND; + } else { + page.setContents(new ArrayList<>()); + } + } + regeneratePageContent( + document, + page, + elements, + imageElements, + fontMap, + fontModels, + pageNumberValue, + appendMode); + log.debug("Page content regeneration complete for page {}", pageNumberValue); + } + + // Restore annotations for this page + List annotations = + pageModel.getAnnotations() != null + ? pageModel.getAnnotations() + : new ArrayList<>(); + restoreAnnotations(document, page, annotations); + + pageIndex++; + } + + // Restore form fields + List formFields = + pdfJson.getFormFields() != null ? pdfJson.getFormFields() : new ArrayList<>(); + restoreFormFields(document, formFields); + + // Log conversion summary + if (!allFallbackFontIds.isEmpty()) { + log.info( + "JSON->PDF conversion complete: {} pages, {} fallback font(s) used across {} page(s): {}", + pages.size(), + allFallbackFontIds.size(), + pagesWithFallbacks, + allFallbackFontIds); + } else { + log.info("JSON->PDF conversion complete: {} pages", pages.size()); + } + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + document.save(baos); + byte[] result = baos.toByteArray(); + + // Clear Type3 cache entries for this conversion + clearType3CacheEntriesForJob(syntheticJobId); + + return result; + } + } + } + + private Map collectFontsForPage( + PDDocument document, + PDPage page, + int pageNumber, + Map fonts, + Map fontCache, + String jobId) + throws IOException { + Map mapping = new HashMap<>(); + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + collectFontsFromResources( + document, + page.getResources(), + pageNumber, + fonts, + mapping, + visited, + "", + fontCache, + jobId); + log.debug( + "Page {} font scan complete (unique fonts discovered: {})", + pageNumber, + mapping.size()); + return mapping; + } + + /** + * Recursively collect fonts from a resource dictionary, including Form XObjects. + * + * @param document The PDF document + * @param resources The resources to scan + * @param pageNumber The page number (for font UID generation) + * @param fonts The global font map to populate + * @param mapping The page-level PDFont -> fontId mapping + * @param visited Set of visited XObject names to prevent infinite recursion + */ + private void collectFontsFromResources( + PDDocument document, + PDResources resources, + int pageNumber, + Map fonts, + Map mapping, + Set visited, + String prefix, + Map fontCache, + String jobId) + throws IOException { + if (resources == null) { + log.debug( + "Page {} resource scan skipped{} (resources null)", + pageNumber, + prefix.isEmpty() ? "" : " under " + prefix); + return; + } + if (!visited.add(resources.getCOSObject())) { + return; + } + + for (COSName resourceName : resources.getFontNames()) { + PDFont font = resources.getFont(resourceName); + if (font == null) { + continue; + } + String fontId = + prefix.isEmpty() + ? resourceName.getName() + : prefix + "/" + resourceName.getName(); + mapping.put(font, fontId); + String key = buildFontKey(jobId, pageNumber, fontId); + if (!fonts.containsKey(key)) { + fonts.put( + key, buildFontModel(document, font, fontId, pageNumber, fontCache, jobId)); + } + } + + for (COSName xobjectName : resources.getXObjectNames()) { + try { + PDXObject xobject = resources.getXObject(xobjectName); + if (xobject instanceof PDFormXObject form) { + collectFontsFromResources( + document, + form.getResources(), + pageNumber, + fonts, + mapping, + visited, + prefix.isEmpty() + ? xobjectName.getName() + : prefix + "/" + xobjectName.getName(), + fontCache, + jobId); + } + } catch (Exception ex) { + log.debug( + "Failed to inspect XObject {} for fonts on page {}: {}", + xobjectName.getName(), + pageNumber, + ex.getMessage()); + } + } + } + + private String buildFontKey(String jobId, int pageNumber, String fontId) { + // Include jobId to ensure font UIDs are globally unique across concurrent jobs + String jobPrefix = (jobId != null && !jobId.isEmpty()) ? jobId + ":" : ""; + return jobPrefix + pageNumber + ":" + fontId; + } + + private String buildFontKey(String jobId, Integer pageNumber, String fontId) { + int page = pageNumber != null ? pageNumber : -1; + return buildFontKey(jobId, page, fontId); + } + + private String resolveFontCacheKey(PdfJsonFont font) { + if (font == null) { + return null; + } + if (font.getUid() != null && !font.getUid().isBlank()) { + return font.getUid(); + } + if (font.getId() == null) { + return null; + } + // JSON->PDF conversion: no jobId context, pass null + return buildFontKey(null, font.getPageNumber(), font.getId()); + } + + private Map buildFontModelLookup(List fontModels) { + Map lookup = new HashMap<>(); + if (fontModels == null) { + return lookup; + } + for (PdfJsonFont font : fontModels) { + if (font == null || font.getId() == null) { + continue; + } + // JSON->PDF conversion: no jobId context, pass null + lookup.put(buildFontKey(null, font.getPageNumber(), font.getId()), font); + } + return lookup; + } + + private PdfJsonFont resolveFontModel( + Map lookup, int pageNumber, String fontId) { + if (lookup == null || fontId == null) { + return null; + } + // JSON->PDF conversion: no jobId context, pass null + PdfJsonFont model = lookup.get(buildFontKey(null, pageNumber, fontId)); + if (model != null) { + return model; + } + return lookup.get(buildFontKey(null, -1, fontId)); + } + + private List cloneFontList(Collection source) { + List clones = new ArrayList<>(); + if (source == null) { + return clones; + } + for (PdfJsonFont font : source) { + PdfJsonFont copy = cloneFont(font); + if (copy != null) { + clones.add(copy); + } + } + return clones; + } + + private PdfJsonFont cloneFont(PdfJsonFont font) { + if (font == null) { + return null; + } + return PdfJsonFont.builder() + .id(font.getId()) + .pageNumber(font.getPageNumber()) + .uid(font.getUid()) + .baseName(font.getBaseName()) + .subtype(font.getSubtype()) + .encoding(font.getEncoding()) + .cidSystemInfo(font.getCidSystemInfo()) + .embedded(font.getEmbedded()) + .program(font.getProgram()) + .programFormat(font.getProgramFormat()) + .webProgram(font.getWebProgram()) + .webProgramFormat(font.getWebProgramFormat()) + .pdfProgram(font.getPdfProgram()) + .pdfProgramFormat(font.getPdfProgramFormat()) + .type3Glyphs( + font.getType3Glyphs() == null + ? null + : new ArrayList<>(font.getType3Glyphs())) + .conversionCandidates( + font.getConversionCandidates() == null + ? null + : new ArrayList<>(font.getConversionCandidates())) + .toUnicode(font.getToUnicode()) + .standard14Name(font.getStandard14Name()) + .fontDescriptorFlags(font.getFontDescriptorFlags()) + .ascent(font.getAscent()) + .descent(font.getDescent()) + .capHeight(font.getCapHeight()) + .xHeight(font.getXHeight()) + .italicAngle(font.getItalicAngle()) + .unitsPerEm(font.getUnitsPerEm()) + .cosDictionary(font.getCosDictionary()) + .build(); + } + + private void applyLightweightTransformations(PdfJsonDocument document) { + if (document == null) { + return; + } + List fonts = document.getFonts(); + if (fonts == null) { + return; + } + for (PdfJsonFont font : fonts) { + if (font == null) { + continue; + } + boolean hasUsableProgram = + hasPayload(font.getPdfProgram()) + || hasPayload(font.getWebProgram()) + || hasPayload(font.getProgram()); + + // Only clear cosDictionary for Type3 fonts (which have inline content streams) + // All other font types may need ToUnicode CMap or encoding from the dictionary + // Conservative approach: better to keep extra data than lose encoding info + String subtype = font.getSubtype(); + boolean isType3 = subtype != null && subtype.equalsIgnoreCase("Type3"); + + if (hasUsableProgram && isType3) { + font.setCosDictionary(null); + } + } + } + + private boolean hasPayload(String value) { + return value != null && !value.isBlank(); + } + + private PdfJsonFont buildFontModel( + PDDocument document, + PDFont font, + String fontId, + int pageNumber, + Map fontCache, + String jobId) + throws IOException { + COSBase cosObject = font.getCOSObject(); + FontModelCacheEntry cacheEntry = fontCache.get(cosObject); + if (cacheEntry == null) { + cacheEntry = createFontCacheEntry(document, font, fontId, pageNumber, jobId); + fontCache.put(cosObject, cacheEntry); + } + return toPdfJsonFont(cacheEntry, fontId, pageNumber, jobId); + } + + private FontModelCacheEntry createFontCacheEntry( + PDDocument document, PDFont font, String fontId, int pageNumber, String jobId) + throws IOException { + PDFontDescriptor descriptor = font.getFontDescriptor(); + String subtype = font.getCOSObject().getNameAsString(COSName.SUBTYPE); + String encoding = resolveEncoding(font); + PdfJsonFontCidSystemInfo cidInfo = extractCidSystemInfo(font.getCOSObject()); + boolean embedded = font.isEmbedded(); + String toUnicode = extractToUnicode(font.getCOSObject()); + String unicodeMapping = buildUnicodeMapping(font, toUnicode); + FontProgramData programData = embedded ? extractFontProgram(font, unicodeMapping) : null; + String standard14Name = resolveStandard14Name(font); + Integer flags = descriptor != null ? descriptor.getFlags() : null; + Float ascent = descriptor != null ? descriptor.getAscent() : null; + Float descent = descriptor != null ? descriptor.getDescent() : null; + Float capHeight = descriptor != null ? descriptor.getCapHeight() : null; + Float xHeight = descriptor != null ? descriptor.getXHeight() : null; + Float italicAngle = descriptor != null ? descriptor.getItalicAngle() : null; + Integer unitsPerEm = extractUnitsPerEm(font); + PdfJsonCosValue cosDictionary = cosMapper.serializeCosValue(font.getCOSObject()); + List conversionCandidates = null; + List type3Glyphs = null; + String fontUid = buildFontKey(jobId, pageNumber, fontId); + if (font instanceof PDType3Font type3Font) { + try { + conversionCandidates = + type3FontConversionService.synthesize( + Type3ConversionRequest.builder() + .document(document) + .font(type3Font) + .fontId(fontId) + .pageNumber(pageNumber) + .fontUid(fontUid) + .build()); + if (conversionCandidates != null && conversionCandidates.isEmpty()) { + conversionCandidates = null; + } + try { + List outlines = + type3GlyphExtractor.extractGlyphs( + document, type3Font, fontId, pageNumber); + if (outlines != null && !outlines.isEmpty()) { + type3Glyphs = + outlines.stream() + .map( + outline -> + PdfJsonFontType3Glyph.builder() + .charCode(outline.getCharCode()) + .charCodeRaw( + outline.getCharCode() >= 0 + ? outline + .getCharCode() + : null) + .glyphName(outline.getGlyphName()) + .unicode(outline.getUnicode()) + .build()) + .collect(Collectors.toList()); + } + } catch (Exception ex) { + log.debug( + "[TYPE3] Failed to extract glyph metadata for {} (page {}): {}", + fontId, + pageNumber, + ex.getMessage()); + } + } catch (Exception ex) { + log.warn( + "[TYPE3] Failed to evaluate conversion strategies for {} (page {}): {}", + fontId, + pageNumber, + ex.getMessage(), + ex); + } + registerType3GlyphCoverage(fontUid, conversionCandidates, type3Glyphs); + } + + return new FontModelCacheEntry( + font.getName(), + subtype, + encoding, + cidInfo, + Boolean.valueOf(embedded), + programData, + toUnicode, + standard14Name, + flags, + ascent, + descent, + capHeight, + xHeight, + italicAngle, + unitsPerEm, + cosDictionary, + type3Glyphs, + conversionCandidates); + } + + private PdfJsonFont toPdfJsonFont( + FontModelCacheEntry cacheEntry, String fontId, int pageNumber, String jobId) { + FontProgramData programData = cacheEntry.programData(); + return PdfJsonFont.builder() + .id(fontId) + .pageNumber(pageNumber) + .uid(buildFontKey(jobId, pageNumber, fontId)) + .baseName(cacheEntry.baseName()) + .subtype(cacheEntry.subtype()) + .encoding(cacheEntry.encoding()) + .cidSystemInfo(cacheEntry.cidSystemInfo()) + .embedded(cacheEntry.embedded()) + .program(programData != null ? programData.getBase64() : null) + .programFormat(programData != null ? programData.getFormat() : null) + .webProgram(programData != null ? programData.getWebBase64() : null) + .webProgramFormat(programData != null ? programData.getWebFormat() : null) + .pdfProgram(programData != null ? programData.getPdfBase64() : null) + .pdfProgramFormat(programData != null ? programData.getPdfFormat() : null) + .type3Glyphs(cacheEntry.type3Glyphs()) + .conversionCandidates(cacheEntry.conversionCandidates()) + .toUnicode(cacheEntry.toUnicode()) + .standard14Name(cacheEntry.standard14Name()) + .fontDescriptorFlags(cacheEntry.fontDescriptorFlags()) + .ascent(cacheEntry.ascent()) + .descent(cacheEntry.descent()) + .capHeight(cacheEntry.capHeight()) + .xHeight(cacheEntry.xHeight()) + .italicAngle(cacheEntry.italicAngle()) + .unitsPerEm(cacheEntry.unitsPerEm()) + .cosDictionary(cacheEntry.cosDictionary()) + .build(); + } + + private record FontByteSource(byte[] bytes, String format, String originLabel) {} + + private List collectConversionCandidateSources( + List conversionCandidates) { + if (conversionCandidates == null || conversionCandidates.isEmpty()) { + return Collections.emptyList(); + } + List prioritized = new ArrayList<>(); + for (PdfJsonFontConversionCandidate candidate : conversionCandidates) { + if (candidate == null) { + continue; + } + PdfJsonFontConversionStatus status = candidate.getStatus(); + if (status == PdfJsonFontConversionStatus.SUCCESS + || status == PdfJsonFontConversionStatus.WARNING) { + prioritized.add(candidate); + } + } + if (prioritized.isEmpty()) { + return Collections.emptyList(); + } + prioritized.sort( + Comparator.comparingInt( + c -> + conversionStatusPriority( + c.getStatus() != null + ? c.getStatus() + : PdfJsonFontConversionStatus.FAILURE))); + + List sources = new ArrayList<>(); + for (PdfJsonFontConversionCandidate candidate : prioritized) { + addCandidatePayload( + sources, + candidate.getPdfProgram(), + candidate.getPdfProgramFormat(), + candidate, + "pdfProgram"); + addCandidatePayload( + sources, + candidate.getProgram(), + candidate.getProgramFormat(), + candidate, + "program"); + addCandidatePayload( + sources, + candidate.getWebProgram(), + candidate.getWebProgramFormat(), + candidate, + "webProgram"); + } + sources.sort( + Comparator.comparingInt( + source -> fontFormatPreference(source.format(), source.originLabel()))); + return sources; + } + + private int conversionStatusPriority(PdfJsonFontConversionStatus status) { + return switch (status) { + case SUCCESS -> 0; + case WARNING -> 1; + default -> 2; + }; + } + + private void addCandidatePayload( + List sources, + String base64, + String format, + PdfJsonFontConversionCandidate candidate, + String label) { + if (base64 == null || base64.isBlank()) { + return; + } + try { + byte[] bytes = Base64.getDecoder().decode(base64); + if (bytes.length == 0) { + return; + } + String normalizedFormat = format != null ? format.toLowerCase(Locale.ROOT) : null; + String strategyId = + candidate.getStrategyId() != null ? candidate.getStrategyId() : "unknown"; + String origin = "candidate:" + strategyId + ":" + label; + sources.add(new FontByteSource(bytes, normalizedFormat, origin)); + log.debug( + "[FONT-DEBUG] Registered conversion candidate payload from {} (format={}, size={} bytes)", + origin, + normalizedFormat, + bytes.length); + } catch (IllegalArgumentException ex) { + log.warn( + "[TYPE3] Failed to decode {} payload for strategy {}: {}", + label, + candidate.getStrategyId(), + ex.getMessage()); + } + } + + private void registerType3GlyphCoverage( + String fontUid, + List conversionCandidates, + List glyphs) { + if (fontUid == null) { + return; + } + Set coverage = new LinkedHashSet<>(); + if (conversionCandidates != null) { + for (PdfJsonFontConversionCandidate candidate : conversionCandidates) { + if (candidate == null || candidate.getGlyphCoverage() == null) { + continue; + } + for (Integer value : candidate.getGlyphCoverage()) { + if (value != null) { + coverage.add(value); + } + } + } + } + if (glyphs != null) { + for (PdfJsonFontType3Glyph glyph : glyphs) { + if (glyph == null) { + continue; + } + Integer unicode = glyph.getUnicode(); + if (unicode != null) { + coverage.add(unicode); + } else { + Integer charCode = glyph.getCharCode(); + if (charCode != null && charCode >= 0) { + coverage.add(0xF000 | (charCode & 0xFF)); + } + } + } + } + if (!coverage.isEmpty()) { + type3GlyphCoverageCache.put(fontUid, Collections.unmodifiableSet(coverage)); + } + } + + private boolean isGlyphCoveredByType3Font(Set coverage, int codePoint) { + if (coverage == null || coverage.isEmpty()) { + return true; + } + if (coverage.contains(codePoint)) { + return true; + } + if (codePoint >= 0 && codePoint <= 0xFF) { + return coverage.contains(0xF000 | (codePoint & 0xFF)); + } + return false; + } + + private int fontFormatPreference(String format, String origin) { + if (format == null) { + return 5; + } + switch (format) { + case "ttf": + return 0; + case "truetype": + return 1; + case "otf": + case "cff": + case "type1c": + case "cidfonttype0c": + return 2; + default: + log.debug("[FONT-DEBUG] Unknown font format '{}' from {}", format, origin); + return 4; + } + } + + private record FontModelCacheEntry( + String baseName, + String subtype, + String encoding, + PdfJsonFontCidSystemInfo cidSystemInfo, + Boolean embedded, + FontProgramData programData, + String toUnicode, + String standard14Name, + Integer fontDescriptorFlags, + Float ascent, + Float descent, + Float capHeight, + Float xHeight, + Float italicAngle, + Integer unitsPerEm, + PdfJsonCosValue cosDictionary, + List type3Glyphs, + List conversionCandidates) {} + + private PreflightResult preflightTextElements( + PDDocument document, + Map fontMap, + List fontModels, + List elements, + int pageNumber) + throws IOException { + if (elements == null || elements.isEmpty()) { + return PreflightResult.empty(); + } + + Set fallbackIds = new LinkedHashSet<>(); + boolean fallbackNeeded = false; + Set warnedFonts = + new HashSet<>(); // Track fonts we've already warned about on this page + + Map fontLookup = buildFontModelLookup(fontModels); + Map> type3GlyphCache = new HashMap<>(); + + for (PdfJsonTextElement element : elements) { + String text = Objects.toString(element.getText(), ""); + if (text.isEmpty()) { + continue; + } + + PDFont font = fontMap.get(buildFontKey(null, pageNumber, element.getFontId())); + if (font == null && element.getFontId() != null) { + font = fontMap.get(buildFontKey(null, -1, element.getFontId())); + } + + if (font == null) { + fallbackNeeded = true; + fallbackIds.add(FALLBACK_FONT_ID); + element.setFallbackUsed(Boolean.TRUE); + continue; + } + + PdfJsonFont fontModel = resolveFontModel(fontLookup, pageNumber, element.getFontId()); + if (font instanceof PDType3Font && fontModel != null) { + Set supportedGlyphs = + type3GlyphCache.computeIfAbsent( + fontModel.getUid() != null ? fontModel.getUid() : fontModel.getId(), + key -> { + List glyphs = fontModel.getType3Glyphs(); + if (glyphs == null || glyphs.isEmpty()) { + return Collections.emptySet(); + } + return glyphs.stream() + .map(PdfJsonFontType3Glyph::getUnicode) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + }); + + boolean missingGlyph = false; + for (int offset = 0; offset < text.length(); ) { + int codePoint = text.codePointAt(offset); + offset += Character.charCount(codePoint); + if (!supportedGlyphs.contains(codePoint)) { + missingGlyph = true; + break; + } + } + + if (missingGlyph) { + fallbackNeeded = true; + element.setFallbackUsed(Boolean.TRUE); + for (int offset = 0; offset < text.length(); ) { + int codePoint = text.codePointAt(offset); + offset += Character.charCount(codePoint); + if (!supportedGlyphs.contains(codePoint)) { + String fallbackId = + fallbackFontService.resolveFallbackFontId(codePoint); + fallbackIds.add(fallbackId != null ? fallbackId : FALLBACK_FONT_ID); + } + } + } + continue; + } + + if (!fallbackFontService.canEncodeFully(font, text)) { + String fontName = + fontModel != null && fontModel.getBaseName() != null + ? fontModel + .getBaseName() + .replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix + : (font != null ? font.getName() : "unknown"); + String fontKey = fontName + ":" + element.getFontId() + ":" + pageNumber; + if (!warnedFonts.contains(fontKey)) { + log.warn( + "[FALLBACK-NEEDED] Font '{}' (resource {}, subtype {}) cannot encode text on page {}. Using fallback font.", + fontName, + element.getFontId(), + fontModel != null ? fontModel.getSubtype() : "unknown", + pageNumber); + warnedFonts.add(fontKey); + } + fallbackNeeded = true; + element.setFallbackUsed(Boolean.TRUE); + for (int offset = 0; offset < text.length(); ) { + int codePoint = text.codePointAt(offset); + offset += Character.charCount(codePoint); + if (!fallbackFontService.canEncode(font, codePoint)) { + String fallbackId = fallbackFontService.resolveFallbackFontId(codePoint); + fallbackIds.add(fallbackId != null ? fallbackId : FALLBACK_FONT_ID); + } + } + } + } + + for (String fallbackId : fallbackIds) { + ensureFallbackFont(document, fontMap, fontModels, fallbackId); + } + + if (fallbackNeeded && fallbackIds.isEmpty()) { + fallbackIds.add(FALLBACK_FONT_ID); + ensureFallbackFont(document, fontMap, fontModels, FALLBACK_FONT_ID); + } + + return new PreflightResult(fallbackNeeded, fallbackIds); + } + + private void ensureFallbackResources( + PDPage page, Set fallbackFontIds, Map fontMap) { + if (fallbackFontIds == null || fallbackFontIds.isEmpty()) { + return; + } + PDResources resources = page.getResources(); + if (resources == null) { + resources = new PDResources(); + page.setResources(resources); + } + for (String fallbackId : fallbackFontIds) { + if (fallbackId == null) { + continue; + } + PDFont fallbackFont = fontMap.get(buildFontKey(null, -1, fallbackId)); + if (fallbackFont == null) { + continue; + } + COSName fallbackName = COSName.getPDFName(fallbackId); + boolean exists = false; + for (COSName name : resources.getFontNames()) { + if (fallbackName.equals(name)) { + exists = true; + break; + } + } + if (!exists) { + resources.put(fallbackName, fallbackFont); + } + } + } + + private PDFont ensureFallbackFont( + PDDocument document, + Map fontMap, + List fontModels, + String fallbackId) + throws IOException { + String effectiveId = fallbackId != null ? fallbackId : FALLBACK_FONT_ID; + String key = buildFontKey(null, -1, effectiveId); + PDFont font = fontMap.get(key); + if (font != null) { + log.debug( + "[FALLBACK-DEBUG] Reusing cached fallback font {} (key: {})", effectiveId, key); + return font; + } + log.info( + "[FALLBACK-DEBUG] Loading fallback font {} (key: {}) via fallbackFontService", + effectiveId, + key); + PDFont loaded = fallbackFontService.loadFallbackPdfFont(document, effectiveId); + log.info( + "[FALLBACK-DEBUG] Loaded fallback font {} - PDFont class: {}, name: {}", + effectiveId, + loaded.getClass().getSimpleName(), + loaded.getName()); + fontMap.put(key, loaded); + if (fontModels != null + && fontModels.stream().noneMatch(f -> effectiveId.equals(f.getId()))) { + fontModels.add(fallbackFontService.buildFallbackFontModel(effectiveId)); + } + return loaded; + } + + private boolean canRunGhostscript() { + if (!fontNormalizationEnabled) { + return false; + } + if (!isGhostscriptGroupEnabled()) { + return false; + } + if (!ghostscriptAvailable) { + log.debug("Skipping Ghostscript normalization; executable not available"); + return false; + } + return true; + } + + private boolean isGhostscriptGroupEnabled() { + try { + return endpointConfiguration != null + && endpointConfiguration.isGroupEnabled("Ghostscript"); + } catch (Exception ex) { + log.debug("Ghostscript group check failed: {}", ex.getMessage()); + return false; + } + } + + private TempFile normalizePdfFonts(Path sourcePath) throws IOException { + if (sourcePath == null || !Files.exists(sourcePath)) { + return null; + } + TempFile outputFile = new TempFile(tempFileManager, ".pdf"); + List command = new ArrayList<>(); + command.add("gs"); + command.add("-sDEVICE=pdfwrite"); + command.add("-dCompatibilityLevel=1.7"); + command.add("-dPDFSETTINGS=/prepress"); + command.add("-dEmbedAllFonts=true"); + command.add("-dSubsetFonts=true"); + command.add("-dCompressFonts=true"); + command.add("-dNOPAUSE"); + command.add("-dBATCH"); + command.add("-dQUIET"); + command.add("-o"); + command.add(outputFile.getAbsolutePath()); + command.add("-c"); + command.add("<> setdistillerparams"); + command.add("-f"); + command.add(sourcePath.toString()); + try { + ProcessExecutorResult result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT) + .runCommandWithOutputHandling(command); + if (result.getRc() == 0 + && Files.exists(outputFile.getPath()) + && Files.size(outputFile.getPath()) > 0) { + return outputFile; + } + log.warn("Ghostscript normalization exited with code {}", result.getRc()); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + closeQuietly(outputFile); + throw new IOException("Ghostscript normalization interrupted", ex); + } catch (IOException ex) { + closeQuietly(outputFile); + throw ex; + } + + closeQuietly(outputFile); + return null; + } + + private byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) { + return fontService.convertCffProgramToTrueType(fontBytes, toUnicode); + } + + private String buildUnicodeMapping(PDFont font, String toUnicodeBase64) throws IOException { + if (toUnicodeBase64 == null || toUnicodeBase64.isBlank()) { + return null; + } + + // For CID fonts (Type0), build complete CharCode→CID→GID→Unicode mapping + if (!(font instanceof PDType0Font type0Font)) { + // For non-CID fonts, just return ToUnicode as-is + return toUnicodeBase64; + } + + try { + // Build a map of CharCode → Unicode from ToUnicode + Map charCodeToUnicode = new HashMap<>(); + byte[] toUnicodeBytes = Base64.getDecoder().decode(toUnicodeBase64); + String toUnicodeStr = new String(toUnicodeBytes, StandardCharsets.UTF_8); + + // Parse ToUnicode CMap for bfchar and bfrange + java.util.regex.Pattern bfcharPattern = + java.util.regex.Pattern.compile("<([0-9A-Fa-f]+)>\\s*<([0-9A-Fa-f]+)>"); + java.util.regex.Matcher matcher = bfcharPattern.matcher(toUnicodeStr); + while (matcher.find()) { + int charCode = Integer.parseInt(matcher.group(1), 16); + int unicode = Integer.parseInt(matcher.group(2), 16); + charCodeToUnicode.put(charCode, unicode); + } + + // Build JSON mapping: CharCode → CID → GID → Unicode + StringBuilder json = new StringBuilder(); + json.append("{\"isCID\":true,\"cidToGidIdentity\":true,\"entries\":["); + + boolean first = true; + for (Map.Entry entry : charCodeToUnicode.entrySet()) { + int charCode = entry.getKey(); + int unicode = entry.getValue(); + + try { + // Get CID from char code + int cid = type0Font.codeToCID(charCode); + // For Identity-H/V encoding, GID == CID + int gid = cid; + + if (!first) { + json.append(","); + } + first = false; + json.append( + String.format( + "{\"code\":%d,\"cid\":%d,\"gid\":%d,\"unicode\":%d}", + charCode, cid, gid, unicode)); + } catch (Exception e) { + // Skip entries that fail to map + log.debug( + "Failed to map charCode {} in font {}: {}", + charCode, + font.getName(), + e.getMessage()); + } + } + + json.append("]}"); + String jsonStr = json.toString(); + log.debug( + "Built Unicode mapping for CID font {} with {} entries", + font.getName(), + charCodeToUnicode.size()); + return Base64.getEncoder().encodeToString(jsonStr.getBytes(StandardCharsets.UTF_8)); + + } catch (Exception e) { + log.warn( + "Failed to build Unicode mapping for font {}: {}", + font.getName(), + e.getMessage()); + return toUnicodeBase64; // Fall back to raw ToUnicode + } + } + + private PdfJsonFontCidSystemInfo extractCidSystemInfo(COSDictionary fontDictionary) { + if (fontDictionary == null) { + return null; + } + COSBase base = fontDictionary.getDictionaryObject(COSName.CIDSYSTEMINFO); + if (!(base instanceof COSDictionary cidDictionary)) { + return null; + } + String registry = cidDictionary.getString(COSName.REGISTRY); + String ordering = cidDictionary.getString(COSName.ORDERING); + int supplementValue = cidDictionary.getInt(COSName.SUPPLEMENT, -1); + if (registry == null && ordering == null && supplementValue < 0) { + return null; + } + PdfJsonFontCidSystemInfo info = new PdfJsonFontCidSystemInfo(); + info.setRegistry(registry); + info.setOrdering(ordering); + if (supplementValue >= 0) { + info.setSupplement(supplementValue); + } + return info; + } + + private FontProgramData extractFontProgram(PDFont font, String toUnicode) throws IOException { + PDFontDescriptor descriptor = font.getFontDescriptor(); + if (descriptor == null) { + return null; + } + + PDStream fontFile3 = descriptor.getFontFile3(); + if (fontFile3 != null) { + String subtype = fontFile3.getCOSObject().getNameAsString(COSName.SUBTYPE); + log.info( + "[FONT-DEBUG] Font {}: Found FontFile3 with subtype {}", + font.getName(), + subtype); + return readFontProgram( + fontFile3, subtype != null ? subtype : "fontfile3", false, toUnicode); + } + + PDStream fontFile2 = descriptor.getFontFile2(); + if (fontFile2 != null) { + log.debug("[FONT-DEBUG] Font {}: Found FontFile2 (TrueType)", font.getName()); + return readFontProgram(fontFile2, null, true, toUnicode); + } + + PDStream fontFile = descriptor.getFontFile(); + if (fontFile != null) { + log.debug("[FONT-DEBUG] Font {}: Found FontFile (Type1)", font.getName()); + return readFontProgram(fontFile, "type1", false, toUnicode); + } + + log.debug("[FONT-DEBUG] Font {}: No font program found", font.getName()); + return null; + } + + private FontProgramData readFontProgram( + PDStream stream, String formatHint, boolean detectTrueType, String toUnicode) + throws IOException { + try (InputStream inputStream = stream.createInputStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + inputStream.transferTo(baos); + byte[] data = baos.toByteArray(); + String format = formatHint; + if (detectTrueType) { + format = fontService.detectTrueTypeFormat(data); + } + log.debug( + "[FONT-DEBUG] Font program: size={} bytes, formatHint={}, detectedFormat={}", + data.length, + formatHint, + format); + + String webBase64 = null; + String webFormat = null; + String pdfBase64 = null; + String pdfFormat = null; + if (format != null && isCffFormat(format)) { + log.debug( + "[FONT-DEBUG] Font is CFF format, attempting conversion. CFF conversion enabled: {}, method: {}", + fontService.isCffConversionEnabled(), + fontService.getCffConverterMethod()); + + byte[] converted = convertCffProgramToTrueType(data, toUnicode); + if (converted != null && converted.length > 0) { + String detectedFormat = fontService.detectFontFlavor(converted); + webBase64 = Base64.getEncoder().encodeToString(converted); + webFormat = detectedFormat; + log.debug( + "[FONT-DEBUG] Primary CFF conversion succeeded: {} bytes -> {}", + data.length, + detectedFormat); + if ("ttf".equals(detectedFormat)) { + pdfBase64 = webBase64; + pdfFormat = detectedFormat; + } + } else { + log.debug("[FONT-DEBUG] Primary CFF conversion returned null/empty"); + } + + if (pdfBase64 == null && fontService.isCffConversionEnabled()) { + log.debug("[FONT-DEBUG] Attempting fallback FontForge conversion"); + byte[] ttfConverted = fontService.convertCffUsingFontForge(data); + if (ttfConverted != null && ttfConverted.length > 0) { + String detectedFormat = fontService.detectFontFlavor(ttfConverted); + if (detectedFormat != null) { + pdfBase64 = Base64.getEncoder().encodeToString(ttfConverted); + pdfFormat = detectedFormat; + if (webBase64 == null) { + webBase64 = pdfBase64; + webFormat = detectedFormat; + } + log.debug( + "[FONT-DEBUG] FontForge conversion succeeded: {} bytes -> {}", + data.length, + detectedFormat); + } + } else { + log.debug("[FONT-DEBUG] FontForge conversion also returned null/empty"); + } + } + + if (webBase64 == null && pdfBase64 == null) { + log.warn( + "[FONT-DEBUG] ALL CFF conversions failed - font will not be usable in browser!"); + } + } else if (format != null) { + log.debug("[FONT-DEBUG] Font is non-CFF format ({}), using as-is", format); + // For non-CFF formats (TrueType, etc.), preserve original font stream as pdfProgram + // This allows PDFBox to reconstruct the font during JSON->PDF + String base64 = Base64.getEncoder().encodeToString(data); + pdfBase64 = base64; + pdfFormat = format; + } + + String base64 = Base64.getEncoder().encodeToString(data); + return new FontProgramData(base64, format, webBase64, webFormat, pdfBase64, pdfFormat); + } + } + + private String extractToUnicode(COSDictionary fontDictionary) throws IOException { + if (fontDictionary == null) { + return null; + } + COSBase base = fontDictionary.getDictionaryObject(COSName.TO_UNICODE); + if (!(base instanceof COSStream stream)) { + return null; + } + try (InputStream inputStream = stream.createInputStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + inputStream.transferTo(baos); + byte[] data = baos.toByteArray(); + if (data.length == 0) { + return null; + } + return Base64.getEncoder().encodeToString(data); + } + } + + private String resolveEncoding(PDFont font) { + if (font == null) { + return null; + } + COSDictionary dictionary = font.getCOSObject(); + if (dictionary == null) { + return null; + } + COSBase encoding = dictionary.getDictionaryObject(COSName.ENCODING); + if (encoding instanceof COSName name) { + return name.getName(); + } + if (encoding instanceof COSDictionary encodingDictionary) { + return encodingDictionary.getNameAsString(COSName.BASE_ENCODING); + } + return null; + } + + private String resolveStandard14Name(PDFont font) { + if (font == null) { + return null; + } + try { + Standard14Fonts.FontName mapped = Standard14Fonts.getMappedFontName(font.getName()); + return mapped != null ? mapped.getName() : null; + } catch (IllegalArgumentException ex) { + return null; + } + } + + /** + * Fuzzy match a font name against Standard14 fonts as a last resort. Handles common variations + * like "TimesNewRoman" → "Times-Roman", "Arial" → "Helvetica", etc. + * + * @param baseName the font base name to match + * @return matched Standard14 font, or null if no reasonable match found + */ + private Standard14Fonts.FontName fuzzyMatchStandard14(String baseName) { + if (baseName == null || baseName.isBlank()) { + return null; + } + + // Normalize: lowercase, remove spaces/hyphens/underscores, strip prefix (ABCD+FontName) + String normalized = baseName.trim(); + int plusIndex = normalized.indexOf('+'); + if (plusIndex >= 0 && plusIndex < normalized.length() - 1) { + normalized = normalized.substring(plusIndex + 1); + } + normalized = normalized.toLowerCase(Locale.ROOT).replaceAll("[\\s\\-_]", ""); + + // Exact match after normalization + try { + Standard14Fonts.FontName exact = Standard14Fonts.getMappedFontName(baseName); + if (exact != null) { + return exact; + } + } catch (IllegalArgumentException ignored) { + // Not an exact match, continue with fuzzy matching + } + + // Times family: Times, TimesRoman, TimesNewRoman, TNR + if (normalized.contains("times") || normalized.equals("tnr")) { + if (normalized.contains("bold") && normalized.contains("italic")) { + return Standard14Fonts.FontName.TIMES_BOLD_ITALIC; + } + if (normalized.contains("bold")) { + return Standard14Fonts.FontName.TIMES_BOLD; + } + if (normalized.contains("italic") || normalized.contains("oblique")) { + return Standard14Fonts.FontName.TIMES_ITALIC; + } + return Standard14Fonts.FontName.TIMES_ROMAN; + } + + // Helvetica family: Helvetica, Arial, Swiss + if (normalized.contains("helvetica") + || normalized.contains("arial") + || normalized.contains("swiss")) { + if (normalized.contains("bold") && normalized.contains("oblique")) { + return Standard14Fonts.FontName.HELVETICA_BOLD_OBLIQUE; + } + if (normalized.contains("bold")) { + return Standard14Fonts.FontName.HELVETICA_BOLD; + } + if (normalized.contains("oblique") || normalized.contains("italic")) { + return Standard14Fonts.FontName.HELVETICA_OBLIQUE; + } + return Standard14Fonts.FontName.HELVETICA; + } + + // Courier family: Courier, CourierNew, Mono, Monospace + if (normalized.contains("courier") || normalized.contains("mono")) { + if (normalized.contains("bold") + && (normalized.contains("oblique") || normalized.contains("italic"))) { + return Standard14Fonts.FontName.COURIER_BOLD_OBLIQUE; + } + if (normalized.contains("bold")) { + return Standard14Fonts.FontName.COURIER_BOLD; + } + if (normalized.contains("oblique") || normalized.contains("italic")) { + return Standard14Fonts.FontName.COURIER_OBLIQUE; + } + return Standard14Fonts.FontName.COURIER; + } + + // Symbol and ZapfDingbats (less common) + if (normalized.contains("symbol")) { + return Standard14Fonts.FontName.SYMBOL; + } + if (normalized.contains("zapf") || normalized.contains("dingbat")) { + return Standard14Fonts.FontName.ZAPF_DINGBATS; + } + + // No reasonable match found + return null; + } + + private List extractPages( + PDDocument document, + Map> textByPage, + Map> imagesByPage, + Map> annotationsByPage) + throws IOException { + List pages = new ArrayList<>(); + int pageIndex = 0; + for (PDPage page : document.getPages()) { + PdfJsonPage pageModel = new PdfJsonPage(); + pageModel.setPageNumber(pageIndex + 1); + // Use CropBox if present (defines visible page area), otherwise fall back to MediaBox + PDRectangle pageBox = page.getCropBox(); + if (pageBox == null || pageBox.getWidth() == 0 || pageBox.getHeight() == 0) { + pageBox = page.getMediaBox(); + } + pageModel.setWidth(pageBox.getWidth()); + pageModel.setHeight(pageBox.getHeight()); + pageModel.setRotation(page.getRotation()); + pageModel.setTextElements(textByPage.getOrDefault(pageIndex + 1, new ArrayList<>())); + pageModel.setImageElements(imagesByPage.getOrDefault(pageIndex + 1, new ArrayList<>())); + pageModel.setAnnotations( + annotationsByPage.getOrDefault(pageIndex + 1, new ArrayList<>())); + // Serialize resources but exclude image XObject streams to avoid duplication with + // imageElements + COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES); + COSBase filteredResources = filterImageXObjectsFromResources(resourcesBase); + pageModel.setResources(cosMapper.serializeCosValue(filteredResources)); + pageModel.setContentStreams(extractContentStreams(page)); + pages.add(pageModel); + pageIndex++; + } + return pages; + } + + private Map> collectImages( + PDDocument document, + int totalPages, + Consumer progress, + Map imageCache) + throws IOException { + Map> imagesByPage = new LinkedHashMap<>(); + int pageNumber = 1; + for (PDPage page : document.getPages()) { + ImageCollectingEngine engine = + new ImageCollectingEngine(page, pageNumber, imagesByPage, imageCache); + engine.processPage(page); + + // Update progress for image extraction (70-80%) + int imageProgress = 70 + (int) ((pageNumber / (double) totalPages) * 10); + progress.accept( + PdfJsonConversionProgress.of( + imageProgress, "images", "Extracting images", pageNumber, totalPages)); + pageNumber++; + } + return imagesByPage; + } + + private Map> collectAnnotations( + PDDocument document, int totalPages, Consumer progress) + throws IOException { + Map> annotationsByPage = new LinkedHashMap<>(); + int pageNumber = 1; + for (PDPage page : document.getPages()) { + List annotations = new ArrayList<>(); + for (PDAnnotation annotation : page.getAnnotations()) { + try { + PdfJsonAnnotation ann = new PdfJsonAnnotation(); + ann.setSubtype(annotation.getSubtype()); + ann.setContents(annotation.getContents()); + + PDRectangle rect = annotation.getRectangle(); + if (rect != null) { + ann.setRect( + List.of( + rect.getLowerLeftX(), + rect.getLowerLeftY(), + rect.getUpperRightX(), + rect.getUpperRightY())); + } + + COSName appearanceState = annotation.getAppearanceState(); + if (appearanceState != null) { + ann.setAppearanceState(appearanceState.getName()); + } + + if (annotation.getColor() != null) { + float[] colorComponents = annotation.getColor().getComponents(); + List colorList = new ArrayList<>(colorComponents.length); + for (float c : colorComponents) { + colorList.add(c); + } + ann.setColor(colorList); + } + + COSDictionary annotDict = annotation.getCOSObject(); + COSString title = (COSString) annotDict.getDictionaryObject(COSName.T); + if (title != null) { + ann.setAuthor(title.getString()); + } + + COSString subj = (COSString) annotDict.getDictionaryObject(COSName.SUBJ); + if (subj != null) { + ann.setSubject(subj.getString()); + } + + COSString creationDateStr = + (COSString) annotDict.getDictionaryObject(COSName.CREATION_DATE); + if (creationDateStr != null) { + try { + Calendar creationDate = + DateConverter.toCalendar(creationDateStr.getString()); + ann.setCreationDate(formatCalendar(creationDate)); + } catch (Exception e) { + log.debug( + "Failed to parse annotation creation date: {}", e.getMessage()); + } + } + + COSString modDateStr = (COSString) annotDict.getDictionaryObject(COSName.M); + if (modDateStr != null) { + try { + Calendar modDate = DateConverter.toCalendar(modDateStr.getString()); + ann.setModificationDate(formatCalendar(modDate)); + } catch (Exception e) { + log.debug( + "Failed to parse annotation modification date: {}", + e.getMessage()); + } + } + + // Store raw dictionary for lossless round-trip + ann.setRawData(cosMapper.serializeCosValue(annotDict)); + + annotations.add(ann); + } catch (Exception e) { + log.warn( + "Failed to extract annotation on page {}: {}", + pageNumber, + e.getMessage()); + } + } + if (!annotations.isEmpty()) { + annotationsByPage.put(pageNumber, annotations); + } + + // Update progress for annotation collection (80-90%) + int annotationProgress = 80 + (int) ((pageNumber / (double) totalPages) * 10); + progress.accept( + PdfJsonConversionProgress.of( + annotationProgress, + "annotations", + "Collecting annotations", + pageNumber, + totalPages)); + pageNumber++; + } + return annotationsByPage; + } + + private List collectFormFields(PDDocument document) { + List formFields = new ArrayList<>(); + PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(); + if (acroForm == null) { + return formFields; + } + + try { + for (PDField field : acroForm.getFields()) { + try { + PdfJsonFormField formField = new PdfJsonFormField(); + formField.setName(field.getFullyQualifiedName()); + formField.setPartialName(field.getPartialName()); + formField.setFieldType(field.getFieldType()); + formField.setValue(field.getValueAsString()); + + // Get default value from COS dictionary + COSBase dv = field.getCOSObject().getDictionaryObject(COSName.DV); + if (dv != null) { + if (dv instanceof COSString) { + formField.setDefaultValue(((COSString) dv).getString()); + } else if (dv instanceof COSName) { + formField.setDefaultValue(((COSName) dv).getName()); + } + } + + formField.setFlags(field.getFieldFlags()); + formField.setAlternateFieldName(field.getAlternateFieldName()); + formField.setMappingName(field.getMappingName()); + + // Find which page the field is on + PDAnnotationWidget widget = + field.getWidgets().isEmpty() ? null : field.getWidgets().get(0); + if (widget != null) { + PDPage fieldPage = widget.getPage(); + if (fieldPage != null) { + int pageNum = document.getPages().indexOf(fieldPage) + 1; + formField.setPageNumber(pageNum); + + PDRectangle rect = widget.getRectangle(); + if (rect != null) { + formField.setRect( + List.of( + rect.getLowerLeftX(), + rect.getLowerLeftY(), + rect.getUpperRightX(), + rect.getUpperRightY())); + } + } + } + + // Store raw dictionary for lossless round-trip + formField.setRawData(cosMapper.serializeCosValue(field.getCOSObject())); + + formFields.add(formField); + } catch (Exception e) { + log.warn( + "Failed to extract form field {}: {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + } catch (Exception e) { + log.warn("Failed to extract form fields: {}", e.getMessage()); + } + + return formFields; + } + + /** + * Filters out image XObject streams from resources to avoid duplication with imageElements. + * Images are already captured in imageElements[] with their base64 data, so we don't need them + * in the resources dictionary. + */ + private COSBase filterImageXObjectsFromResources(COSBase resourcesBase) { + if (!(resourcesBase instanceof COSDictionary)) { + return resourcesBase; + } + + // Clone the resources dictionary + COSDictionary resources = new COSDictionary((COSDictionary) resourcesBase); + + // Get the XObject dictionary + COSBase xobjectBase = resources.getDictionaryObject(COSName.XOBJECT); + if (!(xobjectBase instanceof COSDictionary)) { + return resources; + } + + COSDictionary xobjects = (COSDictionary) xobjectBase; + COSDictionary filteredXObjects = new COSDictionary(); + + // Copy all XObjects except images + for (COSName key : xobjects.keySet()) { + COSBase value = xobjects.getDictionaryObject(key); + if (value instanceof COSStream) { + COSStream stream = (COSStream) value; + COSName type = (COSName) stream.getDictionaryObject(COSName.TYPE); + COSName subtype = (COSName) stream.getDictionaryObject(COSName.SUBTYPE); + + // Skip if this is an Image XObject + if (COSName.XOBJECT.equals(type) && COSName.IMAGE.equals(subtype)) { + continue; + } + } + // Keep non-image XObjects (Form XObjects, etc.) + filteredXObjects.setItem(key, value); + } + + // If all XObjects were images, remove the XObject entry entirely + if (filteredXObjects.keySet().isEmpty()) { + resources.removeItem(COSName.XOBJECT); + } else { + resources.setItem(COSName.XOBJECT, filteredXObjects); + } + + return resources; + } + + private PdfJsonMetadata extractMetadata(PDDocument document) { + PdfJsonMetadata metadata = new PdfJsonMetadata(); + PDDocumentInformation info = document.getDocumentInformation(); + if (info != null) { + metadata.setTitle(info.getTitle()); + metadata.setAuthor(info.getAuthor()); + metadata.setSubject(info.getSubject()); + metadata.setKeywords(info.getKeywords()); + metadata.setCreator(info.getCreator()); + metadata.setProducer(info.getProducer()); + metadata.setCreationDate(formatCalendar(info.getCreationDate())); + metadata.setModificationDate(formatCalendar(info.getModificationDate())); + metadata.setTrapped(info.getTrapped()); + } + metadata.setNumberOfPages(document.getNumberOfPages()); + return metadata; + } + + private String extractXmpMetadata(PDDocument document) { + if (document.getDocumentCatalog() == null) { + return null; + } + PDMetadata metadata = document.getDocumentCatalog().getMetadata(); + if (metadata == null) { + return null; + } + try (InputStream inputStream = metadata.createInputStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + inputStream.transferTo(baos); + byte[] data = baos.toByteArray(); + if (data.length == 0) { + return null; + } + return Base64.getEncoder().encodeToString(data); + } catch (IOException ex) { + log.debug("Failed to extract XMP metadata: {}", ex.getMessage()); + return null; + } + } + + private void applyMetadata(PDDocument document, PdfJsonMetadata metadata) { + if (metadata == null) { + return; + } + PDDocumentInformation info = document.getDocumentInformation(); + info.setTitle(metadata.getTitle()); + info.setAuthor(metadata.getAuthor()); + info.setSubject(metadata.getSubject()); + info.setKeywords(metadata.getKeywords()); + info.setCreator(metadata.getCreator()); + info.setProducer(metadata.getProducer()); + if (metadata.getCreationDate() != null) { + parseInstant(metadata.getCreationDate()) + .ifPresent(instant -> info.setCreationDate(toCalendar(instant))); + } + if (metadata.getModificationDate() != null) { + parseInstant(metadata.getModificationDate()) + .ifPresent(instant -> info.setModificationDate(toCalendar(instant))); + } + info.setTrapped(metadata.getTrapped()); + } + + private void applyXmpMetadata(PDDocument document, String base64) { + if (base64 == null || base64.isBlank()) { + return; + } + try (InputStream inputStream = + new ByteArrayInputStream(Base64.getDecoder().decode(base64))) { + PDMetadata metadata = new PDMetadata(document, inputStream); + document.getDocumentCatalog().setMetadata(metadata); + } catch (IllegalArgumentException | IOException ex) { + log.debug("Failed to apply XMP metadata: {}", ex.getMessage()); + } + } + + private void restoreAnnotations( + PDDocument document, PDPage page, List annotations) { + if (annotations == null || annotations.isEmpty()) { + return; + } + + for (PdfJsonAnnotation annModel : annotations) { + try { + // Restore from raw COS data if available for lossless round-trip + if (annModel.getRawData() != null) { + COSBase rawAnnot = + cosMapper.deserializeCosValue(annModel.getRawData(), document); + if (rawAnnot instanceof COSDictionary) { + PDAnnotation annotation = + PDAnnotation.createAnnotation((COSDictionary) rawAnnot); + page.getAnnotations().add(annotation); + log.debug("Restored annotation from raw data: {}", annModel.getSubtype()); + continue; + } + } + + // Fallback: reconstruct from structured fields + // Note: This is simplified - full annotation reconstruction is complex + // Most use cases should rely on rawData for lossless round-trip + log.debug( + "Warning: Annotation {} has no rawData, basic reconstruction may lose information", + annModel.getSubtype()); + + } catch (Exception e) { + log.warn( + "Failed to restore annotation {}: {}", + annModel.getSubtype(), + e.getMessage()); + } + } + } + + private void restoreFormFields(PDDocument document, List formFields) { + if (formFields == null || formFields.isEmpty()) { + return; + } + + try { + PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(); + if (acroForm == null) { + acroForm = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(acroForm); + } + + COSArray fieldsArray = + (COSArray) acroForm.getCOSObject().getDictionaryObject(COSName.FIELDS); + if (fieldsArray == null) { + fieldsArray = new COSArray(); + acroForm.getCOSObject().setItem(COSName.FIELDS, fieldsArray); + } + + for (PdfJsonFormField fieldModel : formFields) { + try { + // Restore from raw COS data if available for lossless round-trip + if (fieldModel.getRawData() != null) { + COSBase rawField = + cosMapper.deserializeCosValue(fieldModel.getRawData(), document); + if (rawField instanceof COSDictionary) { + // Add the field dictionary directly to the fields array + fieldsArray.add(rawField); + log.debug( + "Restored form field from raw data: {}", fieldModel.getName()); + continue; + } + } + + // Fallback: reconstruct from structured fields + // Note: This is simplified - full field reconstruction is complex + log.debug( + "Warning: Form field {} has no rawData, basic reconstruction may lose information", + fieldModel.getName()); + + } catch (Exception e) { + log.warn( + "Failed to restore form field {}: {}", + fieldModel.getName(), + e.getMessage()); + } + } + } catch (Exception e) { + log.warn("Failed to restore form fields: {}", e.getMessage()); + } + } + + private void applyPageResources( + PDDocument document, PDPage page, PdfJsonCosValue resourcesModel) throws IOException { + if (resourcesModel == null) { + return; + } + COSBase base = cosMapper.deserializeCosValue(resourcesModel, document); + if (base instanceof COSDictionary dictionary) { + page.setResources(new PDResources(dictionary)); + } + } + + /** + * Reconstructs image XObjects from imageElements when content streams are preserved. During + * serialization, image streams are filtered out from resources to avoid duplication. This + * method adds them back by scanning content streams for XObject references and matching them + * with imageElements by objectName. + */ + private void reconstructImageXObjects( + PDDocument document, + PDPage page, + List contentStreams, + List imageElements) + throws IOException { + + // Build map of objectName -> imageElement + Map imageMap = new HashMap<>(); + for (PdfJsonImageElement img : imageElements) { + if (img.getObjectName() != null && !img.getObjectName().isBlank()) { + imageMap.put(img.getObjectName(), img); + } + } + + if (imageMap.isEmpty()) { + return; + } + + // Scan content streams for image XObject references + Set referencedXObjects = new HashSet<>(); + for (PDStream stream : contentStreams) { + try { + byte[] contentBytes = stream.toByteArray(); + PDFStreamParser parser = new PDFStreamParser(contentBytes); + List tokens = parser.parse(); + + for (int i = 0; i < tokens.size(); i++) { + Object token = tokens.get(i); + if (token instanceof Operator op + && OperatorName.DRAW_OBJECT.equals(op.getName())) { + if (i > 0 && tokens.get(i - 1) instanceof COSName name) { + referencedXObjects.add(name.getName()); + } + } + } + } catch (Exception e) { + log.warn("Failed to parse content stream for image references: {}", e.getMessage()); + } + } + + // Reconstruct referenced image XObjects + PDResources resources = page.getResources(); + if (resources == null) { + resources = new PDResources(); + page.setResources(resources); + } + + for (String xobjName : referencedXObjects) { + PdfJsonImageElement imageElement = imageMap.get(xobjName); + if (imageElement == null) { + log.warn( + "Content stream references image XObject '{}' but no matching imageElement found", + xobjName); + continue; + } + + try { + PDImageXObject image = createImageXObject(document, imageElement); + if (image != null) { + resources.put(COSName.getPDFName(xobjName), image); + log.debug("Reconstructed image XObject: {}", xobjName); + } + } catch (Exception e) { + log.warn("Failed to reconstruct image XObject '{}': {}", xobjName, e.getMessage()); + } + } + } + + private List buildContentStreams( + PDDocument document, List streamModels) throws IOException { + List streams = new ArrayList<>(); + if (streamModels == null) { + return streams; + } + for (PdfJsonStream streamModel : streamModels) { + if (streamModel == null) { + continue; + } + COSStream cosStream = cosMapper.buildStreamFromModel(streamModel, document); + if (cosStream != null) { + streams.add(new PDStream(cosStream)); + } + } + return streams; + } + + private List extractContentStreams(PDPage page) throws IOException { + List streams = new ArrayList<>(); + Iterator iterator = page.getContentStreams(); + if (iterator == null) { + return streams; + } + while (iterator.hasNext()) { + PDStream stream = iterator.next(); + PdfJsonStream model = cosMapper.serializeStream(stream); + if (model != null) { + streams.add(model); + } + } + return streams; + } + + private PDStream extractVectorGraphics( + PDDocument document, + List preservedStreams, + List imageElements) + throws IOException { + if (preservedStreams == null || preservedStreams.isEmpty()) { + return null; + } + + Set imageObjectNames = new HashSet<>(); + if (imageElements != null) { + for (PdfJsonImageElement element : imageElements) { + if (element == null) { + continue; + } + String objectName = element.getObjectName(); + if (objectName != null && !objectName.isBlank()) { + imageObjectNames.add(objectName); + } + } + } + + List filteredTokens = new ArrayList<>(); + for (PDStream stream : preservedStreams) { + if (stream == null) { + continue; + } + try { + PDFStreamParser parser = new PDFStreamParser(stream.toByteArray()); + List tokens = parser.parse(); + collectVectorTokens(tokens, filteredTokens, imageObjectNames); + } catch (IOException ex) { + log.debug( + "Failed to parse preserved content stream for vector extraction: {}", + ex.getMessage()); + } + } + + if (filteredTokens.isEmpty()) { + return null; + } + + PDStream vectorStream = new PDStream(document); + try (OutputStream outputStream = vectorStream.createOutputStream(COSName.FLATE_DECODE)) { + new ContentStreamWriter(outputStream).writeTokens(filteredTokens); + } + return vectorStream; + } + + private void collectVectorTokens( + List sourceTokens, List targetTokens, Set imageObjectNames) { + if (sourceTokens == null || sourceTokens.isEmpty()) { + return; + } + + boolean insideText = false; + boolean insideInlineImage = false; + + for (Object token : sourceTokens) { + if (token instanceof Operator operator) { + String name = operator.getName(); + if (OperatorName.BEGIN_TEXT.equals(name)) { + insideText = true; + continue; + } + if (OperatorName.END_TEXT.equals(name)) { + insideText = false; + continue; + } + if (OperatorName.BEGIN_INLINE_IMAGE.equals(name) + || OperatorName.BEGIN_INLINE_IMAGE_DATA.equals(name)) { + if (!insideText) { + targetTokens.add(operator); + } + insideInlineImage = true; + continue; + } + if (OperatorName.END_INLINE_IMAGE.equals(name)) { + if (!insideText) { + targetTokens.add(operator); + } + insideInlineImage = false; + continue; + } + if (insideText && !insideInlineImage) { + continue; + } + if (OperatorName.DRAW_OBJECT.equals(name) + && imageObjectNames != null + && !imageObjectNames.isEmpty() + && !targetTokens.isEmpty()) { + Object previous = targetTokens.get(targetTokens.size() - 1); + if (previous instanceof COSName cosName + && imageObjectNames.contains(cosName.getName())) { + targetTokens.remove(targetTokens.size() - 1); + continue; + } + } + targetTokens.add(operator); + } else { + if (insideText && !insideInlineImage) { + continue; + } + targetTokens.add(token); + } + } + } + + private void regeneratePageContent( + PDDocument document, + PDPage page, + List textElements, + List imageElements, + Map fontMap, + List fontModels, + int pageNumber, + AppendMode appendMode) + throws IOException { + List drawables = mergeDrawables(textElements, imageElements); + Map imageCache = new HashMap<>(); + Map runFontLookup = buildFontModelLookup(fontModels); + + AppendMode mode = appendMode != null ? appendMode : AppendMode.OVERWRITE; + try (PDPageContentStream contentStream = + new PDPageContentStream(document, page, mode, true, true)) { + boolean textOpen = false; + for (DrawableElement drawable : drawables) { + switch (drawable.type()) { + case TEXT -> { + PdfJsonTextElement element = drawable.textElement(); + if (element == null) { + continue; + } + String text = Objects.toString(element.getText(), ""); + + if (!textOpen) { + contentStream.beginText(); + textOpen = true; + } + + PDFont baseFont = + fontMap.get(buildFontKey(null, pageNumber, element.getFontId())); + if (baseFont == null && element.getFontId() != null) { + baseFont = fontMap.get(buildFontKey(null, -1, element.getFontId())); + } + + float fontScale = resolveFontMatrixSize(element); + + applyTextState(contentStream, element); + applyRenderingMode(contentStream, element.getRenderingMode()); + applyTextMatrix(contentStream, element); + + List runs = + buildFontRuns( + document, + fontMap, + fontModels, + pageNumber, + baseFont, + text, + element); + + PDFont activeFont = null; + for (FontRun run : runs) { + if (run == null || run.text().isEmpty()) { + continue; + } + if (run.font() != activeFont) { + contentStream.setFont(run.font(), fontScale); + activeFont = run.font(); + } + PdfJsonFont runFontModel = + resolveFontModel(runFontLookup, pageNumber, run.fontId()); + if (runFontModel == null) { + runFontLookup = buildFontModelLookup(fontModels); + runFontModel = + resolveFontModel(runFontLookup, pageNumber, run.fontId()); + } + // Check if this is a normalized Type3 font (has Type3 metadata but is + // not PDType3Font) + boolean isNormalizedType3 = + !(run.font() instanceof PDType3Font) + && runFontModel != null + && runFontModel.getType3Glyphs() != null + && !runFontModel.getType3Glyphs().isEmpty(); + + // For fonts with proper Unicode mappings, let PDFBox handle encoding + // This includes: normalized Type3 fonts, PDType0Font (composite fonts) + boolean useDirectText = + isNormalizedType3 + || run.font() + instanceof + org.apache.pdfbox.pdmodel.font.PDType0Font; + + if (useDirectText) { + // Pass text directly - PDFBox handles encoding internally + contentStream.showText(run.text()); + } else { + // For actual Type3 fonts and other fonts, encode manually + byte[] encoded; + if (run.font() instanceof PDType3Font + && run.charCodes() != null + && !run.charCodes().isEmpty()) { + encoded = encodeType3CharCodes(run.charCodes()); + if (encoded == null || encoded.length == 0) { + log.warn( + "[FONT-DEBUG] Failed to emit raw Type3 char codes for font {} on page {}", + run.font().getName(), + pageNumber); + continue; + } + } else { + try { + log.debug( + "[ENCODE-DEBUG] Encoding text '{}' with font {} (fontId={}, runFontModel={})", + run.text(), + run.font().getName(), + run.fontId(), + runFontModel != null + ? runFontModel.getId() + : "null"); + encoded = + encodeTextWithFont( + run.font(), + runFontModel, + run.text(), + run.charCodes()); + } catch (IOException ex) { + log.warn( + "Failed to encode text '{}' with font {} (fontId={}, runFontModel={}) on page {}: {}", + run.text(), + run.font().getName(), + run.fontId(), + runFontModel != null + ? runFontModel.getId() + : "null", + pageNumber, + ex.getMessage()); + continue; + } + } + if (encoded == null || encoded.length == 0) { + log.warn( + "Failed to encode text '{}' with font {} on page {}", + run.text(), + run.font().getName(), + pageNumber); + continue; + } + try { + contentStream.showText( + new String(encoded, StandardCharsets.ISO_8859_1)); + } catch (IllegalArgumentException ex) { + log.warn( + "Failed to render text '{}' with font {} on page {}: {}", + run.text(), + run.font().getName(), + pageNumber, + ex.getMessage()); + continue; + } + } + } + } + case IMAGE -> { + if (textOpen) { + contentStream.endText(); + textOpen = false; + } + PdfJsonImageElement element = drawable.imageElement(); + if (element == null) { + continue; + } + drawImageElement(contentStream, document, element, imageCache); + } + } + } + if (textOpen) { + contentStream.endText(); + } + } + } + + private List buildFontRuns( + PDDocument document, + Map fontMap, + List fontModels, + int pageNumber, + PDFont primaryFont, + String text, + PdfJsonTextElement element) + throws IOException { + List runs = new ArrayList<>(); + if (text == null || text.isEmpty()) { + return runs; + } + + PDFont baseFont = primaryFont; + String baseFontId = element.getFontId(); + boolean fallbackApplied = primaryFont == null; + if (baseFont == null) { + baseFont = ensureFallbackFont(document, fontMap, fontModels, FALLBACK_FONT_ID); + if (baseFont != null) { + baseFontId = FALLBACK_FONT_ID; + fallbackApplied = true; + } + } + if (baseFont == null) { + log.warn("Unable to resolve a base font for text element; skipping text content"); + return runs; + } + + Map runFontLookup = buildFontModelLookup(fontModels); + PdfJsonFont baseFontModel = resolveFontModel(runFontLookup, pageNumber, baseFontId); + boolean baseIsType3 = + baseFontModel != null + && baseFontModel.getSubtype() != null + && "type3".equalsIgnoreCase(baseFontModel.getSubtype()); + PDFont normalizedType3Font = + baseIsType3 && baseFontModel.getUid() != null + ? type3NormalizedFontCache.get(baseFontModel.getUid()) + : null; + Set baseType3Coverage = + baseIsType3 && baseFontModel != null + ? type3GlyphCoverageCache.getOrDefault( + baseFontModel.getUid(), Collections.emptySet()) + : Collections.emptySet(); + boolean hasNormalizedType3 = baseIsType3 && normalizedType3Font != null; + if (hasNormalizedType3 && log.isInfoEnabled()) { + log.info( + "[TYPE3-RUNTIME] Using normalized library font {} for Type3 resource {} on page {}", + normalizedType3Font.getName(), + baseFontModel != null ? baseFontModel.getId() : baseFontId, + pageNumber); + } + + StringBuilder buffer = new StringBuilder(); + List codeBuffer = new ArrayList<>(); + PDFont currentFont = baseFont; + String currentFontId = baseFontId; + + List elementCodes = element.getCharCodes(); + int codeIndex = 0; + boolean rawType3CodesUsed = false; + int rawType3GlyphCount = 0; + + for (int offset = 0; offset < text.length(); ) { + int codePoint = text.codePointAt(offset); + offset += Character.charCount(codePoint); + String glyph = new String(Character.toChars(codePoint)); + PDFont targetFont = baseFont; + String targetFontId = baseFontId; + Integer rawCode = null; + if (elementCodes != null && codeIndex < elementCodes.size()) { + rawCode = elementCodes.get(codeIndex); + } + codeIndex++; + + if (hasNormalizedType3) { + targetFont = normalizedType3Font; + // For normalized fonts, check if the font can actually encode the glyph + // Don't check Type3 coverage since normalized fonts have full glyph sets + if (!fallbackFontService.canEncode(normalizedType3Font, glyph)) { + // Glyph not in normalized font, will trigger fallback below + targetFont = null; + targetFontId = null; + } + } else if (baseIsType3) { + // For actual Type3 fonts without normalized replacement + boolean type3SupportsGlyph = + isGlyphCoveredByType3Font(baseType3Coverage, codePoint); + if (!type3SupportsGlyph) { + targetFont = null; + targetFontId = null; + } + } + if (targetFont == null || !fallbackFontService.canEncode(targetFont, glyph)) { + fallbackApplied = true; + // Try to match fallback font to original font family for visual consistency + String originalFontName = + baseFontModel != null ? baseFontModel.getBaseName() : null; + String fallbackId = + fallbackFontService.resolveFallbackFontId(originalFontName, codePoint); + targetFont = ensureFallbackFont(document, fontMap, fontModels, fallbackId); + targetFontId = fallbackId != null ? fallbackId : FALLBACK_FONT_ID; + if (targetFont == null || !fallbackFontService.canEncode(targetFont, glyph)) { + String mapped = fallbackFontService.mapUnsupportedGlyph(codePoint); + if (mapped != null) { + if (targetFont != null + && fallbackFontService.canEncode(targetFont, mapped)) { + glyph = mapped; + } else if (fallbackFontService.canEncode(baseFont, mapped)) { + glyph = mapped; + targetFont = baseFont; + targetFontId = baseFontId; + } + } + if (targetFont == null || !fallbackFontService.canEncode(targetFont, glyph)) { + glyph = "?"; + targetFont = + ensureFallbackFont(document, fontMap, fontModels, FALLBACK_FONT_ID); + targetFontId = FALLBACK_FONT_ID; + if (targetFont == null + || !fallbackFontService.canEncode(targetFont, glyph)) { + log.debug( + "Dropping unsupported glyph U+{} for text element", + Integer.toHexString(codePoint)); + continue; + } + } + } + // Fallback applied - tracked at page level, not logged per character + } + + boolean useRawType3Glyph = + rawCode != null + && baseIsType3 + && !hasNormalizedType3 + && targetFont == baseFont + && targetFont instanceof PDType3Font; + + if (targetFont != currentFont) { + if (buffer.length() > 0) { + runs.add( + new FontRun( + currentFont, + currentFontId, + buffer.toString(), + codeBuffer.isEmpty() ? null : new ArrayList<>(codeBuffer))); + buffer.setLength(0); + codeBuffer.clear(); + } + currentFont = targetFont; + currentFontId = targetFontId; + } + buffer.append(glyph); + if (useRawType3Glyph + && currentFontId != null + && currentFontId.equals(element.getFontId())) { + codeBuffer.add(rawCode); + rawType3CodesUsed = true; + rawType3GlyphCount++; + } + } + + if (buffer.length() > 0) { + runs.add( + new FontRun( + currentFont, + currentFontId, + buffer.toString(), + codeBuffer.isEmpty() ? null : new ArrayList<>(codeBuffer))); + } + + if (fallbackApplied) { + element.setFallbackUsed(Boolean.TRUE); + } + + if (rawType3CodesUsed) { + log.info( + "[TYPE3-RUNTIME] Reused original Type3 charCodes for font {} on page {} ({} glyphs)", + baseFontModel != null ? baseFontModel.getId() : baseFontId, + pageNumber, + rawType3GlyphCount); + } + + return runs; + } + + private Integer extractUnitsPerEm(PDFont font) { + if (font == null) { + return null; + } + Matrix matrix = font.getFontMatrix(); + if (matrix != null) { + float scaleX = matrix.getScaleX(); + if (scaleX != 0f) { + int units = Math.round(Math.abs(1f / scaleX)); + if (units > 0 && units < 10_000) { + return units; + } + } + } + return 1000; + } + + private void closeQuietly(TempFile tempFile) { + if (tempFile == null) { + return; + } + try { + tempFile.close(); + } catch (Exception ex) { + log.debug("Failed to close temporary file: {}", ex.getMessage()); + } + } + + private void applyTextState(PDPageContentStream contentStream, PdfJsonTextElement element) + throws IOException { + if (element.getCharacterSpacing() != null) { + contentStream.setCharacterSpacing(element.getCharacterSpacing()); + } + if (element.getWordSpacing() != null) { + contentStream.setWordSpacing(element.getWordSpacing()); + } + if (element.getHorizontalScaling() != null) { + contentStream.setHorizontalScaling(element.getHorizontalScaling()); + } + if (element.getLeading() != null) { + contentStream.setLeading(element.getLeading()); + } + if (element.getRise() != null) { + contentStream.setTextRise(element.getRise()); + } + applyColor(contentStream, element.getFillColor(), true); + applyColor(contentStream, element.getStrokeColor(), false); + } + + private void applyColor( + PDPageContentStream contentStream, PdfJsonTextColor color, boolean nonStroking) + throws IOException { + if (color == null || color.getComponents() == null) { + return; + } + float[] components = new float[color.getComponents().size()]; + for (int i = 0; i < components.length; i++) { + components[i] = color.getComponents().get(i); + } + String space = color.getColorSpace(); + if (space == null) { + // Infer color space from component count + PDColorSpace colorSpace; + if (components.length == 1) { + colorSpace = PDColorSpace.create(COSName.DEVICEGRAY); + } else if (components.length == 3) { + colorSpace = PDColorSpace.create(COSName.DEVICERGB); + } else if (components.length == 4) { + colorSpace = PDColorSpace.create(COSName.DEVICECMYK); + } else { + // Default to RGB if unsure + colorSpace = PDColorSpace.create(COSName.DEVICERGB); + } + PDColor pdColor = new PDColor(components, colorSpace); + if (nonStroking) { + contentStream.setNonStrokingColor(pdColor); + } else { + contentStream.setStrokingColor(pdColor); + } + return; + } + switch (space) { + case "DeviceRGB": + if (components.length >= 3) { + if (nonStroking) { + contentStream.setNonStrokingColor( + components[0], components[1], components[2]); + } else { + contentStream.setStrokingColor(components[0], components[1], components[2]); + } + } + break; + case "DeviceCMYK": + if (components.length >= 4) { + if (nonStroking) { + contentStream.setNonStrokingColor( + components[0], components[1], components[2], components[3]); + } else { + contentStream.setStrokingColor( + components[0], components[1], components[2], components[3]); + } + } + break; + case "DeviceGray": + if (components.length >= 1) { + if (nonStroking) { + contentStream.setNonStrokingColor(components[0]); + } else { + contentStream.setStrokingColor(components[0]); + } + } + break; + default: + log.debug("[ColorApply] Skipping unsupported color space {}", space); + } + } + + private String abbreviate(String value) { + if (value == null) { + return ""; + } + String trimmed = value.replaceAll("\s+", " ").trim(); + if (trimmed.length() <= 32) { + return trimmed; + } + return trimmed.substring(0, 29) + "..."; + } + + private static class FontProgramData { + private final String base64; + private final String format; + private final String webBase64; + private final String webFormat; + private final String pdfBase64; + private final String pdfFormat; + + private FontProgramData( + String base64, + String format, + String webBase64, + String webFormat, + String pdfBase64, + String pdfFormat) { + this.base64 = base64; + this.format = format; + this.webBase64 = webBase64; + this.webFormat = webFormat; + this.pdfBase64 = pdfBase64; + this.pdfFormat = pdfFormat; + } + + private String getBase64() { + return base64; + } + + private String getFormat() { + return format; + } + + private String getWebBase64() { + return webBase64; + } + + private String getWebFormat() { + return webFormat; + } + + private String getPdfBase64() { + return pdfBase64; + } + + private String getPdfFormat() { + return pdfFormat; + } + } + + private static final class PreflightResult { + private static final PreflightResult EMPTY = new PreflightResult(false, Set.of()); + + private final boolean usesFallback; + private final Set fallbackFontIds; + + private PreflightResult(boolean usesFallback, Set fallbackFontIds) { + this.usesFallback = usesFallback; + this.fallbackFontIds = fallbackFontIds != null ? Set.copyOf(fallbackFontIds) : Set.of(); + } + + private static PreflightResult empty() { + return EMPTY; + } + + private boolean usesFallback() { + return usesFallback; + } + + private Set fallbackFontIds() { + return fallbackFontIds; + } + } + + private static final class FontRun { + private final PDFont font; + private final String fontId; + private final String text; + private final List charCodes; + + private FontRun(PDFont font, String fontId, String text, List charCodes) { + this.font = font; + this.fontId = fontId; + this.text = text; + this.charCodes = charCodes; + } + + private PDFont font() { + return font; + } + + private String fontId() { + return fontId; + } + + private String text() { + return text; + } + + private List charCodes() { + return charCodes; + } + } + + private boolean rewriteTextOperators( + PDDocument document, + PDPage page, + List elements, + boolean removeOnly, + boolean forceRegenerate, + Map fontLookup, + int pageNumber) { + if (forceRegenerate) { + log.debug("forceRegenerate flag set; skipping token rewrite for page"); + return false; + } + if (elements == null || elements.isEmpty()) { + return true; + } + PDResources resources = page.getResources(); + if (resources == null) { + return false; + } + try { + log.debug("Attempting token-level rewrite for page"); + PDFStreamParser parser = new PDFStreamParser(page); + List tokens = parser.parse(); + log.debug("Parsed {} tokens for rewrite", tokens.size()); + TextElementCursor cursor = new TextElementCursor(elements); + PDFont currentFont = null; + String currentFontName = null; + PdfJsonFont currentFontModel = null; + + boolean encounteredModifiedFont = false; + + for (int i = 0; i < tokens.size(); i++) { + Object token = tokens.get(i); + if (!(token instanceof Operator operator)) { + continue; + } + String operatorName = operator.getName(); + switch (operatorName) { + case "Tf": + if (i >= 2 && tokens.get(i - 2) instanceof COSName fontResourceName) { + currentFont = resources.getFont(fontResourceName); + currentFontName = fontResourceName.getName(); + currentFontModel = + resolveFontModel(fontLookup, pageNumber, currentFontName); + log.trace( + "Encountered Tf operator; switching to font resource {}", + currentFontName); + if (forceRegenerate) { + encounteredModifiedFont = true; + } + } else { + currentFont = null; + currentFontName = null; + currentFontModel = null; + log.debug( + "Tf operator missing resource operand; clearing current font"); + } + break; + case "Tj": + if (i == 0 || !(tokens.get(i - 1) instanceof COSString cosString)) { + log.debug( + "Encountered Tj without preceding string operand; aborting rewrite"); + return false; + } + log.trace( + "Rewriting Tj operator using font {} (token index {}, cursor remaining {})", + currentFontName, + i, + cursor.remaining()); + if (!rewriteShowText( + cosString, + currentFont, + currentFontModel, + currentFontName, + cursor, + removeOnly)) { + log.debug("Failed to rewrite Tj operator; aborting rewrite"); + return false; + } + break; + case "TJ": + if (i == 0 || !(tokens.get(i - 1) instanceof COSArray array)) { + log.debug("Encountered TJ without array operand; aborting rewrite"); + return false; + } + log.trace( + "Rewriting TJ operator using font {} (token index {}, cursor remaining {})", + currentFontName, + i, + cursor.remaining()); + if (!rewriteShowTextArray( + array, + currentFont, + currentFontModel, + currentFontName, + cursor, + removeOnly)) { + log.debug("Failed to rewrite TJ operator; aborting rewrite"); + return false; + } + break; + default: + break; + } + } + + if (cursor.hasRemaining()) { + log.debug("Rewrite cursor still has {} elements; falling back", cursor.remaining()); + return false; + } + + if (forceRegenerate && encounteredModifiedFont) { + log.debug( + "Rewrite succeeded but forceRegenerate=true, returning false to trigger rebuild"); + return false; + } + + PDStream newStream = new PDStream(document); + try (OutputStream outputStream = newStream.createOutputStream(COSName.FLATE_DECODE)) { + new ContentStreamWriter(outputStream).writeTokens(tokens); + } + page.setContents(newStream); + log.debug("Token rewrite completed successfully"); + return true; + } catch (IOException ex) { + log.debug("Failed to rewrite content stream: {}", ex.getMessage()); + return false; + } + } + + private boolean rewriteShowText( + COSString cosString, + PDFont font, + PdfJsonFont fontModel, + String expectedFontName, + TextElementCursor cursor, + boolean removeOnly) + throws IOException { + if (font == null) { + log.debug( + "rewriteShowText aborted: no active font for expected resource {}", + expectedFontName); + return false; + } + int glyphCount = countGlyphs(cosString, font); + log.trace( + "rewriteShowText consuming {} glyphs at cursor index {} for font {}", + glyphCount, + cursor.index, + expectedFontName); + List consumed = cursor.consume(expectedFontName, glyphCount); + if (consumed == null) { + log.debug( + "Failed to consume {} glyphs for font {} (cursor remaining {})", + glyphCount, + expectedFontName, + cursor.remaining()); + return false; + } + if (removeOnly) { + cosString.setValue(new byte[0]); + return true; + } + MergedText replacement = mergeText(consumed); + try { + byte[] encoded = + encodeTextWithFont( + font, fontModel, replacement.text(), replacement.charCodes()); + if (encoded == null) { + log.debug( + "Failed to map replacement text to glyphs for font {} (text='{}')", + expectedFontName, + replacement.text()); + return false; + } + cosString.setValue(encoded); + return true; + } catch (IOException | IllegalArgumentException | UnsupportedOperationException ex) { + log.debug( + "Failed to encode replacement text with font {}: {}", + expectedFontName, + ex.getMessage()); + return false; + } + } + + private boolean rewriteShowTextArray( + COSArray array, + PDFont font, + PdfJsonFont fontModel, + String expectedFontName, + TextElementCursor cursor, + boolean removeOnly) + throws IOException { + if (font == null) { + log.debug( + "rewriteShowTextArray aborted: no active font for expected resource {}", + expectedFontName); + return false; + } + for (int i = 0; i < array.size(); i++) { + COSBase element = array.get(i); + if (element instanceof COSString cosString) { + int glyphCount = countGlyphs(cosString, font); + List consumed = cursor.consume(expectedFontName, glyphCount); + if (consumed == null) { + log.debug( + "Failed to consume {} glyphs for font {} in TJ segment {} (cursor remaining {})", + glyphCount, + expectedFontName, + i, + cursor.remaining()); + return false; + } + if (removeOnly) { + array.set(i, new COSString(new byte[0])); + continue; + } + MergedText replacement = mergeText(consumed); + try { + byte[] encoded = + encodeTextWithFont( + font, fontModel, replacement.text(), replacement.charCodes()); + if (encoded == null) { + log.debug( + "Failed to map replacement text in TJ array for font {} segment {}", + expectedFontName, + i); + return false; + } + array.set(i, new COSString(encoded)); + } catch (IOException + | IllegalArgumentException + | UnsupportedOperationException ex) { + log.debug( + "Failed to encode replacement text in TJ array for font {} segment {}: {}", + expectedFontName, + i, + ex.getMessage()); + return false; + } + } + } + return true; + } + + private byte[] encodeTextWithFont( + PDFont font, PdfJsonFont fontModel, String text, List rawCharCodes) + throws IOException { + boolean isType3Font = font instanceof PDType3Font; + boolean hasType3Metadata = + fontModel != null + && fontModel.getType3Glyphs() != null + && !fontModel.getType3Glyphs().isEmpty(); + + // For normalized Type3 fonts (font is NOT Type3 but has Type3 metadata) + if (!isType3Font && hasType3Metadata) { + // If loaded as full font (not subset), use standard Unicode encoding + // Try standard encoding first - this works when the font has all glyphs + try { + byte[] encoded = font.encode(text); + // NOTE: Do NOT sanitize encoded bytes for normalized Type3 fonts + // Multi-byte encodings (UTF-16BE, CID fonts) have null bytes that are essential + // Removing them corrupts the byte boundaries and produces garbled text + log.info( + "[TYPE3] Encoded text '{}' for normalized font {}: encoded={} bytes", + text.length() > 20 ? text.substring(0, 20) + "..." : text, + fontModel.getId(), + encoded != null ? encoded.length : 0); + if (encoded != null && encoded.length > 0) { + log.info( + "[TYPE3] Successfully encoded text for normalized Type3 font {} using standard encoding", + fontModel.getId()); + return encoded; + } + log.info( + "[TYPE3] Standard encoding produced empty result for normalized Type3 font {}, falling through to Type3 mapping", + fontModel.getId()); + } catch (IOException | IllegalArgumentException ex) { + log.info( + "[TYPE3] Standard encoding failed for normalized Type3 font {}: {}", + fontModel.getId(), + ex.getMessage()); + } + // If standard encoding failed, fall through to Type3 glyph mapping (for subset fonts) + // or return null to trigger fallback font + } else if (!isType3Font || fontModel == null) { + // For non-Type3 fonts without Type3 metadata, use standard encoding + try { + byte[] encoded = font.encode(text); + return sanitizeEncoded(encoded); + } catch (IllegalArgumentException ex) { + log.debug( + "[FONT-DEBUG] Font {} cannot encode text '{}': {}", + font.getName(), + text, + ex.getMessage()); + // Return null to trigger fallback font mechanism + return null; + } + } + + // Type3 glyph mapping logic (for actual Type3 fonts AND normalized Type3 fonts) + List glyphs = fontModel.getType3Glyphs(); + if (glyphs == null || glyphs.isEmpty()) { + return null; + } + + // For normalized Type3 fonts, DO NOT use rawCharCodes because: + // 1. They may be stale if text was edited + // 2. The subset font only has glyphs from the original PDF + // Instead, try Type3 glyph mapping and return null if glyphs are missing + // (null will trigger fallback font usage in the calling code) + + // Build Unicode to character code mapping from Type3 glyphs + Map unicodeToCode = new HashMap<>(); + for (PdfJsonFontType3Glyph glyph : glyphs) { + if (glyph == null) { + continue; + } + Integer unicode = glyph.getUnicode(); + Integer charCode = glyph.getCharCode(); + if (unicode == null || charCode == null) { + continue; + } + unicodeToCode.putIfAbsent(unicode, charCode); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + boolean mappedAll = true; + for (int offset = 0; offset < text.length(); ) { + int codePoint = text.codePointAt(offset); + offset += Character.charCount(codePoint); + Integer charCode = unicodeToCode.get(codePoint); + if (charCode == null) { + log.debug( + "[TYPE3] Missing glyph mapping for code point U+{} in font {}", + Integer.toHexString(codePoint).toUpperCase(Locale.ROOT), + fontModel.getId()); + mappedAll = false; + break; + } + if (charCode < 0 || charCode > 0xFF) { + log.debug( + "[TYPE3] Unsupported Type3 charCode {} for font {} (only 1-byte codes supported)", + charCode, + fontModel.getId()); + mappedAll = false; + break; + } + baos.write(charCode); + } + if (mappedAll) { + return sanitizeEncoded(baos.toByteArray()); + } + // Fallback to rawCharCodes for actual Type3 fonts if mapping failed + if (rawCharCodes != null && !rawCharCodes.isEmpty()) { + boolean valid = true; + ByteArrayOutputStream fallbackBytes = new ByteArrayOutputStream(rawCharCodes.size()); + for (Integer code : rawCharCodes) { + if (code == null || code < 0 || code > 0xFF) { + valid = false; + break; + } + fallbackBytes.write(code); + } + if (valid) { + return fallbackBytes.toByteArray(); + } + } + return null; + } + + private byte[] encodeType3CharCodes(List charCodes) { + if (charCodes == null || charCodes.isEmpty()) { + return null; + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(charCodes.size()); + for (Integer code : charCodes) { + if (code == null || code < 0 || code > 0xFF) { + return null; + } + baos.write(code); + } + return baos.toByteArray(); + } + + private byte[] sanitizeEncoded(byte[] encoded) { + if (encoded == null || encoded.length == 0) { + return new byte[0]; + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(encoded.length); + for (byte b : encoded) { + if (isStrippedControlByte(b)) { + continue; + } + baos.write(b); + } + byte[] sanitized = baos.toByteArray(); + if (sanitized.length == 0) { + return sanitized; + } + return sanitized; + } + + private boolean isStrippedControlByte(byte value) { + if (value == 0) { + return true; + } + int unsigned = Byte.toUnsignedInt(value); + if (unsigned <= 0x1F) { + return !(unsigned == 0x09 || unsigned == 0x0A || unsigned == 0x0D); + } + return false; + } + + private int countGlyphs(COSString value, PDFont font) { + if (value == null) { + return 0; + } + if (font != null) { + try (InputStream inputStream = new ByteArrayInputStream(value.getBytes())) { + int count = 0; + int code; + while ((code = font.readCode(inputStream)) != -1) { + count++; + } + if (count > 0) { + return count; + } + } catch (IOException ex) { + log.debug("Failed to decode glyphs: {}", ex.getMessage()); + } + } + byte[] bytes = value.getBytes(); + return Math.max(1, bytes.length); + } + + private MergedText mergeText(List elements) { + StringBuilder builder = new StringBuilder(); + List combinedCodes = new ArrayList<>(); + for (PdfJsonTextElement element : elements) { + builder.append(Objects.toString(element.getText(), "")); + if (element.getCharCodes() != null && !element.getCharCodes().isEmpty()) { + combinedCodes.addAll(element.getCharCodes()); + } + } + return new MergedText(builder.toString(), combinedCodes.isEmpty() ? null : combinedCodes); + } + + private record MergedText(String text, List charCodes) {} + + private static class TextElementCursor { + private final List elements; + private int index = 0; + + TextElementCursor(List elements) { + this.elements = elements; + } + + boolean hasRemaining() { + return index < elements.size(); + } + + int remaining() { + return Math.max(0, elements.size() - index); + } + + List consume(String expectedFontName, int glyphCount) { + if (glyphCount <= 0) { + return Collections.emptyList(); + } + List consumed = new ArrayList<>(); + int remaining = glyphCount; + while (remaining > 0 && index < elements.size()) { + PdfJsonTextElement element = elements.get(index); + if (!fontMatches(expectedFontName, element.getFontId())) { + log.debug( + "Cursor consume failed: font mismatch (expected={}, actual={}) at element {}", + expectedFontName, + element.getFontId(), + index); + return null; + } + consumed.add(element); + remaining -= countGlyphs(element); + index++; + } + if (remaining > 0) { + log.debug( + "Cursor consume failed: ran out of elements (remaining={}, currentIndex={}, total={})", + remaining, + index, + elements.size()); + return null; + } + return consumed; + } + + private boolean fontMatches(String expected, String actual) { + if (expected == null || expected.isEmpty()) { + return true; + } + if (actual == null) { + return false; + } + return Objects.equals(expected, actual); + } + + private int countGlyphs(PdfJsonTextElement element) { + List codes = element.getCharCodes(); + if (codes != null && !codes.isEmpty()) { + return codes.size(); + } + String text = element.getText(); + if (text != null && !text.isEmpty()) { + return Math.max(1, text.codePointCount(0, text.length())); + } + return 1; + } + } + + private Map buildFontMap( + PDDocument document, List fonts, String jobId) throws IOException { + Map fontMap = new HashMap<>(); + if (fonts != null) { + for (PdfJsonFont fontModel : fonts) { + if (FALLBACK_FONT_ID.equals(fontModel.getId())) { + continue; + } + PDFont loadedFont = createFontFromModel(document, fontModel, jobId); + if (loadedFont != null && fontModel.getId() != null) { + // Use null jobId for map keys - JSON->PDF doesn't need job-scoped lookups + // The jobId is only used internally for Type3 cache isolation + fontMap.put( + buildFontKey(null, fontModel.getPageNumber(), fontModel.getId()), + loadedFont); + } + } + } + + boolean fallbackPresent = + fonts != null && fonts.stream().anyMatch(f -> FALLBACK_FONT_ID.equals(f.getId())); + if (!fallbackPresent) { + PdfJsonFont fallbackModel = fallbackFontService.buildFallbackFontModel(); + if (fonts != null) { + fonts.add(fallbackModel); + log.debug("Added fallback font definition to JSON font list"); + } + PDFont fallbackFont = createFontFromModel(document, fallbackModel, jobId); + fontMap.put(buildFontKey(null, -1, FALLBACK_FONT_ID), fallbackFont); + } else if (!fontMap.containsKey(buildFontKey(null, -1, FALLBACK_FONT_ID))) { + PdfJsonFont fallbackModel = + fonts.stream() + .filter(f -> FALLBACK_FONT_ID.equals(f.getId())) + .findFirst() + .orElse(null); + if (fallbackModel == null) { + fallbackModel = fallbackFontService.buildFallbackFontModel(); + fonts.add(fallbackModel); + } + PDFont fallbackFont = createFontFromModel(document, fallbackModel, jobId); + fontMap.put(buildFontKey(null, -1, FALLBACK_FONT_ID), fallbackFont); + } + + return fontMap; + } + + private PDFont createFontFromModel(PDDocument document, PdfJsonFont fontModel, String jobId) + throws IOException { + if (fontModel == null || fontModel.getId() == null) { + return null; + } + + if (FALLBACK_FONT_ID.equals(fontModel.getId())) { + return fallbackFontService.loadFallbackPdfFont(document); + } + + log.debug( + "[FONT-LOAD] Loading font {} (subtype={}, hasCosDictionary={}, hasProgram={}, hasPdfProgram={}, hasWebProgram={})", + fontModel.getId(), + fontModel.getSubtype(), + fontModel.getCosDictionary() != null, + fontModel.getProgram() != null && !fontModel.getProgram().isBlank(), + fontModel.getPdfProgram() != null && !fontModel.getPdfProgram().isBlank(), + fontModel.getWebProgram() != null && !fontModel.getWebProgram().isBlank()); + + String originalFormat = + fontModel.getProgramFormat() != null + ? fontModel.getProgramFormat().toLowerCase(Locale.ROOT) + : null; + + String program = fontModel.getProgram(); + String webProgram = fontModel.getWebProgram(); + String pdfProgram = fontModel.getPdfProgram(); + String webFormat = + fontModel.getWebProgramFormat() != null + ? fontModel.getWebProgramFormat().toLowerCase(Locale.ROOT) + : null; + String pdfFormat = + fontModel.getPdfProgramFormat() != null + ? fontModel.getPdfProgramFormat().toLowerCase(Locale.ROOT) + : null; + + List baseCandidates = new ArrayList<>(); + List deferredWebCandidates = new ArrayList<>(); + + boolean hasPdfProgram = pdfProgram != null && !pdfProgram.isBlank(); + boolean hasWebProgram = webProgram != null && !webProgram.isBlank(); + + if (hasPdfProgram) { + try { + byte[] bytes = Base64.getDecoder().decode(pdfProgram); + if (bytes.length > 0) { + baseCandidates.add(new FontByteSource(bytes, pdfFormat, "pdfProgram")); + } + } catch (IllegalArgumentException ex) { + log.warn( + "Failed to decode pdfProgram for {}: {}", + fontModel.getId(), + ex.getMessage()); + } + } + + if (hasWebProgram) { + try { + byte[] bytes = Base64.getDecoder().decode(webProgram); + if (bytes.length > 0) { + // Prefer the converted blob when the original program is CFF/Type1C, because + // PDFBox expects TrueType/OpenType data during reconstruction. + boolean preferWeb = + originalFormat == null + || isCffFormat(originalFormat) + || "cidfonttype0c".equals(originalFormat); + FontByteSource source = new FontByteSource(bytes, webFormat, "webProgram"); + if (preferWeb) { + baseCandidates.add(source); + } else { + // Keep the converted blob as a secondary option in case loading the + // original program fails: some PDFs mix Type1 metadata with actual CFF + // payloads that PDFBox cannot parse. + deferredWebCandidates.add(source); + } + } + } catch (IllegalArgumentException ex) { + log.warn( + "Failed to decode webProgram for {}: {}", + fontModel.getId(), + ex.getMessage()); + } + } + + if (program != null && !program.isBlank()) { + try { + byte[] bytes = Base64.getDecoder().decode(program); + if (bytes.length > 0) { + // Original bytes should still be attempted. When we already preferred the + // converted blob, these will be appended as fallback. + baseCandidates.add(new FontByteSource(bytes, originalFormat, "program")); + } + } catch (IllegalArgumentException ex) { + log.warn( + "Failed to decode font program for {}: {}", + fontModel.getId(), + ex.getMessage()); + } + } + + // If no candidates were added (e.g. both payloads missing/invalid) attempt to fall back to + // the converted program when it exists but we skipped it earlier. + if (baseCandidates.isEmpty() && hasWebProgram) { + try { + byte[] bytes = Base64.getDecoder().decode(webProgram); + if (bytes.length > 0) { + baseCandidates.add(new FontByteSource(bytes, webFormat, "webProgram")); + } + } catch (IllegalArgumentException ignored) { + // Already logged above when decoding failed the first time. + } + } + + baseCandidates.addAll(deferredWebCandidates); + + List conversionCandidates = + collectConversionCandidateSources(fontModel.getConversionCandidates()); + + List orderedCandidates = new ArrayList<>(); + if (!conversionCandidates.isEmpty()) { + orderedCandidates.addAll(conversionCandidates); + } + orderedCandidates.addAll(baseCandidates); + + boolean isType3Font = + fontModel.getSubtype() != null && "type3".equalsIgnoreCase(fontModel.getSubtype()); + if (isType3Font) { + // Generate new UID with current jobId to prevent cache collisions across conversions + String type3CacheKey = + buildFontKey(jobId, fontModel.getPageNumber(), fontModel.getId()); + + // Update fontModel UID so runtime lookups use the same key + fontModel.setUid(type3CacheKey); + + cacheType3NormalizedFont( + document, fontModel, orderedCandidates, originalFormat, type3CacheKey); + PDFont cachedNormalized = type3NormalizedFontCache.get(type3CacheKey); + if (cachedNormalized != null) { + log.debug("Using cached normalized font for Type3 {}", fontModel.getId()); + return cachedNormalized; + } + PDFont restored = restoreFontFromDictionary(document, fontModel); + if (restored != null) { + return restored; + } + // Fall through to Standard14 fallback below if nothing else succeeded. + } else { + // For TrueType and Type0 fonts, prioritize cosDictionary restoration + // These fonts often use ToUnicode CMap which is preserved in the dictionary + String subtype = fontModel.getSubtype(); + boolean preferDictionary = + subtype != null + && (subtype.equalsIgnoreCase("TrueType") + || subtype.equalsIgnoreCase("Type0")); + + if (preferDictionary) { + PDFont restored = restoreFontFromDictionary(document, fontModel); + if (restored != null) { + log.debug( + "Font {} restored from cosDictionary (preferred for subsetted {})", + fontModel.getId(), + subtype); + return restored; + } + // If dictionary restoration fails, fall back to font program bytes + log.debug( + "Font {} cosDictionary restoration failed, trying font program bytes", + fontModel.getId()); + } + + PDFont loaded = + loadFirstAvailableFont(document, fontModel, orderedCandidates, originalFormat); + if (loaded != null) { + return loaded; + } + + // Try to restore from COS dictionary if font programs failed and we haven't tried yet + if (!preferDictionary) { + PDFont restored = restoreFontFromDictionary(document, fontModel); + if (restored != null) { + return restored; + } + } + } + + for (FontByteSource source : orderedCandidates) { + byte[] fontBytes = source.bytes(); + String format = source.format(); + String originLabel = source.originLabel(); + + if (fontBytes == null || fontBytes.length == 0) { + continue; + } + + try { + PDFont font = + loadFontFromSource( + document, fontModel, source, originalFormat, false, false, false); + if (font != null) { + return font; + } + } catch (IOException ex) { + // loadFontFromSource already logged details. + } + } + + PDFont restored = restoreFontFromDictionary(document, fontModel); + if (restored != null) { + return restored; + } + + log.warn( + "Font {} has no usable program bytes (originalFormat: {}, hasWebProgram: {}, hasPdfProgram: {})", + fontModel.getId(), + originalFormat, + hasWebProgram, + hasPdfProgram); + + String standardName = fontModel.getStandard14Name(); + if (standardName != null) { + try { + Standard14Fonts.FontName fontName = Standard14Fonts.getMappedFontName(standardName); + if (fontName != null) { + PDFont font = new PDType1Font(fontName); + applyAdditionalFontMetadata(document, font, fontModel); + return font; + } + log.warn( + "Standard 14 font mapping for {} returned null, using fallback", + standardName); + } catch (IllegalArgumentException ex) { + log.warn("Unknown Standard 14 font {}, using fallback", standardName); + } + } + + // Last resort: Fuzzy match baseName against Standard14 fonts + Standard14Fonts.FontName fuzzyMatch = fuzzyMatchStandard14(fontModel.getBaseName()); + if (fuzzyMatch != null) { + log.info( + "Fuzzy-matched font {} (baseName: {}) to Standard14 font {}", + fontModel.getId(), + fontModel.getBaseName(), + fuzzyMatch.getName()); + PDFont font = new PDType1Font(fuzzyMatch); + applyAdditionalFontMetadata(document, font, fontModel); + return font; + } + + PDFont fallback = fallbackFontService.loadFallbackPdfFont(document); + applyAdditionalFontMetadata(document, fallback, fontModel); + return fallback; + } + + private void cacheType3NormalizedFont( + PDDocument document, + PdfJsonFont fontModel, + List candidates, + String originalFormat, + String cacheKey) + throws IOException { + if (cacheKey == null || candidates == null || candidates.isEmpty()) { + return; + } + if (type3NormalizedFontCache.containsKey(cacheKey)) { + return; + } + for (FontByteSource source : candidates) { + PDFont font = + loadFontFromSource( + document, fontModel, source, originalFormat, true, true, true); + if (font != null) { + type3NormalizedFontCache.put(cacheKey, font); + log.info( + "Cached normalized font {} for Type3 {} (key: {})", + source.originLabel(), + fontModel.getId(), + cacheKey); + break; + } + } + } + + private PDFont loadFirstAvailableFont( + PDDocument document, + PdfJsonFont fontModel, + List candidates, + String originalFormat) + throws IOException { + for (FontByteSource source : candidates) { + PDFont font = + loadFontFromSource( + document, fontModel, source, originalFormat, false, false, false); + if (font != null) { + return font; + } + } + return null; + } + + private PDFont loadFontFromSource( + PDDocument document, + PdfJsonFont fontModel, + FontByteSource source, + String originalFormat, + boolean suppressWarn, + boolean skipMetadataLog, + boolean skipMetadata) + throws IOException { + if (source == null) { + return null; + } + byte[] fontBytes = source.bytes(); + if (fontBytes == null || fontBytes.length == 0) { + return null; + } + String format = source.format(); + String originLabel = source.originLabel(); + try { + if (!skipMetadataLog) { + log.info( + "[FONT-DEBUG] Attempting to load font {} using payload {} (format={}, size={} bytes)", + fontModel.getId(), + originLabel, + format, + fontBytes.length); + } + if (isType1Format(format)) { + try (InputStream stream = new ByteArrayInputStream(fontBytes)) { + PDFont font = new PDType1Font(document, stream); + if (!skipMetadata) { + applyAdditionalFontMetadata(document, font, fontModel); + } + log.debug( + "Successfully loaded Type1 font {} from {} bytes (format: {}, originalFormat: {})", + fontModel.getId(), + originLabel, + format, + originalFormat); + return font; + } + } + try (InputStream stream = new ByteArrayInputStream(fontBytes)) { + // For library fonts (Type3 normalized fonts), load WITHOUT subsetting + // so all glyphs are available for editing + boolean willBeSubset = !originLabel.contains("type3-library"); + if (!willBeSubset) { + log.info( + "[TYPE3-RUNTIME] Loading library font {} WITHOUT subsetting (full glyph set) from {}", + fontModel.getId(), + originLabel); + } + PDFont font = PDType0Font.load(document, stream, willBeSubset); + if (!skipMetadata) { + applyAdditionalFontMetadata(document, font, fontModel); + } + log.debug( + "Successfully loaded Type0 font {} from {} bytes (format: {}, originalFormat: {}, subset: {})", + fontModel.getId(), + originLabel, + format, + originalFormat, + willBeSubset); + return font; + } + } catch (IOException ex) { + if (suppressWarn) { + log.debug( + "Unable to load embedded font program for {} from {} (format: {}, originalFormat: {}): {}", + fontModel.getId(), + originLabel, + format, + originalFormat, + ex.getMessage()); + } else { + log.warn( + "Unable to load embedded font program for {} from {} (format: {}, originalFormat: {}): {}", + fontModel.getId(), + originLabel, + format, + originalFormat, + ex.getMessage()); + } + return null; + } + } + + private PDFont restoreFontFromDictionary(PDDocument document, PdfJsonFont fontModel) + throws IOException { + if (fontModel.getCosDictionary() == null) { + log.debug("[FONT-RESTORE] Font {} has no cosDictionary", fontModel.getId()); + return null; + } + + // Deserialize the cosDictionary - cosMapper handles validation internally + COSBase restored; + try { + restored = cosMapper.deserializeCosValue(fontModel.getCosDictionary(), document); + } catch (Exception ex) { + log.warn( + "[FONT-RESTORE] Font {} cosDictionary deserialization failed: {}", + fontModel.getId(), + ex.getMessage()); + return null; + } + + if (!(restored instanceof COSDictionary cosDictionary)) { + log.warn( + "[FONT-RESTORE] Font {} cosDictionary deserialized to {} instead of COSDictionary", + fontModel.getId(), + restored != null ? restored.getClass().getSimpleName() : "null"); + return null; + } + + // Validate that dictionary contains required font keys + if (!cosDictionary.containsKey(org.apache.pdfbox.cos.COSName.TYPE) + || !cosDictionary.containsKey(org.apache.pdfbox.cos.COSName.SUBTYPE)) { + log.warn( + "[FONT-RESTORE] Font {} cosDictionary missing required Type or Subtype keys", + fontModel.getId()); + return null; + } + + try { + PDFont font = PDFontFactory.createFont(cosDictionary); + if (font == null) { + log.warn( + "[FONT-RESTORE] Font {} PDFontFactory returned null for valid dictionary", + fontModel.getId()); + return null; + } + + if (!font.isEmbedded()) { + log.warn( + "[FONT-RESTORE] Font {} restored from dictionary but is not embedded; rejecting to avoid system font substitution", + fontModel.getId()); + return null; + } + + applyAdditionalFontMetadata(document, font, fontModel); + log.debug( + "[FONT-RESTORE] Successfully restored embedded font {} (subtype={}) from original dictionary", + fontModel.getId(), + font.getSubType()); + return font; + + } catch (IOException ex) { + log.warn( + "[FONT-RESTORE] Failed to restore font {} from dictionary ({}): {}", + fontModel.getId(), + fontModel.getSubtype(), + ex.getMessage()); + return null; + } catch (Exception ex) { + log.error( + "[FONT-RESTORE] Unexpected error restoring font {} from dictionary: {}", + fontModel.getId(), + ex.getMessage(), + ex); + return null; + } + } + + private boolean isType1Format(String format) { + if (format == null) { + return false; + } + return "type1".equals(format) || format.endsWith("pfb"); + } + + private boolean isCffFormat(String format) { + if (format == null) { + return false; + } + String normalized = format.toLowerCase(Locale.ROOT); + return normalized.contains("type1c") + || normalized.contains("cidfonttype0c") + || "cff".equals(normalized); + } + + private void applyAdditionalFontMetadata( + PDDocument document, PDFont font, PdfJsonFont fontModel) throws IOException { + if (fontModel.getToUnicode() != null && !fontModel.getToUnicode().isBlank()) { + byte[] bytes = Base64.getDecoder().decode(fontModel.getToUnicode()); + PDStream toUnicodeStream = new PDStream(document); + try (OutputStream outputStream = toUnicodeStream.createOutputStream()) { + outputStream.write(bytes); + } + font.getCOSObject().setItem(COSName.TO_UNICODE, toUnicodeStream.getCOSObject()); + } + + PdfJsonFontCidSystemInfo cidInfo = fontModel.getCidSystemInfo(); + if (cidInfo != null) { + COSDictionary cidDictionary = new COSDictionary(); + if (cidInfo.getRegistry() != null) { + cidDictionary.setString(COSName.REGISTRY, cidInfo.getRegistry()); + } + if (cidInfo.getOrdering() != null) { + cidDictionary.setString(COSName.ORDERING, cidInfo.getOrdering()); + } + if (cidInfo.getSupplement() != null) { + cidDictionary.setInt(COSName.SUPPLEMENT, cidInfo.getSupplement()); + } + font.getCOSObject().setItem(COSName.CIDSYSTEMINFO, cidDictionary); + } + } + + private void applyTextMatrix(PDPageContentStream contentStream, PdfJsonTextElement element) + throws IOException { + List matrix = element.getTextMatrix(); + if (matrix != null && matrix.size() == 6) { + float fontScale = resolveFontMatrixSize(element); + float a = matrix.get(0); + float b = matrix.get(1); + float c = matrix.get(2); + float d = matrix.get(3); + float e = matrix.get(4); + float f = matrix.get(5); + + if (fontScale != 0f) { + a /= fontScale; + b /= fontScale; + c /= fontScale; + d /= fontScale; + } + + contentStream.setTextMatrix(new Matrix(a, b, c, d, e, f)); + return; + } + float x = safeFloat(element.getX(), 0f); + float y = safeFloat(element.getY(), 0f); + contentStream.setTextMatrix(new Matrix(1, 0, 0, 1, x, y)); + } + + private float resolveFontMatrixSize(PdfJsonTextElement element) { + Float fromElement = element.getFontMatrixSize(); + if (fromElement != null && fromElement > 0f) { + return fromElement; + } + List matrix = element.getTextMatrix(); + if (matrix != null && matrix.size() >= 4) { + float a = matrix.get(0); + float b = matrix.get(1); + float c = matrix.get(2); + float d = matrix.get(3); + float verticalScale = (float) Math.hypot(b, d); + if (verticalScale > 0f) { + return verticalScale; + } + float horizontalScale = (float) Math.hypot(a, c); + if (horizontalScale > 0f) { + return horizontalScale; + } + } + return safeFloat(element.getFontSize(), 12f); + } + + private void applyRenderingMode(PDPageContentStream contentStream, Integer renderingMode) + throws IOException { + if (renderingMode == null) { + return; + } + RenderingMode mode = toRenderingMode(renderingMode); + if (mode == null) { + log.debug("Ignoring unsupported rendering mode {}", renderingMode); + return; + } + try { + contentStream.setRenderingMode(mode); + } catch (IllegalArgumentException ex) { + log.debug("Failed to apply rendering mode {}: {}", renderingMode, ex.getMessage()); + } + } + + private float safeFloat(Float value, float defaultValue) { + if (value == null || Float.isNaN(value) || Float.isInfinite(value)) { + return defaultValue; + } + return value; + } + + private String formatCalendar(Calendar calendar) { + if (calendar == null) { + return null; + } + return calendar.toInstant().toString(); + } + + private Optional parseInstant(String value) { + try { + return Optional.of(Instant.parse(value)); + } catch (DateTimeParseException ex) { + log.warn("Failed to parse instant '{}': {}", value, ex.getMessage()); + return Optional.empty(); + } + } + + private Calendar toCalendar(Instant instant) { + Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + calendar.setTimeInMillis(instant.toEpochMilli()); + return calendar; + } + + private class ImageCollectingEngine extends PDFGraphicsStreamEngine { + + private final int pageNumber; + private final Map> imagesByPage; + private final Map imageCache; + + private COSName currentXObjectName; + private int imageCounter = 0; + + protected ImageCollectingEngine( + PDPage page, + int pageNumber, + Map> imagesByPage, + Map imageCache) + throws IOException { + super(page); + this.pageNumber = pageNumber; + this.imagesByPage = imagesByPage; + this.imageCache = imageCache; + } + + @Override + public void processPage(PDPage page) throws IOException { + super.processPage(page); + } + + @Override + public void drawImage(PDImage pdImage) throws IOException { + EncodedImage encoded = getOrEncodeImage(pdImage); + if (encoded == null) { + return; + } + Matrix ctm = getGraphicsState().getCurrentTransformationMatrix(); + Bounds bounds = computeBounds(ctm); + List matrixValues = toMatrixValues(ctm); + + PdfJsonImageElement element = + PdfJsonImageElement.builder() + .id(UUID.randomUUID().toString()) + .objectName( + currentXObjectName != null + ? currentXObjectName.getName() + : null) + .inlineImage(!(pdImage instanceof PDImageXObject)) + .nativeWidth(pdImage.getWidth()) + .nativeHeight(pdImage.getHeight()) + .x(bounds.left) + .y(bounds.bottom) + .width(bounds.width()) + .height(bounds.height()) + .left(bounds.left) + .right(bounds.right) + .top(bounds.top) + .bottom(bounds.bottom) + .transform(matrixValues) + .zOrder(-1_000_000 + imageCounter) + .imageData(encoded.base64()) + .imageFormat(encoded.format()) + .build(); + imageCounter++; + imagesByPage.computeIfAbsent(pageNumber, key -> new ArrayList<>()).add(element); + } + + @Override + public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3) + throws IOException { + // Not needed for image extraction + } + + @Override + public void clip(int windingRule) throws IOException { + // Not needed for image extraction + } + + @Override + public void moveTo(float x, float y) throws IOException { + // Not needed for image extraction + } + + @Override + public void lineTo(float x, float y) throws IOException { + // Not needed for image extraction + } + + @Override + public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3) + throws IOException { + // Not needed for image extraction + } + + @Override + public Point2D getCurrentPoint() throws IOException { + return new Point2D.Float(); + } + + @Override + public void closePath() throws IOException { + // Not needed for image extraction + } + + @Override + public void endPath() throws IOException { + // Not needed for image extraction + } + + @Override + public void shadingFill(COSName shadingName) throws IOException { + // Not needed for image extraction + } + + @Override + public void fillAndStrokePath(int windingRule) throws IOException { + // Not needed for image extraction + } + + @Override + public void fillPath(int windingRule) throws IOException { + // Not needed for image extraction + } + + @Override + public void strokePath() throws IOException { + // Not needed for image extraction + } + + @Override + protected void processOperator(Operator operator, List operands) + throws IOException { + if (OperatorName.DRAW_OBJECT.equals(operator.getName()) + && !operands.isEmpty() + && operands.get(0) instanceof COSName name) { + currentXObjectName = name; + } + super.processOperator(operator, operands); + currentXObjectName = null; + } + + private EncodedImage getOrEncodeImage(PDImage pdImage) { + if (pdImage == null) { + return null; + } + + if (pdImage instanceof PDImageXObject xObject) { + if (xObject.isStencil()) { + return encodeImage(pdImage); + } + COSBase key = xObject.getCOSObject(); + EncodedImage cached = imageCache.get(key); + if (cached != null) { + return cached; + } + EncodedImage encoded = encodeImage(pdImage); + if (encoded != null) { + imageCache.put(key, encoded); + } + return encoded; + } + + return encodeImage(pdImage); + } + + private Bounds computeBounds(Matrix ctm) { + AffineTransform transform = ctm.createAffineTransform(); + Point2D.Float p0 = new Point2D.Float(0, 0); + Point2D.Float p1 = new Point2D.Float(1, 0); + Point2D.Float p2 = new Point2D.Float(0, 1); + Point2D.Float p3 = new Point2D.Float(1, 1); + transform.transform(p0, p0); + transform.transform(p1, p1); + transform.transform(p2, p2); + transform.transform(p3, p3); + + float minX = Math.min(Math.min(p0.x, p1.x), Math.min(p2.x, p3.x)); + float maxX = Math.max(Math.max(p0.x, p1.x), Math.max(p2.x, p3.x)); + float minY = Math.min(Math.min(p0.y, p1.y), Math.min(p2.y, p3.y)); + float maxY = Math.max(Math.max(p0.y, p1.y), Math.max(p2.y, p3.y)); + + if (!Float.isFinite(minX) || !Float.isFinite(minY)) { + return new Bounds(0f, 0f, 0f, 0f); + } + return new Bounds(minX, maxX, minY, maxY); + } + } + + private record Bounds(float left, float right, float bottom, float top) { + float width() { + return Math.max(0f, right - left); + } + + float height() { + return Math.max(0f, top - bottom); + } + } + + private enum DrawableType { + TEXT, + IMAGE + } + + private record DrawableElement( + DrawableType type, + PdfJsonTextElement textElement, + PdfJsonImageElement imageElement, + int zOrder, + int sequence) {} + + private record EncodedImage(String base64, String format) {} + + private List toMatrixValues(Matrix matrix) { + List values = new ArrayList<>(6); + values.add(matrix.getValue(0, 0)); + values.add(matrix.getValue(0, 1)); + values.add(matrix.getValue(1, 0)); + values.add(matrix.getValue(1, 1)); + values.add(matrix.getValue(2, 0)); + values.add(matrix.getValue(2, 1)); + return values; + } + + private EncodedImage encodeImage(PDImage image) { + try { + BufferedImage bufferedImage = image.getImage(); + if (bufferedImage == null) { + return null; + } + String format = resolveImageFormat(image); + if (format == null || format.isBlank()) { + format = "png"; + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + boolean written = ImageIO.write(bufferedImage, format, baos); + if (!written) { + if (!"png".equalsIgnoreCase(format)) { + baos.reset(); + if (!ImageIO.write(bufferedImage, "png", baos)) { + return null; + } + format = "png"; + } else { + return null; + } + } + return new EncodedImage(Base64.getEncoder().encodeToString(baos.toByteArray()), format); + } catch (IOException ex) { + log.debug("Failed to encode image: {}", ex.getMessage()); + return null; + } + } + + private String resolveImageFormat(PDImage image) { + if (image instanceof PDImageXObject xObject) { + String suffix = xObject.getSuffix(); + if (suffix != null && !suffix.isBlank()) { + return suffix.toLowerCase(Locale.ROOT); + } + } + return "png"; + } + + private List mergeDrawables( + List textElements, List imageElements) { + List drawables = new ArrayList<>(); + int sequence = 0; + + if (imageElements != null) { + int imageIndex = 0; + for (PdfJsonImageElement imageElement : imageElements) { + if (imageElement == null) { + continue; + } + int order = + imageElement.getZOrder() != null + ? imageElement.getZOrder() + : Integer.MIN_VALUE / 2 + imageIndex; + drawables.add( + new DrawableElement( + DrawableType.IMAGE, null, imageElement, order, sequence++)); + imageIndex++; + } + } + + if (textElements != null) { + int textIndex = 0; + for (PdfJsonTextElement textElement : textElements) { + if (textElement == null) { + continue; + } + int order = + textElement.getZOrder() != null + ? textElement.getZOrder() + : 1_000_000 + textIndex; + drawables.add( + new DrawableElement( + DrawableType.TEXT, textElement, null, order, sequence++)); + textIndex++; + } + } + + drawables.sort( + Comparator.comparingInt(DrawableElement::zOrder) + .thenComparingInt(DrawableElement::sequence)); + return drawables; + } + + private void drawImageElement( + PDPageContentStream contentStream, + PDDocument document, + PdfJsonImageElement element, + Map cache) + throws IOException { + if (element == null || element.getImageData() == null || element.getImageData().isBlank()) { + return; + } + + String cacheKey = + element.getId() != null && !element.getId().isBlank() + ? element.getId() + : Integer.toHexString(System.identityHashCode(element)); + PDImageXObject image = cache.get(cacheKey); + if (image == null) { + image = createImageXObject(document, element); + if (image == null) { + return; + } + cache.put(cacheKey, image); + } + + List transform = element.getTransform(); + if (transform != null && transform.size() == 6) { + Matrix matrix = + new Matrix( + safeFloat(transform.get(0), 1f), + safeFloat(transform.get(1), 0f), + safeFloat(transform.get(2), 0f), + safeFloat(transform.get(3), 1f), + safeFloat(transform.get(4), 0f), + safeFloat(transform.get(5), 0f)); + contentStream.drawImage(image, matrix); + return; + } + + float width = safeFloat(element.getWidth(), fallbackWidth(element)); + float height = safeFloat(element.getHeight(), fallbackHeight(element)); + if (width <= 0f) { + width = Math.max(1f, fallbackWidth(element)); + } + if (height <= 0f) { + height = Math.max(1f, fallbackHeight(element)); + } + float left = resolveLeft(element, width); + float bottom = resolveBottom(element, height); + + contentStream.drawImage(image, left, bottom, width, height); + } + + private PDImageXObject createImageXObject(PDDocument document, PdfJsonImageElement element) + throws IOException { + byte[] data; + try { + data = Base64.getDecoder().decode(element.getImageData()); + } catch (IllegalArgumentException ex) { + log.debug("Failed to decode image element: {}", ex.getMessage()); + return null; + } + String name = element.getId() != null ? element.getId() : UUID.randomUUID().toString(); + return PDImageXObject.createFromByteArray(document, data, name); + } + + private float fallbackWidth(PdfJsonImageElement element) { + if (element.getRight() != null && element.getLeft() != null) { + return Math.max(0f, element.getRight() - element.getLeft()); + } + if (element.getNativeWidth() != null) { + return element.getNativeWidth(); + } + return 1f; + } + + private float resolveLeft(PdfJsonImageElement element, float width) { + if (element.getLeft() != null) { + return element.getLeft(); + } + if (element.getX() != null) { + return element.getX(); + } + if (element.getRight() != null) { + return element.getRight() - width; + } + return 0f; + } + + private float resolveBottom(PdfJsonImageElement element, float height) { + if (element.getBottom() != null) { + return element.getBottom(); + } + if (element.getY() != null) { + return element.getY(); + } + if (element.getTop() != null) { + return element.getTop() - height; + } + return 0f; + } + + private float fallbackHeight(PdfJsonImageElement element) { + if (element.getTop() != null && element.getBottom() != null) { + return Math.max(0f, element.getTop() - element.getBottom()); + } + if (element.getNativeHeight() != null) { + return element.getNativeHeight(); + } + return 1f; + } + + private class TextCollectingStripper extends PDFTextStripper { + + private final PDDocument document; + private final Map fonts; + private final Map> textByPage; + private final Map> pageFontResources; + private final Map fontCache; + private final String jobId; + + private int currentPage = 1; + private Map currentFontResources = Collections.emptyMap(); + private int currentZOrderCounter; + + TextCollectingStripper( + PDDocument document, + Map fonts, + Map> textByPage, + Map> pageFontResources, + Map fontCache, + String jobId) + throws IOException { + this.document = document; + this.fonts = fonts; + this.textByPage = textByPage; + this.pageFontResources = pageFontResources; + this.fontCache = fontCache != null ? fontCache : new IdentityHashMap<>(); + this.jobId = jobId; + } + + @Override + protected void startPage(PDPage page) throws IOException { + super.startPage(page); + currentPage = getCurrentPageNo(); + currentFontResources = + pageFontResources.getOrDefault(currentPage, Collections.emptyMap()); + currentZOrderCounter = 0; + } + + @Override + protected void writeString(String text, List textPositions) + throws IOException { + if (textPositions == null || textPositions.isEmpty()) { + return; + } + List pageElements = + textByPage.computeIfAbsent(currentPage, key -> new ArrayList<>()); + + TextRunAccumulator accumulator = null; + for (TextPosition position : textPositions) { + PDFont font = position.getFont(); + String fontId = registerFont(font); + PdfJsonTextElement element = createTextElement(position, fontId, font); + + if (accumulator == null) { + accumulator = new TextRunAccumulator(element, position); + } else if (!accumulator.canAppend(element, position)) { + PdfJsonTextElement built = accumulator.build(); + built.setZOrder(1_000_000 + currentZOrderCounter++); + pageElements.add(built); + accumulator = new TextRunAccumulator(element, position); + } else { + accumulator.append(element, position); + } + } + + if (accumulator != null) { + PdfJsonTextElement built = accumulator.build(); + built.setZOrder(1_000_000 + currentZOrderCounter++); + pageElements.add(built); + } + } + + private PdfJsonTextElement createTextElement( + TextPosition position, String fontId, PDFont pdfont) throws IOException { + PdfJsonTextElement element = new PdfJsonTextElement(); + element.setText(position.getUnicode()); + element.setFontId(fontId); + element.setFontSize(position.getFontSizeInPt()); + element.setX(position.getXDirAdj()); + element.setY(position.getYDirAdj()); + element.setWidth(position.getWidthDirAdj()); + element.setHeight(position.getHeightDir()); + element.setTextMatrix(extractMatrix(position)); + element.setFontMatrixSize(computeFontMatrixSize(element.getTextMatrix())); + element.setSpaceWidth(position.getWidthOfSpace()); + if (pdfont instanceof PDType3Font) { + int[] codes = position.getCharacterCodes(); + if (codes != null && codes.length > 0) { + List codeList = new ArrayList<>(codes.length); + for (int code : codes) { + if (code >= 0) { + codeList.add(code); + } + } + if (!codeList.isEmpty()) { + element.setCharCodes(codeList); + } + } + } + + PDGraphicsState graphicsState = getGraphicsState(); + if (graphicsState != null) { + PDTextState textState = graphicsState.getTextState(); + if (textState != null) { + element.setCharacterSpacing(textState.getCharacterSpacing()); + element.setWordSpacing(textState.getWordSpacing()); + element.setHorizontalScaling(textState.getHorizontalScaling()); + element.setLeading(textState.getLeading()); + element.setRise(textState.getRise()); + if (textState.getRenderingMode() != null) { + element.setRenderingMode(textState.getRenderingMode().intValue()); + } + } + element.setFillColor(toTextColor(graphicsState.getNonStrokingColor())); + element.setStrokeColor(toTextColor(graphicsState.getStrokingColor())); + } + return element; + } + + private void compactTextElement(PdfJsonTextElement element) { + if (element == null) { + return; + } + + List matrix = element.getTextMatrix(); + if (matrix != null) { + if (matrix.isEmpty()) { + element.setTextMatrix(null); + } else if (matrix.size() == 6) { + element.setX(null); + element.setY(null); + } + } + + if (isZero(element.getCharacterSpacing())) { + element.setCharacterSpacing(null); + } + if (isZero(element.getWordSpacing())) { + element.setWordSpacing(null); + } + if (isZero(element.getLeading())) { + element.setLeading(null); + } + if (isZero(element.getRise())) { + element.setRise(null); + } + if (element.getHorizontalScaling() != null + && Math.abs(element.getHorizontalScaling() - 100f) < FLOAT_EPSILON) { + element.setHorizontalScaling(null); + } + if (element.getRenderingMode() != null && element.getRenderingMode() == 0) { + element.setRenderingMode(null); + } + if (isDefaultBlack(element.getFillColor())) { + element.setFillColor(null); + } + if (isDefaultBlack(element.getStrokeColor())) { + element.setStrokeColor(null); + } + } + + private boolean isZero(Float value) { + return value != null && Math.abs(value) < FLOAT_EPSILON; + } + + private boolean isDefaultBlack(PdfJsonTextColor color) { + if (color == null || color.getComponents() == null) { + return true; + } + List components = color.getComponents(); + if (components.isEmpty()) { + return true; + } + String space = color.getColorSpace(); + if (space == null || "DeviceRGB".equals(space)) { + if (components.size() < 3) { + return false; + } + return Math.abs(components.get(0)) < FLOAT_EPSILON + && Math.abs(components.get(1)) < FLOAT_EPSILON + && Math.abs(components.get(2)) < FLOAT_EPSILON; + } + if ("DeviceGray".equals(space)) { + return Math.abs(components.get(0)) < FLOAT_EPSILON; + } + return false; + } + + private Float baselineFrom(PdfJsonTextElement element) { + List matrix = element.getTextMatrix(); + if (matrix != null && matrix.size() >= 6) { + return matrix.get(5); + } + return element.getY(); + } + + private TextStyleKey buildStyleKey(PdfJsonTextElement element) { + return new TextStyleKey( + element.getFontId(), + element.getFontSize(), + element.getFontMatrixSize(), + element.getCharacterSpacing(), + element.getWordSpacing(), + element.getHorizontalScaling(), + element.getLeading(), + element.getRise(), + element.getFillColor(), + element.getStrokeColor(), + element.getRenderingMode(), + element.getSpaceWidth()); + } + + private class TextRunAccumulator { + private final PdfJsonTextElement baseElement; + private final TextStyleKey styleKey; + private final float orientationA; + private final float orientationB; + private final float orientationC; + private final float orientationD; + private final Float baseline; + private final List baseMatrix; + private final float startXCoord; + private final float startYCoord; + private final StringBuilder textBuilder = new StringBuilder(); + private final List charCodeBuffer = new ArrayList<>(); + private float totalWidth; + private float maxHeight; + private float endXCoord; + + TextRunAccumulator(PdfJsonTextElement element, TextPosition position) { + this.baseElement = element; + this.styleKey = buildStyleKey(element); + this.baseMatrix = + element.getTextMatrix() != null + ? new ArrayList<>(element.getTextMatrix()) + : null; + if (baseMatrix != null && baseMatrix.size() >= 6) { + orientationA = baseMatrix.get(0); + orientationB = baseMatrix.get(1); + orientationC = baseMatrix.get(2); + orientationD = baseMatrix.get(3); + startXCoord = baseMatrix.get(4); + startYCoord = baseMatrix.get(5); + } else { + orientationA = 1f; + orientationB = 0f; + orientationC = 0f; + orientationD = 1f; + startXCoord = element.getX() != null ? element.getX() : 0f; + startYCoord = element.getY() != null ? element.getY() : 0f; + } + this.baseline = baselineFrom(element); + this.totalWidth = element.getWidth() != null ? element.getWidth() : 0f; + this.maxHeight = element.getHeight() != null ? element.getHeight() : 0f; + this.endXCoord = position.getXDirAdj() + position.getWidthDirAdj(); + this.textBuilder.append(element.getText()); + if (element.getCharCodes() != null) { + charCodeBuffer.addAll(element.getCharCodes()); + } + } + + boolean canAppend(PdfJsonTextElement element, TextPosition position) { + if (!styleKey.equals(buildStyleKey(element))) { + return false; + } + List matrix = element.getTextMatrix(); + float a = 1f; + float b = 0f; + float c = 0f; + float d = 1f; + if (matrix != null && matrix.size() >= 4) { + a = matrix.get(0); + b = matrix.get(1); + c = matrix.get(2); + d = matrix.get(3); + } + if (Math.abs(a - orientationA) > ORIENTATION_TOLERANCE + || Math.abs(b - orientationB) > ORIENTATION_TOLERANCE + || Math.abs(c - orientationC) > ORIENTATION_TOLERANCE + || Math.abs(d - orientationD) > ORIENTATION_TOLERANCE) { + return false; + } + + Float otherBaseline = baselineFrom(element); + if (baseline != null && otherBaseline != null) { + if (Math.abs(otherBaseline - baseline) > BASELINE_TOLERANCE) { + return false; + } + } else if (baseline != null || otherBaseline != null) { + return false; + } + + return true; + } + + void append(PdfJsonTextElement element, TextPosition position) { + textBuilder.append(element.getText()); + float width = + element.getWidth() != null ? element.getWidth() : position.getWidthDirAdj(); + totalWidth += width; + float height = + element.getHeight() != null ? element.getHeight() : position.getHeightDir(); + if (height > maxHeight) { + maxHeight = height; + } + endXCoord = position.getXDirAdj() + position.getWidthDirAdj(); + if (element.getCharCodes() != null) { + charCodeBuffer.addAll(element.getCharCodes()); + } + } + + PdfJsonTextElement build() { + PdfJsonTextElement result = baseElement; + result.setText(textBuilder.toString()); + float widthCandidate = endXCoord - startXCoord; + if (widthCandidate > totalWidth) { + totalWidth = widthCandidate; + } + result.setWidth(totalWidth); + result.setHeight(maxHeight); + if (baseMatrix != null && baseMatrix.size() == 6) { + List matrix = new ArrayList<>(baseMatrix); + matrix.set(0, orientationA); + matrix.set(1, orientationB); + matrix.set(2, orientationC); + matrix.set(3, orientationD); + matrix.set(4, startXCoord); + matrix.set(5, startYCoord); + result.setTextMatrix(matrix); + result.setX(null); + result.setY(null); + } + if (charCodeBuffer.isEmpty()) { + result.setCharCodes(null); + } else { + result.setCharCodes(new ArrayList<>(charCodeBuffer)); + } + compactTextElement(result); + return result; + } + } + + private record TextStyleKey( + String fontId, + Float fontSize, + Float fontMatrixSize, + Float characterSpacing, + Float wordSpacing, + Float horizontalScaling, + Float leading, + Float rise, + PdfJsonTextColor fillColor, + PdfJsonTextColor strokeColor, + Integer renderingMode, + Float spaceWidth) {} + + private List extractMatrix(TextPosition position) { + float[] values = new float[6]; + values[0] = position.getTextMatrix().getValue(0, 0); + values[1] = position.getTextMatrix().getValue(0, 1); + values[2] = position.getTextMatrix().getValue(1, 0); + values[3] = position.getTextMatrix().getValue(1, 1); + values[4] = position.getTextMatrix().getValue(2, 0); + values[5] = position.getTextMatrix().getValue(2, 1); + List matrix = new ArrayList<>(6); + for (float value : values) { + matrix.add(value); + } + return matrix; + } + + private Float computeFontMatrixSize(List matrix) { + if (matrix == null || matrix.size() < 4) { + return null; + } + float a = matrix.get(0); + float b = matrix.get(1); + float c = matrix.get(2); + float d = matrix.get(3); + float scaleX = (float) Math.hypot(a, c); + float scaleY = (float) Math.hypot(b, d); + float scale = Math.max(scaleX, scaleY); + return scale > 0 ? scale : null; + } + + private String registerFont(PDFont font) throws IOException { + String fontId = currentFontResources.get(font); + if (fontId == null || fontId.isBlank()) { + fontId = font.getName(); + } + String key = buildFontKey(jobId, currentPage, fontId); + if (!fonts.containsKey(key)) { + fonts.put( + key, buildFontModel(document, font, fontId, currentPage, fontCache, jobId)); + } + return fontId; + } + + private PdfJsonTextColor toTextColor(PDColor color) { + if (color == null) { + return null; + } + PDColorSpace colorSpace = color.getColorSpace(); + if (colorSpace == null) { + log.debug("[ColorCapture] No color space for PDColor {}", color); + return null; + } + float[] components = color.getComponents(); + String colorSpaceName = colorSpace.getName(); + float[] effective = components; + try { + float[] rgb = colorSpace.toRGB(components); + if (rgb != null && rgb.length >= 3) { + effective = rgb; + colorSpaceName = COSName.DEVICERGB.getName(); + } + } catch (IOException ex) { + log.debug( + "[ColorCapture] Failed to convert color space {} to RGB: {}", + colorSpaceName, + ex.getMessage()); + } + List values = new ArrayList<>(effective.length); + for (float component : effective) { + values.add(component); + } + return PdfJsonTextColor.builder().colorSpace(colorSpaceName).components(values).build(); + } + + private String sanitizeForLog(String value) { + if (value == null) { + return "null"; + } + return value.replace("\n", "\\n").replace("\r", "\\r"); + } + + private String describeColor(PdfJsonTextColor color) { + if (color == null || color.getComponents() == null) { + return "null"; + } + return color.getColorSpace() + "=" + color.getComponents(); + } + } + + private RenderingMode toRenderingMode(Integer renderingMode) { + if (renderingMode == null) { + return null; + } + switch (renderingMode) { + case 0: + return RenderingMode.FILL; + case 1: + return RenderingMode.STROKE; + case 2: + return RenderingMode.FILL_STROKE; + case 3: + return RenderingMode.NEITHER; + case 4: + return RenderingMode.FILL_CLIP; + case 5: + return RenderingMode.STROKE_CLIP; + case 6: + return RenderingMode.FILL_STROKE_CLIP; + case 7: + return RenderingMode.NEITHER_CLIP; + default: + return null; + } + } + + /** + * Get the job ID from the current request context + * + * @return The job ID, or null if not in an async job context + */ + private String getJobIdFromRequest() { + // First check ThreadLocal (for async jobs) + String jobId = stirling.software.common.util.JobContext.getJobId(); + if (jobId != null) { + log.debug("Retrieved jobId from JobContext: {}", jobId); + return jobId; + } + + // Fallback to request attribute (for sync jobs) + try { + org.springframework.web.context.request.RequestAttributes attrs = + org.springframework.web.context.request.RequestContextHolder + .getRequestAttributes(); + if (attrs instanceof org.springframework.web.context.request.ServletRequestAttributes) { + jakarta.servlet.http.HttpServletRequest request = + ((org.springframework.web.context.request.ServletRequestAttributes) attrs) + .getRequest(); + jobId = (String) request.getAttribute("jobId"); + if (jobId != null) { + log.debug("Retrieved jobId from request attribute: {}", jobId); + return jobId; + } + } + } catch (Exception e) { + log.debug("Could not retrieve job ID from request context: {}", e.getMessage()); + } + return null; + } + + /** + * Report progress to TaskManager for async jobs + * + * @param jobId The job ID + * @param progress The progress update + */ + private void reportProgressToTaskManager(String jobId, PdfJsonConversionProgress progress) { + try { + log.debug( + "Reporting progress for job {}: {}% - {}", + jobId, progress.getPercent(), progress.getStage()); + // Add progress note to job + String note; + if (progress.getCurrent() != null && progress.getTotal() != null) { + note = + String.format( + "[%d%%] %s: %s (%d/%d)", + progress.getPercent(), + progress.getStage(), + progress.getMessage(), + progress.getCurrent(), + progress.getTotal()); + } else { + note = + String.format( + "[%d%%] %s: %s", + progress.getPercent(), progress.getStage(), progress.getMessage()); + } + boolean added = taskManager.addNote(jobId, note); + if (!added) { + log.warn("Failed to add note - job {} not found in TaskManager", jobId); + } else { + log.debug("Successfully added progress note for job {}: {}", jobId, note); + } + } catch (Exception e) { + log.error("Exception reporting progress for job {}: {}", jobId, e.getMessage(), e); + } + } + + // ======================================================================== + // Lazy Page Loading Support + // ======================================================================== + + /** + * Stores PDF bytes for lazy page loading. Each page is extracted on-demand by re-loading the + * PDF from bytes. + */ + private static class CachedPdfDocument { + private final byte[] pdfBytes; + private final PdfJsonDocumentMetadata metadata; + private final Map fonts; // Font map with UIDs for consistency + private final Map> pageFontResources; // Page font resources + private final long timestamp; + + public CachedPdfDocument( + byte[] pdfBytes, + PdfJsonDocumentMetadata metadata, + Map fonts, + Map> pageFontResources) { + this.pdfBytes = pdfBytes; + this.metadata = metadata; + // Create defensive copies to prevent mutation of shared maps + this.fonts = + fonts != null + ? new java.util.concurrent.ConcurrentHashMap<>(fonts) + : new java.util.concurrent.ConcurrentHashMap<>(); + this.pageFontResources = + pageFontResources != null + ? new java.util.concurrent.ConcurrentHashMap<>(pageFontResources) + : new java.util.concurrent.ConcurrentHashMap<>(); + this.timestamp = System.currentTimeMillis(); + } + + // Getters return defensive copies to prevent external mutation + public byte[] getPdfBytes() { + return pdfBytes; + } + + public PdfJsonDocumentMetadata getMetadata() { + return metadata; + } + + public Map getFonts() { + return new java.util.concurrent.ConcurrentHashMap<>(fonts); + } + + public Map> getPageFontResources() { + return new java.util.concurrent.ConcurrentHashMap<>(pageFontResources); + } + + public long getTimestamp() { + return timestamp; + } + + public CachedPdfDocument withUpdatedPdfBytes(byte[] nextBytes) { + return withUpdatedFonts(nextBytes, this.fonts); + } + + public CachedPdfDocument withUpdatedFonts( + byte[] nextBytes, Map nextFonts) { + Map fontsToUse = nextFonts != null ? nextFonts : this.fonts; + return new CachedPdfDocument(nextBytes, metadata, fontsToUse, pageFontResources); + } + } + + /** + * Extracts document metadata, fonts, and page dimensions without page content. Caches the PDF + * bytes for subsequent page requests. + */ + public byte[] extractDocumentMetadata(MultipartFile file, String jobId) throws IOException { + if (file == null) { + throw ExceptionUtils.createNullArgumentException("fileInput"); + } + + Consumer progress = + jobId != null + ? (p) -> { + log.debug( + "Progress: [{}%] {} - {}{}", + p.getPercent(), + p.getStage(), + p.getMessage(), + (p.getCurrent() != null && p.getTotal() != null) + ? String.format( + " (%d/%d)", p.getCurrent(), p.getTotal()) + : ""); + reportProgressToTaskManager(jobId, p); + } + : (p) -> {}; + + // Read PDF bytes once for processing and caching + byte[] pdfBytes = file.getBytes(); + + try (PDDocument document = pdfDocumentFactory.load(pdfBytes, true)) { + int totalPages = document.getNumberOfPages(); + + // Extract fonts + progress.accept( + PdfJsonConversionProgress.of(30, "fonts", "Collecting font information")); + Map fonts = new LinkedHashMap<>(); + Map> pageFontResources = new HashMap<>(); + Map fontCache = new IdentityHashMap<>(); + int pageNumber = 1; + for (PDPage page : document.getPages()) { + Map resourceMap = + collectFontsForPage(document, page, pageNumber, fonts, fontCache, jobId); + pageFontResources.put(pageNumber, resourceMap); + pageNumber++; + } + + // Build metadata response + progress.accept(PdfJsonConversionProgress.of(90, "metadata", "Extracting metadata")); + PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata(); + docMetadata.setMetadata(extractMetadata(document)); + docMetadata.setXmpMetadata(extractXmpMetadata(document)); + + List serializedFonts = new ArrayList<>(fonts.values()); + serializedFonts.sort( + Comparator.comparing( + PdfJsonFont::getUid, Comparator.nullsLast(Comparator.naturalOrder()))); + docMetadata.setFonts(serializedFonts); + + // Extract page dimensions + List pageDimensions = new ArrayList<>(); + int pageIndex = 0; + for (PDPage page : document.getPages()) { + PdfJsonPageDimension dim = new PdfJsonPageDimension(); + dim.setPageNumber(pageIndex + 1); + PDRectangle mediaBox = page.getMediaBox(); + dim.setWidth(mediaBox.getWidth()); + dim.setHeight(mediaBox.getHeight()); + dim.setRotation(page.getRotation()); + pageDimensions.add(dim); + pageIndex++; + } + docMetadata.setPageDimensions(pageDimensions); + docMetadata.setFormFields(collectFormFields(document)); + docMetadata.setLazyImages(Boolean.TRUE); + + // 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); + log.debug( + "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {}", + pdfBytes.length, + totalPages, + fonts.size(), + jobId); + + // Schedule cleanup after 30 minutes + scheduleDocumentCleanup(jobId); + } + + progress.accept( + PdfJsonConversionProgress.of(100, "complete", "Metadata extraction complete")); + + return objectMapper.writeValueAsBytes(docMetadata); + } + } + + /** 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); + if (cached == null) { + throw new IllegalArgumentException("No cached document found for jobId: " + jobId); + } + + int pageIndex = pageNumber - 1; + int totalPages = cached.getMetadata().getPageDimensions().size(); + + if (pageIndex < 0 || pageIndex >= totalPages) { + throw new IllegalArgumentException( + "Page number " + pageNumber + " out of range (1-" + totalPages + ")"); + } + + log.debug( + "Loading PDF from bytes ({} bytes) to extract page {} (jobId: {})", + cached.getPdfBytes().length, + pageNumber, + jobId); + + // Re-load PDF from cached bytes and extract the single page + try (PDDocument document = pdfDocumentFactory.load(cached.getPdfBytes(), true)) { + PDPage page = document.getPage(pageIndex); + PdfJsonPage pageModel = new PdfJsonPage(); + pageModel.setPageNumber(pageNumber); + PDRectangle mediaBox = page.getMediaBox(); + pageModel.setWidth(mediaBox.getWidth()); + pageModel.setHeight(mediaBox.getHeight()); + pageModel.setRotation(page.getRotation()); + + // Extract text on-demand using cached fonts (ensures consistent font UIDs) + // Create thread-local copies to prevent mutation of cached maps + Map threadLocalFonts = + new java.util.concurrent.ConcurrentHashMap<>(cached.getFonts()); + Map> threadLocalPageFontResources = + new java.util.concurrent.ConcurrentHashMap<>(cached.getPageFontResources()); + + Map> textByPage = new LinkedHashMap<>(); + TextCollectingStripper stripper = + new TextCollectingStripper( + document, + threadLocalFonts, + textByPage, + threadLocalPageFontResources, + new IdentityHashMap<>(), + jobId); + stripper.setStartPage(pageNumber); + stripper.setEndPage(pageNumber); + stripper.setSortByPosition(true); + stripper.getText(document); + pageModel.setTextElements(textByPage.getOrDefault(pageNumber, List.of())); + + // Extract annotations on-demand + List annotations = new ArrayList<>(); + for (PDAnnotation annotation : page.getAnnotations()) { + try { + PdfJsonAnnotation ann = new PdfJsonAnnotation(); + ann.setSubtype(annotation.getSubtype()); + ann.setContents(annotation.getContents()); + + PDRectangle rect = annotation.getRectangle(); + if (rect != null) { + ann.setRect( + List.of( + rect.getLowerLeftX(), + rect.getLowerLeftY(), + rect.getUpperRightX(), + rect.getUpperRightY())); + } + + COSName appearanceState = annotation.getAppearanceState(); + if (appearanceState != null) { + ann.setAppearanceState(appearanceState.getName()); + } + + if (annotation.getColor() != null) { + float[] colorComponents = annotation.getColor().getComponents(); + List colorList = new ArrayList<>(colorComponents.length); + for (float c : colorComponents) { + colorList.add(c); + } + ann.setColor(colorList); + } + + COSDictionary annotDict = annotation.getCOSObject(); + COSString title = (COSString) annotDict.getDictionaryObject(COSName.T); + if (title != null) { + ann.setAuthor(title.getString()); + } + + COSString subj = (COSString) annotDict.getDictionaryObject(COSName.SUBJ); + if (subj != null) { + ann.setSubject(subj.getString()); + } + + COSString creationDateStr = + (COSString) annotDict.getDictionaryObject(COSName.CREATION_DATE); + if (creationDateStr != null) { + try { + Calendar creationDate = + DateConverter.toCalendar(creationDateStr.getString()); + ann.setCreationDate(formatCalendar(creationDate)); + } catch (Exception e) { + log.debug( + "Failed to parse annotation creation date: {}", e.getMessage()); + } + } + + COSString modDateStr = (COSString) annotDict.getDictionaryObject(COSName.M); + if (modDateStr != null) { + try { + Calendar modDate = DateConverter.toCalendar(modDateStr.getString()); + ann.setModificationDate(formatCalendar(modDate)); + } catch (Exception e) { + log.debug( + "Failed to parse annotation modification date: {}", + e.getMessage()); + } + } + + ann.setRawData(cosMapper.serializeCosValue(annotDict)); + annotations.add(ann); + } catch (Exception e) { + log.warn( + "Failed to extract annotation on page {}: {}", + pageNumber, + e.getMessage()); + } + } + pageModel.setAnnotations(annotations); + + // Extract images on-demand + Map> singlePageImages = new LinkedHashMap<>(); + ImageCollectingEngine engine = + new ImageCollectingEngine( + page, pageNumber, singlePageImages, new IdentityHashMap<>()); + engine.processPage(page); + List images = singlePageImages.getOrDefault(pageNumber, List.of()); + pageModel.setImageElements(images); + + // Extract resources and content streams + COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES); + COSBase filteredResources = filterImageXObjectsFromResources(resourcesBase); + pageModel.setResources(cosMapper.serializeCosValue(filteredResources)); + pageModel.setContentStreams(extractContentStreams(page)); + + log.debug( + "Extracted page {} (text: {}, images: {}, annotations: {}) for jobId: {}", + pageNumber, + pageModel.getTextElements().size(), + images.size(), + pageModel.getAnnotations().size(), + jobId); + + return objectMapper.writeValueAsBytes(pageModel); + } + } + + public byte[] exportUpdatedPages(String jobId, PdfJsonDocument updates) throws IOException { + if (jobId == null || jobId.isBlank()) { + throw new IllegalArgumentException("jobId is required for incremental export"); + } + CachedPdfDocument cached = documentCache.get(jobId); + if (cached == null) { + throw new IllegalArgumentException("No cached document available for jobId: " + jobId); + } + if (updates == null || updates.getPages() == null || updates.getPages().isEmpty()) { + log.debug( + "Incremental export requested with no page updates; returning cached PDF for jobId {}", + jobId); + return cached.getPdfBytes(); + } + + try (PDDocument document = pdfDocumentFactory.load(cached.getPdfBytes(), true)) { + Map mergedFonts = new LinkedHashMap<>(); + if (cached.getFonts() != null) { + cached.getFonts() + .forEach( + (key, value) -> { + PdfJsonFont clone = cloneFont(value); + mergedFonts.put(key, clone != null ? clone : value); + }); + } + if (updates.getFonts() != null) { + for (PdfJsonFont font : updates.getFonts()) { + if (font == null) { + continue; + } + String cacheKey = resolveFontCacheKey(font); + if (cacheKey == null) { + continue; + } + PdfJsonFont clone = cloneFont(font); + PdfJsonFont toStore = clone != null ? clone : font; + mergedFonts.put(cacheKey, toStore); + if (toStore.getUid() != null) { + type3NormalizedFontCache.remove(toStore.getUid()); + } + } + } + + List fontModels = new ArrayList<>(mergedFonts.values()); + List fontModelsCopy = new ArrayList<>(fontModels); + // Generate synthetic jobId for this incremental update to prevent cache collisions + String updateJobId = "incremental:" + jobId + ":" + java.util.UUID.randomUUID(); + Map fontMap = buildFontMap(document, fontModelsCopy, updateJobId); + + Set updatedPages = new HashSet<>(); + for (PdfJsonPage pageModel : updates.getPages()) { + if (pageModel == null) { + continue; + } + Integer pageNumber = pageModel.getPageNumber(); + if (pageNumber == null) { + log.warn( + "Skipping incremental page update without pageNumber for jobId {}", + jobId); + continue; + } + int pageIndex = pageNumber - 1; + if (pageIndex < 0 || pageIndex >= document.getNumberOfPages()) { + log.warn( + "Skipping incremental update for out-of-range page {} (jobId {})", + pageNumber, + jobId); + continue; + } + PDPage page = document.getPage(pageIndex); + replacePageContentFromModel( + document, page, pageModel, fontMap, fontModelsCopy, pageNumber); + updatedPages.add(pageIndex); + } + + if (updatedPages.isEmpty()) { + log.debug( + "Incremental export for jobId {} resulted in no page updates; returning cached PDF", + jobId); + return cached.getPdfBytes(); + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + byte[] updatedBytes = baos.toByteArray(); + + documentCache.put(jobId, cached.withUpdatedFonts(updatedBytes, mergedFonts)); + + // Clear Type3 cache entries for this incremental update + clearType3CacheEntriesForJob(updateJobId); + + log.debug( + "Incremental export complete for jobId {} (pages updated: {})", + jobId, + updatedPages.stream().map(i -> i + 1).sorted().toList()); + return updatedBytes; + } + } + + /** Clears a cached document. */ + public void clearCachedDocument(String jobId) { + CachedPdfDocument cached = documentCache.remove(jobId); + if (cached != null) { + log.debug( + "Removed cached PDF bytes ({} bytes) for jobId: {}", + cached.getPdfBytes().length, + jobId); + } + + // Clear Type3 caches for this job + clearType3CacheEntriesForJob(jobId); + } + + /** + * Clear job-specific entries from Type3 font caches. Font UIDs include jobId prefix, so we can + * identify and remove them. + */ + private void clearType3CacheEntriesForJob(String jobId) { + if (jobId == null || jobId.isEmpty()) { + return; + } + + String jobPrefix = jobId + ":"; + + // Collect keys to remove (to avoid ConcurrentModificationException) + java.util.List keysToRemove = new java.util.ArrayList<>(); + + // Find Type3 normalized font keys for this job + for (String key : type3NormalizedFontCache.keySet()) { + if (key.startsWith(jobPrefix)) { + keysToRemove.add(key); + } + } + + // Remove collected keys + for (String key : keysToRemove) { + type3NormalizedFontCache.remove(key); + } + int removedFonts = keysToRemove.size(); + + // Find Type3 glyph coverage keys for this job + keysToRemove.clear(); + for (String key : type3GlyphCoverageCache.keySet()) { + if (key.startsWith(jobPrefix)) { + keysToRemove.add(key); + } + } + + // Remove collected keys + for (String key : keysToRemove) { + type3GlyphCoverageCache.remove(key); + } + int removedGlyphs = keysToRemove.size(); + + if (removedFonts > 0 || removedGlyphs > 0) { + log.debug( + "Cleared Type3 caches for jobId {}: {} fonts, {} glyph entries", + jobId, + removedFonts, + removedGlyphs); + } + } + + private void replacePageContentFromModel( + PDDocument document, + PDPage page, + PdfJsonPage pageModel, + Map fontMap, + List fontModels, + int pageNumberValue) + throws IOException { + PDRectangle currentBox = page.getMediaBox(); + float fallbackWidth = currentBox != null ? currentBox.getWidth() : 612f; + float fallbackHeight = currentBox != null ? currentBox.getHeight() : 792f; + + float width = safeFloat(pageModel.getWidth(), fallbackWidth); + float height = safeFloat(pageModel.getHeight(), fallbackHeight); + PDRectangle newBox = new PDRectangle(width, height); + page.setMediaBox(newBox); + page.setCropBox(newBox); + + if (pageModel.getRotation() != null) { + page.setRotation(pageModel.getRotation()); + } + + applyPageResources(document, page, pageModel.getResources()); + + List preservedStreams = + buildContentStreams(document, pageModel.getContentStreams()); + if (preservedStreams.isEmpty()) { + page.setContents(new ArrayList<>()); + } else { + page.setContents(preservedStreams); + } + + List imageElements = + pageModel.getImageElements() != null + ? new ArrayList<>(pageModel.getImageElements()) + : new ArrayList<>(); + + if (!preservedStreams.isEmpty() && !imageElements.isEmpty()) { + reconstructImageXObjects(document, page, preservedStreams, imageElements); + } + + List textElements = + pageModel.getTextElements() != null + ? new ArrayList<>(pageModel.getTextElements()) + : new ArrayList<>(); + + PreflightResult preflightResult = + preflightTextElements(document, fontMap, fontModels, textElements, pageNumberValue); + if (!preflightResult.fallbackFontIds().isEmpty()) { + ensureFallbackResources(page, preflightResult.fallbackFontIds(), fontMap); + } + + Map fontLookup = buildFontModelLookup(fontModels); + + AppendMode appendMode = + preservedStreams.isEmpty() ? AppendMode.OVERWRITE : AppendMode.APPEND; + + RegenerateMode regenerateMode = + determineRegenerateMode( + document, + page, + preservedStreams, + textElements, + imageElements, + preflightResult, + fontLookup, + pageNumberValue); + + if (regenerateMode == RegenerateMode.REUSE_EXISTING) { + page.getAnnotations().clear(); + List annotations = + pageModel.getAnnotations() != null + ? new ArrayList<>(pageModel.getAnnotations()) + : new ArrayList<>(); + restoreAnnotations(document, page, annotations); + return; + } + + if (regenerateMode == RegenerateMode.REGENERATE_WITH_VECTOR_OVERLAY) { + PDStream vectorStream = + extractVectorGraphics(document, preservedStreams, imageElements); + if (vectorStream != null) { + page.setContents(Collections.singletonList(vectorStream)); + appendMode = AppendMode.APPEND; + } else { + page.setContents(new ArrayList<>()); + appendMode = AppendMode.OVERWRITE; + } + } else if (regenerateMode == RegenerateMode.REGENERATE_CLEAR) { + page.setContents(new ArrayList<>()); + appendMode = AppendMode.OVERWRITE; + } + + regeneratePageContent( + document, + page, + textElements, + imageElements, + fontMap, + fontModels, + pageNumberValue, + appendMode); + + page.getAnnotations().clear(); + List annotations = + pageModel.getAnnotations() != null + ? new ArrayList<>(pageModel.getAnnotations()) + : new ArrayList<>(); + restoreAnnotations(document, page, annotations); + } + + private RegenerateMode determineRegenerateMode( + PDDocument document, + PDPage page, + List preservedStreams, + List textElements, + List imageElements, + PreflightResult preflightResult, + Map fontLookup, + int pageNumberValue) + throws IOException { + boolean hasText = textElements != null && !textElements.isEmpty(); + boolean hasImages = imageElements != null && !imageElements.isEmpty(); + + if (!hasText && !hasImages) { + return RegenerateMode.REGENERATE_CLEAR; + } + + if (preservedStreams.isEmpty()) { + return RegenerateMode.REGENERATE_CLEAR; + } + + if (hasImages) { + return RegenerateMode.REGENERATE_WITH_VECTOR_OVERLAY; + } + + if (hasText && !preflightResult.usesFallback()) { + boolean rewriteSucceeded = + rewriteTextOperators( + document, page, textElements, false, true, fontLookup, pageNumberValue); + if (rewriteSucceeded) { + return RegenerateMode.REUSE_EXISTING; + } + return RegenerateMode.REGENERATE_WITH_VECTOR_OVERLAY; + } + + return RegenerateMode.REGENERATE_WITH_VECTOR_OVERLAY; + } + + private enum RegenerateMode { + REUSE_EXISTING, + REGENERATE_WITH_VECTOR_OVERLAY, + REGENERATE_CLEAR + } + + /** Schedules automatic cleanup of cached documents after 30 minutes. */ + private void scheduleDocumentCleanup(String jobId) { + new Thread( + () -> { + try { + Thread.sleep(TimeUnit.MINUTES.toMillis(30)); + clearCachedDocument(jobId); + log.debug("Auto-cleaned cached document for jobId: {}", jobId); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }) + .start(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/PdfJsonCosMapper.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/PdfJsonCosMapper.java new file mode 100644 index 0000000000..c990c568b6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/PdfJsonCosMapper.java @@ -0,0 +1,274 @@ +package stirling.software.SPDF.service; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSBoolean; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSFloat; +import org.apache.pdfbox.cos.COSInteger; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSNull; +import org.apache.pdfbox.cos.COSObject; +import org.apache.pdfbox.cos.COSStream; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.common.PDStream; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.json.PdfJsonCosValue; +import stirling.software.SPDF.model.json.PdfJsonStream; + +@Slf4j +@Component +public class PdfJsonCosMapper { + + public PdfJsonStream serializeStream(PDStream stream) throws IOException { + if (stream == null) { + return null; + } + return serializeStream( + stream.getCOSObject(), Collections.newSetFromMap(new IdentityHashMap<>())); + } + + public PdfJsonStream serializeStream(COSStream cosStream) throws IOException { + if (cosStream == null) { + return null; + } + return serializeStream(cosStream, Collections.newSetFromMap(new IdentityHashMap<>())); + } + + public PdfJsonCosValue serializeCosValue(COSBase base) throws IOException { + return serializeCosValue(base, Collections.newSetFromMap(new IdentityHashMap<>())); + } + + public COSBase deserializeCosValue(PdfJsonCosValue value, PDDocument document) + throws IOException { + if (value == null || value.getType() == null) { + return null; + } + switch (value.getType()) { + case NULL: + return COSNull.NULL; + case BOOLEAN: + if (value.getValue() instanceof Boolean bool) { + return COSBoolean.getBoolean(bool); + } + return null; + case INTEGER: + if (value.getValue() instanceof Number number) { + return COSInteger.get(number.longValue()); + } + return null; + case FLOAT: + if (value.getValue() instanceof Number number) { + return new COSFloat(number.floatValue()); + } + return null; + case NAME: + if (value.getValue() instanceof String name) { + return COSName.getPDFName(name); + } + return null; + case STRING: + if (value.getValue() instanceof String encoded) { + try { + byte[] bytes = Base64.getDecoder().decode(encoded); + return new COSString(bytes); + } catch (IllegalArgumentException ex) { + log.debug("Failed to decode COSString value: {}", ex.getMessage()); + } + } + return null; + case ARRAY: + COSArray array = new COSArray(); + if (value.getItems() != null) { + for (PdfJsonCosValue item : value.getItems()) { + COSBase entry = deserializeCosValue(item, document); + if (entry != null) { + array.add(entry); + } else { + array.add(COSNull.NULL); + } + } + } + return array; + case DICTIONARY: + COSDictionary dictionary = new COSDictionary(); + if (value.getEntries() != null) { + for (Map.Entry entry : value.getEntries().entrySet()) { + COSName key = COSName.getPDFName(entry.getKey()); + COSBase entryValue = deserializeCosValue(entry.getValue(), document); + if (entryValue != null) { + dictionary.setItem(key, entryValue); + } + } + } + return dictionary; + case STREAM: + if (value.getStream() != null) { + return buildStreamFromModel(value.getStream(), document); + } + return null; + default: + return null; + } + } + + public COSStream buildStreamFromModel(PdfJsonStream streamModel, PDDocument document) + throws IOException { + if (streamModel == null) { + return null; + } + COSStream cosStream = document.getDocument().createCOSStream(); + if (streamModel.getDictionary() != null) { + for (Map.Entry entry : + streamModel.getDictionary().entrySet()) { + COSName key = COSName.getPDFName(entry.getKey()); + COSBase value = deserializeCosValue(entry.getValue(), document); + if (value != null) { + cosStream.setItem(key, value); + } + } + } + + String rawData = streamModel.getRawData(); + if (rawData != null && !rawData.isBlank()) { + byte[] data; + try { + data = Base64.getDecoder().decode(rawData); + } catch (IllegalArgumentException ex) { + log.debug("Invalid base64 content stream data: {}", ex.getMessage()); + data = new byte[0]; + } + try (OutputStream outputStream = cosStream.createRawOutputStream()) { + outputStream.write(data); + } + cosStream.setItem(COSName.LENGTH, COSInteger.get(data.length)); + } else { + cosStream.setItem(COSName.LENGTH, COSInteger.get(0)); + } + return cosStream; + } + + private PdfJsonCosValue serializeCosValue(COSBase base, Set visited) + throws IOException { + if (base == null) { + return null; + } + if (base instanceof COSObject cosObject) { + base = cosObject.getObject(); + if (base == null) { + return null; + } + } + + boolean complex = + base instanceof COSDictionary + || base instanceof COSArray + || base instanceof COSStream; + if (complex) { + if (!visited.add(base)) { + return PdfJsonCosValue.builder() + .type(PdfJsonCosValue.Type.NAME) + .value("__circular__") + .build(); + } + } + + try { + PdfJsonCosValue.PdfJsonCosValueBuilder builder = PdfJsonCosValue.builder(); + if (base instanceof COSNull) { + builder.type(PdfJsonCosValue.Type.NULL); + return builder.build(); + } + if (base instanceof COSBoolean booleanValue) { + builder.type(PdfJsonCosValue.Type.BOOLEAN).value(booleanValue.getValue()); + return builder.build(); + } + if (base instanceof COSInteger integer) { + builder.type(PdfJsonCosValue.Type.INTEGER).value(integer.longValue()); + return builder.build(); + } + if (base instanceof COSFloat floatValue) { + builder.type(PdfJsonCosValue.Type.FLOAT).value(floatValue.floatValue()); + return builder.build(); + } + if (base instanceof COSName name) { + builder.type(PdfJsonCosValue.Type.NAME).value(name.getName()); + return builder.build(); + } + if (base instanceof COSString cosString) { + builder.type(PdfJsonCosValue.Type.STRING) + .value(Base64.getEncoder().encodeToString(cosString.getBytes())); + return builder.build(); + } + if (base instanceof COSArray array) { + List items = new ArrayList<>(array.size()); + for (COSBase item : array) { + PdfJsonCosValue serialized = serializeCosValue(item, visited); + items.add(serialized); + } + builder.type(PdfJsonCosValue.Type.ARRAY).items(items); + return builder.build(); + } + if (base instanceof COSStream stream) { + builder.type(PdfJsonCosValue.Type.STREAM).stream(serializeStream(stream, visited)); + return builder.build(); + } + if (base instanceof COSDictionary dictionary) { + Map entries = new LinkedHashMap<>(); + for (COSName key : dictionary.keySet()) { + PdfJsonCosValue serialized = + serializeCosValue(dictionary.getDictionaryObject(key), visited); + entries.put(key.getName(), serialized); + } + builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries); + return builder.build(); + } + return null; + } finally { + if (complex) { + visited.remove(base); + } + } + } + + private PdfJsonStream serializeStream(COSStream cosStream, Set visited) + throws IOException { + Map dictionary = new LinkedHashMap<>(); + for (COSName key : cosStream.keySet()) { + COSBase value = cosStream.getDictionaryObject(key); + PdfJsonCosValue serialized = serializeCosValue(value, visited); + if (serialized != null) { + dictionary.put(key.getName(), serialized); + } + } + String rawData = null; + try (InputStream inputStream = cosStream.createRawInputStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + if (inputStream != null) { + inputStream.transferTo(baos); + } + byte[] data = baos.toByteArray(); + if (data.length > 0) { + rawData = Base64.getEncoder().encodeToString(data); + } + } + return PdfJsonStream.builder().dictionary(dictionary).rawData(rawData).build(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java new file mode 100644 index 0000000000..4cf0fc8a11 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java @@ -0,0 +1,576 @@ +package stirling.software.SPDF.service; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +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; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.json.PdfJsonFont; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PdfJsonFallbackFontService { + + public static final String FALLBACK_FONT_ID = "fallback-noto-sans"; + public static final String DEFAULT_FALLBACK_FONT_LOCATION = + "classpath:/static/fonts/NotoSans-Regular.ttf"; + public static final String FALLBACK_FONT_CJK_ID = "fallback-noto-cjk"; + public static final String FALLBACK_FONT_JP_ID = "fallback-noto-jp"; + public static final String FALLBACK_FONT_KR_ID = "fallback-noto-korean"; + public static final String FALLBACK_FONT_AR_ID = "fallback-noto-arabic"; + public static final String FALLBACK_FONT_TH_ID = "fallback-noto-thai"; + + // Font name aliases map PDF font names to available fallback fonts + // This provides better visual consistency when editing PDFs + private static final Map FONT_NAME_ALIASES = + Map.ofEntries( + // Liberation fonts are metric-compatible with Microsoft core fonts + Map.entry("arial", "fallback-liberation-sans"), + Map.entry("helvetica", "fallback-liberation-sans"), + Map.entry("arimo", "fallback-liberation-sans"), + Map.entry("liberationsans", "fallback-liberation-sans"), + Map.entry("times", "fallback-liberation-serif"), + Map.entry("timesnewroman", "fallback-liberation-serif"), + Map.entry("tinos", "fallback-liberation-serif"), + Map.entry("liberationserif", "fallback-liberation-serif"), + Map.entry("courier", "fallback-liberation-mono"), + Map.entry("couriernew", "fallback-liberation-mono"), + Map.entry("cousine", "fallback-liberation-mono"), + Map.entry("liberationmono", "fallback-liberation-mono"), + // DejaVu fonts - widely used open source fonts + Map.entry("dejavu", "fallback-dejavu-sans"), + Map.entry("dejavusans", "fallback-dejavu-sans"), + Map.entry("dejavuserif", "fallback-dejavu-serif"), + Map.entry("dejavumono", "fallback-dejavu-mono"), + Map.entry("dejavusansmono", "fallback-dejavu-mono"), + // Noto Sans - Google's universal font (use as last resort generic fallback) + Map.entry("noto", "fallback-noto-sans"), + Map.entry("notosans", "fallback-noto-sans")); + + private static final Map BUILT_IN_FALLBACK_FONTS = + Map.ofEntries( + Map.entry( + FALLBACK_FONT_CJK_ID, + new FallbackFontSpec( + "classpath:/static/fonts/NotoSansSC-Regular.ttf", + "NotoSansSC-Regular", + "ttf")), + Map.entry( + FALLBACK_FONT_JP_ID, + new FallbackFontSpec( + "classpath:/static/fonts/NotoSansJP-Regular.ttf", + "NotoSansJP-Regular", + "ttf")), + Map.entry( + FALLBACK_FONT_KR_ID, + new FallbackFontSpec( + "classpath:/static/fonts/NotoSansKR-Regular.ttf", + "NotoSansKR-Regular", + "ttf")), + Map.entry( + FALLBACK_FONT_AR_ID, + new FallbackFontSpec( + "classpath:/static/fonts/NotoSansArabic-Regular.ttf", + "NotoSansArabic-Regular", + "ttf")), + Map.entry( + FALLBACK_FONT_TH_ID, + new FallbackFontSpec( + "classpath:/static/fonts/NotoSansThai-Regular.ttf", + "NotoSansThai-Regular", + "ttf")), + // Liberation Sans family + Map.entry( + "fallback-liberation-sans", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationSans-Regular.ttf", + "LiberationSans-Regular", + "ttf")), + Map.entry( + "fallback-liberation-sans-bold", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationSans-Bold.ttf", + "LiberationSans-Bold", + "ttf")), + Map.entry( + "fallback-liberation-sans-italic", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationSans-Italic.ttf", + "LiberationSans-Italic", + "ttf")), + Map.entry( + "fallback-liberation-sans-bolditalic", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationSans-BoldItalic.ttf", + "LiberationSans-BoldItalic", + "ttf")), + // Liberation Serif family + Map.entry( + "fallback-liberation-serif", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationSerif-Regular.ttf", + "LiberationSerif-Regular", + "ttf")), + Map.entry( + "fallback-liberation-serif-bold", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationSerif-Bold.ttf", + "LiberationSerif-Bold", + "ttf")), + Map.entry( + "fallback-liberation-serif-italic", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationSerif-Italic.ttf", + "LiberationSerif-Italic", + "ttf")), + Map.entry( + "fallback-liberation-serif-bolditalic", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationSerif-BoldItalic.ttf", + "LiberationSerif-BoldItalic", + "ttf")), + // Liberation Mono family + Map.entry( + "fallback-liberation-mono", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationMono-Regular.ttf", + "LiberationMono-Regular", + "ttf")), + Map.entry( + "fallback-liberation-mono-bold", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationMono-Bold.ttf", + "LiberationMono-Bold", + "ttf")), + Map.entry( + "fallback-liberation-mono-italic", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationMono-Italic.ttf", + "LiberationMono-Italic", + "ttf")), + Map.entry( + "fallback-liberation-mono-bolditalic", + new FallbackFontSpec( + "classpath:/static/fonts/LiberationMono-BoldItalic.ttf", + "LiberationMono-BoldItalic", + "ttf")), + // Noto Sans family (enhanced with weight variants) + Map.entry( + FALLBACK_FONT_ID, + new FallbackFontSpec( + DEFAULT_FALLBACK_FONT_LOCATION, "NotoSans-Regular", "ttf")), + Map.entry( + "fallback-noto-sans-bold", + new FallbackFontSpec( + "classpath:/static/fonts/NotoSans-Bold.ttf", + "NotoSans-Bold", + "ttf")), + Map.entry( + "fallback-noto-sans-italic", + new FallbackFontSpec( + "classpath:/static/fonts/NotoSans-Italic.ttf", + "NotoSans-Italic", + "ttf")), + Map.entry( + "fallback-noto-sans-bolditalic", + new FallbackFontSpec( + "classpath:/static/fonts/NotoSans-BoldItalic.ttf", + "NotoSans-BoldItalic", + "ttf")), + // DejaVu Sans family + Map.entry( + "fallback-dejavu-sans", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSans.ttf", "DejaVuSans", "ttf")), + Map.entry( + "fallback-dejavu-sans-bold", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSans-Bold.ttf", + "DejaVuSans-Bold", + "ttf")), + Map.entry( + "fallback-dejavu-sans-oblique", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSans-Oblique.ttf", + "DejaVuSans-Oblique", + "ttf")), + Map.entry( + "fallback-dejavu-sans-boldoblique", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSans-BoldOblique.ttf", + "DejaVuSans-BoldOblique", + "ttf")), + // DejaVu Serif family + Map.entry( + "fallback-dejavu-serif", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSerif.ttf", + "DejaVuSerif", + "ttf")), + Map.entry( + "fallback-dejavu-serif-bold", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSerif-Bold.ttf", + "DejaVuSerif-Bold", + "ttf")), + Map.entry( + "fallback-dejavu-serif-italic", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSerif-Italic.ttf", + "DejaVuSerif-Italic", + "ttf")), + Map.entry( + "fallback-dejavu-serif-bolditalic", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSerif-BoldItalic.ttf", + "DejaVuSerif-BoldItalic", + "ttf")), + // DejaVu Mono family + Map.entry( + "fallback-dejavu-mono", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSansMono.ttf", + "DejaVuSansMono", + "ttf")), + Map.entry( + "fallback-dejavu-mono-bold", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSansMono-Bold.ttf", + "DejaVuSansMono-Bold", + "ttf")), + Map.entry( + "fallback-dejavu-mono-oblique", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSansMono-Oblique.ttf", + "DejaVuSansMono-Oblique", + "ttf")), + Map.entry( + "fallback-dejavu-mono-boldoblique", + new FallbackFontSpec( + "classpath:/static/fonts/DejaVuSansMono-BoldOblique.ttf", + "DejaVuSansMono-BoldOblique", + "ttf"))); + + private final ResourceLoader resourceLoader; + + @Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}") + private String fallbackFontLocation; + + private final Map fallbackFontCache = new ConcurrentHashMap<>(); + + public PdfJsonFont buildFallbackFontModel() throws IOException { + return buildFallbackFontModel(FALLBACK_FONT_ID); + } + + public PdfJsonFont buildFallbackFontModel(String fallbackId) throws IOException { + FallbackFontSpec spec = getFallbackFontSpec(fallbackId); + if (spec == null) { + throw new IOException("Unknown fallback font id " + fallbackId); + } + byte[] bytes = loadFallbackFontBytes(fallbackId, spec); + String base64 = java.util.Base64.getEncoder().encodeToString(bytes); + return PdfJsonFont.builder() + .id(fallbackId) + .uid(fallbackId) + .baseName(spec.baseName()) + .subtype("TrueType") + .embedded(true) + .program(base64) + .programFormat(spec.format()) + .build(); + } + + public PDFont loadFallbackPdfFont(PDDocument document) throws IOException { + return loadFallbackPdfFont(document, FALLBACK_FONT_ID); + } + + public PDFont loadFallbackPdfFont(PDDocument document, String fallbackId) throws IOException { + FallbackFontSpec spec = getFallbackFontSpec(fallbackId); + if (spec == null) { + throw new IOException("Unknown fallback font id " + fallbackId); + } + byte[] bytes = loadFallbackFontBytes(fallbackId, spec); + try (InputStream stream = new ByteArrayInputStream(bytes)) { + // Load with embedSubset=false to ensure full glyph coverage + // Fallback fonts need all glyphs available for substituting missing characters + return PDType0Font.load(document, stream, false); + } + } + + public boolean canEncodeFully(PDFont font, String text) { + return canEncode(font, text); + } + + public boolean canEncode(PDFont font, int codePoint) { + return canEncode(font, new String(Character.toChars(codePoint))); + } + + public boolean canEncode(PDFont font, String text) { + if (font == null || text == null || text.isEmpty()) { + return false; + } + if (font instanceof PDType3Font) { + return false; + } + try { + font.encode(text); + return true; + } catch (IOException | IllegalArgumentException | UnsupportedOperationException ex) { + // Only log at debug level to reduce verbosity - summary is logged elsewhere + log.debug( + "[FONT-DEBUG] Font {} cannot encode text '{}' ({}): {}", + font != null ? font.getName() : "null", + text, + font != null ? font.getClass().getSimpleName() : "null", + ex.getMessage()); + return false; + } + } + + /** + * Resolve fallback font ID based on the original font name and code point. Attempts to match + * font family and weight/style for visual consistency. + * + * @param originalFontName the name of the original font (may be null) + * @param codePoint the Unicode code point that needs to be rendered + * @return fallback font ID + */ + public String resolveFallbackFontId(String originalFontName, int codePoint) { + // First try to match based on original font name for visual consistency + if (originalFontName != null && !originalFontName.isEmpty()) { + // Normalize font name: remove subset prefix (e.g. "PXAAAC+"), convert to lowercase, + // remove spaces + String normalized = + originalFontName + .replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix + .toLowerCase() + .replaceAll("\\s+", ""); // Remove spaces (e.g. "Times New Roman" -> + // "timesnewroman") + + // Extract base name without weight/style suffixes + // Split on common delimiters: hyphen, underscore, comma, plus + // Handles: "Arimo_700wght" -> "arimo", "Arial-Bold" -> "arial", "Arial,Bold" -> "arial" + String baseName = normalized.split("[-_,+]")[0]; + + String aliasedFontId = FONT_NAME_ALIASES.get(baseName); + if (aliasedFontId != null) { + // Detect weight and style from the normalized font name + boolean isBold = detectBold(normalized); + boolean isItalic = detectItalic(normalized); + + // Apply weight/style suffix to fallback font ID + String styledFontId = applyWeightStyle(aliasedFontId, isBold, isItalic); + + log.debug( + "Matched font '{}' (normalized: '{}', base: '{}', bold: {}, italic: {}) to fallback '{}'", + originalFontName, + normalized, + baseName, + isBold, + isItalic, + styledFontId); + return styledFontId; + } + } + + // Fall back to Unicode-based selection + return resolveFallbackFontId(codePoint); + } + + /** + * Detect if font name indicates bold weight. + * + * @param normalizedFontName lowercase font name without subset prefix or spaces + * @return true if bold weight is detected + */ + private boolean detectBold(String normalizedFontName) { + // Check for explicit bold indicators + if (normalizedFontName.contains("bold") + || normalizedFontName.contains("heavy") + || normalizedFontName.contains("black")) { + return true; + } + + // Check for numeric weight indicators (600-900 = bold) + // Handles: "Arimo_700wght", "Arial-700", "Font-w700" + if (normalizedFontName.matches(".*[_-]?[6-9]00(wght)?.*")) { + return true; + } + + return false; + } + + /** + * Detect if font name indicates italic/oblique style. + * + * @param normalizedFontName lowercase font name without subset prefix or spaces + * @return true if italic style is detected + */ + private boolean detectItalic(String normalizedFontName) { + return normalizedFontName.contains("italic") || normalizedFontName.contains("oblique"); + } + + /** + * Apply weight/style suffix to fallback font ID. + * + *

Weight/style variants are only applied to font families where we have the actual font + * files available. Currently supported: - Liberation Sans: Regular, Bold, Italic, BoldItalic + * (full support) - Liberation Serif: Regular, Bold, Italic, BoldItalic (full support) - + * Liberation Mono: Regular, Bold, Italic, BoldItalic (full support) - Noto Sans: Regular, Bold, + * Italic, BoldItalic (full support) - DejaVu Sans: Regular, Bold, Oblique, BoldOblique (full + * support) - DejaVu Serif: Regular, Bold, Italic, BoldItalic (full support) - DejaVu Mono: + * Regular, Bold, Oblique, BoldOblique (full support) + * + *

To add weight/style support for additional font families: 1. Download the font files + * (Bold, Italic, BoldItalic) to: app/core/src/main/resources/static/fonts/ 2. Register the + * variants in BUILT_IN_FALLBACK_FONTS map (see lines 63-267) 3. Update the check below to + * include the font family prefix + * + * @param baseFontId base fallback font ID (e.g., "fallback-liberation-sans") + * @param isBold true if bold weight needed + * @param isItalic true if italic style needed + * @return styled font ID (e.g., "fallback-liberation-sans-bold"), or base ID if variants not + * available + */ + private String applyWeightStyle(String baseFontId, boolean isBold, boolean isItalic) { + // Only apply weight/style to font families where we have the font files available + // Supported: Liberation (Sans/Serif/Mono), Noto Sans, DejaVu (Sans/Serif/Mono) + boolean isSupported = + baseFontId.startsWith("fallback-liberation-") + || baseFontId.equals("fallback-noto-sans") + || baseFontId.startsWith("fallback-dejavu-"); + + if (!isSupported) { + return baseFontId; + } + + // DejaVu Sans and Mono use "oblique" instead of "italic" + boolean useOblique = + baseFontId.equals("fallback-dejavu-sans") + || baseFontId.equals("fallback-dejavu-mono"); + + if (isBold && isItalic) { + return baseFontId + (useOblique ? "-boldoblique" : "-bolditalic"); + } else if (isBold) { + return baseFontId + "-bold"; + } else if (isItalic) { + return baseFontId + (useOblique ? "-oblique" : "-italic"); + } + + return baseFontId; + } + + /** + * Resolve fallback font ID based on Unicode code point properties. + * + * @param codePoint the Unicode code point + * @return fallback font ID + */ + public String resolveFallbackFontId(int codePoint) { + Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint); + if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS + || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A + || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B + || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C + || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D + || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E + || block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F + || block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION + || block == Character.UnicodeBlock.BOPOMOFO + || block == Character.UnicodeBlock.BOPOMOFO_EXTENDED + || block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS) { + return FALLBACK_FONT_CJK_ID; + } + + Character.UnicodeScript script = Character.UnicodeScript.of(codePoint); + return switch (script) { + case HAN -> FALLBACK_FONT_CJK_ID; + case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID; + case HANGUL -> FALLBACK_FONT_KR_ID; + case ARABIC -> FALLBACK_FONT_AR_ID; + case THAI -> FALLBACK_FONT_TH_ID; + default -> FALLBACK_FONT_ID; + }; + } + + public String mapUnsupportedGlyph(int codePoint) { + return switch (codePoint) { + case 0x276E -> "<"; + case 0x276F -> ">"; + default -> null; + }; + } + + private FallbackFontSpec getFallbackFontSpec(String fallbackId) { + if (FALLBACK_FONT_ID.equals(fallbackId)) { + String baseName = inferBaseName(fallbackFontLocation, "NotoSans-Regular"); + String format = inferFormat(fallbackFontLocation, "ttf"); + return new FallbackFontSpec(fallbackFontLocation, baseName, format); + } + return BUILT_IN_FALLBACK_FONTS.get(fallbackId); + } + + private byte[] loadFallbackFontBytes(String fallbackId, FallbackFontSpec spec) + throws IOException { + if (spec == null) { + throw new IOException("No fallback font specification for " + fallbackId); + } + byte[] cached = fallbackFontCache.get(fallbackId); + if (cached != null) { + return cached; + } + Resource resource = resourceLoader.getResource(spec.resourceLocation()); + if (!resource.exists()) { + throw new IOException("Fallback font resource not found at " + spec.resourceLocation()); + } + try (InputStream inputStream = resource.getInputStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + inputStream.transferTo(baos); + byte[] bytes = baos.toByteArray(); + fallbackFontCache.put(fallbackId, bytes); + return bytes; + } + } + + private String inferBaseName(String location, String defaultName) { + if (location == null || location.isBlank()) { + return defaultName; + } + int slash = location.lastIndexOf('/'); + String fileName = slash >= 0 ? location.substring(slash + 1) : location; + int dot = fileName.lastIndexOf('.'); + if (dot > 0) { + fileName = fileName.substring(0, dot); + } + return fileName.isEmpty() ? defaultName : fileName; + } + + private String inferFormat(String location, String defaultFormat) { + if (location == null || location.isBlank()) { + return defaultFormat; + } + int dot = location.lastIndexOf('.'); + if (dot >= 0 && dot < location.length() - 1) { + return location.substring(dot + 1).toLowerCase(Locale.ROOT); + } + return defaultFormat; + } + + private record FallbackFontSpec(String resourceLocation, String baseName, String format) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/JobOwnershipServiceImpl.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/JobOwnershipServiceImpl.java new file mode 100644 index 0000000000..8fb6814f38 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/JobOwnershipServiceImpl.java @@ -0,0 +1,111 @@ +package stirling.software.SPDF.service.pdfjson; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.UserServiceInterface; + +/** + * Service to manage job ownership and access control for PDF JSON operations. When security is + * enabled, jobs are scoped to authenticated users. When security is disabled, jobs are globally + * accessible. + */ +@Slf4j +@Service +@ConditionalOnProperty(name = "security.enable-login", havingValue = "true", matchIfMissing = false) +public class JobOwnershipServiceImpl + implements stirling.software.common.service.JobOwnershipService { + + @Autowired(required = false) + private UserServiceInterface userService; + + /** + * Get the current authenticated user's identifier. Returns empty if no user is authenticated. + * + * @return Optional containing user identifier, or empty if not authenticated + */ + public Optional getCurrentUserId() { + if (userService == null) { + log.debug("UserService not available"); + return Optional.empty(); + } + + try { + String username = userService.getCurrentUsername(); + if (username != null && !username.isEmpty() && !"anonymousUser".equals(username)) { + log.debug("Current authenticated user: {}", username); + return Optional.of(username); + } + } catch (Exception e) { + log.warn("Failed to get current username from UserService: {}", e.getMessage()); + } + return Optional.empty(); + } + + /** + * Create a scoped job key that includes user ownership when security is enabled. + * + * @param jobId the base job identifier + * @return scoped job key in format "userId:jobId", or just jobId if no user authenticated + */ + public String createScopedJobKey(String jobId) { + Optional userId = getCurrentUserId(); + if (userId.isPresent()) { + String scopedKey = userId.get() + ":" + jobId; + log.debug("Created scoped job key: {}", scopedKey); + return scopedKey; + } + log.debug("No user authenticated, using unsecured job key: {}", jobId); + return jobId; + } + + /** + * Validate that the current user has access to the given job. + * + * @param scopedJobKey the scoped job key to validate + * @return true if current user owns the job or no authentication is required + * @throws SecurityException if current user does not own the job + */ + public boolean validateJobAccess(String scopedJobKey) { + Optional userId = getCurrentUserId(); + + // If no user authenticated, allow access (backwards compatibility) + if (userId.isEmpty()) { + log.debug("No authentication required, allowing access to job: {}", scopedJobKey); + return true; + } + + // Check if job key starts with current user's ID + String userPrefix = userId.get() + ":"; + if (!scopedJobKey.startsWith(userPrefix)) { + log.warn( + "Access denied: User {} attempted to access job key {} which they don't own", + userId.get(), + scopedJobKey); + throw new SecurityException( + "Access denied: You do not have permission to access this job"); + } + + log.debug("Access granted: User {} owns job {}", userId.get(), scopedJobKey); + return true; + } + + /** + * Extract the base job ID from a scoped job key. + * + * @param scopedJobKey the scoped job key + * @return the base job ID without user prefix + */ + public String extractJobId(String scopedJobKey) { + int colonIndex = scopedJobKey.indexOf(':'); + if (colonIndex > 0) { + return scopedJobKey.substring(colonIndex + 1); + } + return scopedJobKey; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/NoOpJobOwnershipService.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/NoOpJobOwnershipService.java new file mode 100644 index 0000000000..d6a7d52b77 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/NoOpJobOwnershipService.java @@ -0,0 +1,44 @@ +package stirling.software.SPDF.service.pdfjson; + +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * No-op implementation of job ownership service when security is disabled. All jobs are globally + * accessible without authentication. + */ +@Slf4j +@Service +@ConditionalOnProperty(name = "security.enable-login", havingValue = "false", matchIfMissing = true) +public class NoOpJobOwnershipService + implements stirling.software.common.service.JobOwnershipService { + + @Override + public Optional getCurrentUserId() { + // No authentication when security is disabled + return Optional.empty(); + } + + @Override + public String createScopedJobKey(String jobId) { + // Jobs are not scoped to users when security is disabled + return jobId; + } + + @Override + public boolean validateJobAccess(String scopedJobKey) { + // All jobs are accessible when security is disabled + log.trace("Security disabled, allowing access to job: {}", scopedJobKey); + return true; + } + + @Override + public String extractJobId(String scopedJobKey) { + // No user prefix when security is disabled + return scopedJobKey; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java new file mode 100644 index 0000000000..1a9f7f698f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java @@ -0,0 +1,350 @@ +package stirling.software.SPDF.service.pdfjson; + +import java.io.IOException; +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; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PdfJsonFontService { + + private final TempFileManager tempFileManager; + + @Getter + @Value("${stirling.pdf.json.cff-converter.enabled:true}") + private boolean cffConversionEnabled; + + @Getter + @Value("${stirling.pdf.json.cff-converter.method:python}") + 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; + private volatile boolean fontForgeCffConverterAvailable; + + @PostConstruct + private void initialiseCffConverterAvailability() { + if (!cffConversionEnabled) { + log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration"); + pythonCffConverterAvailable = false; + fontForgeCffConverterAvailable = false; + return; + } + + log.info("[FONT-DEBUG] CFF conversion enabled, checking tool availability..."); + pythonCffConverterAvailable = isCommandAvailable(pythonCommand); + if (!pythonCffConverterAvailable) { + log.warn( + "[FONT-DEBUG] Python command '{}' not found; Python CFF conversion disabled", + pythonCommand); + } else { + log.info("[FONT-DEBUG] Python command '{}' is available", pythonCommand); + } + + fontForgeCffConverterAvailable = isCommandAvailable(fontforgeCommand); + if (!fontForgeCffConverterAvailable) { + log.warn( + "[FONT-DEBUG] FontForge command '{}' not found; FontForge CFF conversion disabled", + fontforgeCommand); + } else { + log.info("[FONT-DEBUG] FontForge command '{}' is available", fontforgeCommand); + } + + log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod); + } + + public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) { + if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) { + log.warn( + "[FONT-DEBUG] CFF conversion skipped: enabled={}, bytes={}", + cffConversionEnabled, + fontBytes == null ? "null" : fontBytes.length); + return null; + } + + log.info( + "[FONT-DEBUG] Converting CFF font: {} bytes, method: {}", + fontBytes.length, + cffConverterMethod); + + if ("python".equalsIgnoreCase(cffConverterMethod)) { + if (!pythonCffConverterAvailable) { + log.debug("[FONT-DEBUG] Python CFF converter not available, skipping conversion"); + return null; + } + byte[] result = convertCffUsingPython(fontBytes, toUnicode); + log.debug( + "[FONT-DEBUG] Python conversion result: {}", + result == null ? "null" : result.length + " bytes"); + return result; + } else if ("fontforge".equalsIgnoreCase(cffConverterMethod)) { + if (!fontForgeCffConverterAvailable) { + log.debug( + "[FONT-DEBUG] FontForge CFF converter not available, skipping conversion"); + return null; + } + byte[] result = convertCffUsingFontForge(fontBytes); + log.debug( + "[FONT-DEBUG] FontForge conversion result: {}", + result == null ? "null" : result.length + " bytes"); + return result; + } else { + log.debug( + "[FONT-DEBUG] Unknown CFF converter method: {}, falling back to Python", + cffConverterMethod); + if (!pythonCffConverterAvailable) { + log.debug("[FONT-DEBUG] Python CFF converter not available, skipping conversion"); + return null; + } + byte[] result = convertCffUsingPython(fontBytes, toUnicode); + log.debug( + "[FONT-DEBUG] Python conversion result: {}", + result == null ? "null" : result.length + " bytes"); + return result; + } + } + + public String detectFontFlavor(byte[] fontBytes) { + if (fontBytes == null || fontBytes.length < 4) { + return null; + } + int signature = + ((fontBytes[0] & 0xFF) << 24) + | ((fontBytes[1] & 0xFF) << 16) + | ((fontBytes[2] & 0xFF) << 8) + | (fontBytes[3] & 0xFF); + if (signature == 0x00010000 || signature == 0x74727565) { + return "ttf"; + } + if (signature == 0x4F54544F) { + return "otf"; + } + if (signature == 0x74746366) { + return "cff"; + } + return null; + } + + public String detectTrueTypeFormat(byte[] data) { + if (data == null || data.length < 4) { + return null; + } + int signature = + ((data[0] & 0xFF) << 24) + | ((data[1] & 0xFF) << 16) + | ((data[2] & 0xFF) << 8) + | (data[3] & 0xFF); + if (signature == 0x00010000) { + return "ttf"; + } + if (signature == 0x4F54544F) { + return "otf"; + } + if (signature == 0x74746366) { + return "cff"; + } + return null; + } + + public String validateFontTables(byte[] fontBytes) { + if (fontBytes == null || fontBytes.length < 12) { + return "Font program too small"; + } + int numTables = ((fontBytes[4] & 0xFF) << 8) | (fontBytes[5] & 0xFF); + if (numTables <= 0 || numTables > 512) { + return "Invalid numTables: " + numTables; + } + return null; + } + + private byte[] convertCffUsingPython(byte[] fontBytes, String toUnicode) { + if (!pythonCffConverterAvailable) { + log.debug("[FONT-DEBUG] Python CFF converter not available"); + return null; + } + if (pythonCommand == null + || pythonCommand.isBlank() + || pythonScript == null + || pythonScript.isBlank()) { + log.debug("[FONT-DEBUG] Python converter not configured"); + return null; + } + + log.debug( + "[FONT-DEBUG] Running Python CFF converter: command={}, script={}", + pythonCommand, + pythonScript); + + try (TempFile inputFile = new TempFile(tempFileManager, ".cff"); + TempFile outputFile = new TempFile(tempFileManager, ".otf"); + TempFile toUnicodeFile = + toUnicode != null ? new TempFile(tempFileManager, ".tounicode") : null) { + Files.write(inputFile.getPath(), fontBytes); + if (toUnicodeFile != null) { + try { + byte[] toUnicodeBytes = Base64.getDecoder().decode(toUnicode); + Files.write(toUnicodeFile.getPath(), toUnicodeBytes); + } catch (IllegalArgumentException ex) { + log.debug( + "[FONT-DEBUG] Failed to decode ToUnicode data for CFF conversion: {}", + ex.getMessage()); + return null; + } + } + + String[] command = + buildPythonCommand( + inputFile.getAbsolutePath(), + outputFile.getAbsolutePath(), + toUnicodeFile != null ? toUnicodeFile.getAbsolutePath() : null); + log.debug("[FONT-DEBUG] Executing: {}", String.join(" ", command)); + + ProcessExecutorResult result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.CFF_CONVERTER) + .runCommandWithOutputHandling(java.util.Arrays.asList(command)); + + if (result.getRc() != 0) { + log.error( + "[FONT-DEBUG] Python CFF conversion failed with exit code: {}", + result.getRc()); + log.error("[FONT-DEBUG] Stdout: {}", result.getMessages()); + return null; + } + if (!Files.exists(outputFile.getPath())) { + log.error("[FONT-DEBUG] Python CFF conversion produced no output file"); + return null; + } + byte[] data = Files.readAllBytes(outputFile.getPath()); + if (data.length == 0) { + log.error("[FONT-DEBUG] Python CFF conversion returned empty output"); + return null; + } + log.info( + "[FONT-DEBUG] Python CFF conversion succeeded: {} bytes -> {} bytes", + fontBytes.length, + data.length); + return data; + } catch (IOException | InterruptedException ex) { + if (ex instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + log.error("[FONT-DEBUG] Python CFF conversion exception: {}", ex.getMessage(), ex); + return null; + } + } + + public byte[] convertCffUsingFontForge(byte[] fontBytes) { + if (!fontForgeCffConverterAvailable) { + log.debug("FontForge CFF converter not available"); + return null; + } + + try (TempFile inputFile = new TempFile(tempFileManager, ".cff"); + TempFile outputFile = new TempFile(tempFileManager, ".ttf")) { + Files.write(inputFile.getPath(), fontBytes); + + ProcessExecutorResult result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.CFF_CONVERTER) + .runCommandWithOutputHandling( + java.util.Arrays.asList( + fontforgeCommand, + "-lang=ff", + "-c", + "Open($1); " + + "ScaleToEm(1000); " + + "SelectWorthOutputting(); " + + "SetFontOrder(2); " + + "Reencode(\"unicode\"); " + + "RoundToInt(); " + + "RemoveOverlap(); " + + "Simplify(); " + + "CorrectDirection(); " + + "Generate($2, \"\", 4+16+32); " + + "Close(); " + + "Quit()", + inputFile.getAbsolutePath(), + outputFile.getAbsolutePath())); + + if (result.getRc() != 0) { + log.warn("FontForge CFF conversion failed: {}", result.getRc()); + return null; + } + if (!Files.exists(outputFile.getPath())) { + log.warn("FontForge CFF conversion produced no output"); + return null; + } + byte[] data = Files.readAllBytes(outputFile.getPath()); + if (data.length == 0) { + log.warn("FontForge CFF conversion returned empty output"); + return null; + } + return data; + } catch (IOException | InterruptedException ex) { + if (ex instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + log.warn("FontForge CFF conversion failed: {}", ex.getMessage()); + return null; + } + } + + private boolean isCommandAvailable(String command) { + if (command == null || command.isBlank()) { + return false; + } + try { + ProcessBuilder processBuilder = new ProcessBuilder(); + if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows")) { + processBuilder.command("where", command); + } else { + processBuilder.command("which", command); + } + Process process = processBuilder.start(); + int exitCode = process.waitFor(); + return exitCode == 0; + } catch (Exception e) { + log.debug("Error checking for command {}: {}", command, e.getMessage()); + return false; + } + } + + private String[] buildPythonCommand(String input, String output, String toUnicode) { + if (toUnicode != null) { + return new String[] { + pythonCommand, + pythonScript, + "--input", + input, + "--output", + output, + "--to-unicode", + toUnicode + }; + } + return new String[] {pythonCommand, pythonScript, "--input", input, "--output", output}; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonImageService.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonImageService.java new file mode 100644 index 0000000000..805ffd9285 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonImageService.java @@ -0,0 +1,474 @@ +package stirling.software.SPDF.service.pdfjson; + +import java.awt.geom.AffineTransform; +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine; +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.contentstream.operator.OperatorName; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.graphics.image.PDImage; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.util.Matrix; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.api.PdfJsonConversionProgress; +import stirling.software.SPDF.model.json.PdfJsonImageElement; + +/** + * Service for handling PDF image operations for JSON conversion (extraction, encoding, rendering). + */ +@Service +@Slf4j +public class PdfJsonImageService { + + private record EncodedImage(String base64, String format) {} + + private record Bounds(float left, float right, float bottom, float top) { + float width() { + return Math.max(0f, right - left); + } + + float height() { + return Math.max(0f, top - bottom); + } + } + + /** + * Collects images from all pages in a PDF document. + * + * @param document The PDF document + * @param totalPages Total number of pages + * @param progress Progress callback + * @return Map of page number to list of image elements + * @throws IOException If image extraction fails + */ + public Map> collectImages( + PDDocument document, int totalPages, Consumer progress) + throws IOException { + Map> imagesByPage = new LinkedHashMap<>(); + Map imageCache = new IdentityHashMap<>(); + int pageNumber = 1; + for (PDPage page : document.getPages()) { + ImageCollectingEngine engine = + new ImageCollectingEngine(page, pageNumber, imagesByPage, imageCache); + engine.processPage(page); + + // Update progress for image extraction (70-80%) + int imageProgress = 70 + (int) ((pageNumber / (double) totalPages) * 10); + progress.accept( + PdfJsonConversionProgress.of( + imageProgress, "images", "Extracting images", pageNumber, totalPages)); + pageNumber++; + } + return imagesByPage; + } + + /** + * Extracts images from a single PDF page (for on-demand lazy loading). + * + * @param document The PDF document + * @param page The specific page to extract images from + * @param pageNumber The page number (1-indexed) + * @return List of image elements for this page + * @throws IOException If image extraction fails + */ + public List extractImagesForPage( + PDDocument document, PDPage page, int pageNumber) throws IOException { + Map> imagesByPage = new LinkedHashMap<>(); + ImageCollectingEngine engine = + new ImageCollectingEngine(page, pageNumber, imagesByPage, new IdentityHashMap<>()); + engine.processPage(page); + return imagesByPage.getOrDefault(pageNumber, new ArrayList<>()); + } + + /** + * Draws an image element on a PDF page content stream. + * + * @param contentStream The content stream to draw on + * @param document The PDF document + * @param element The image element to draw + * @param cache Cache of previously created image XObjects + * @throws IOException If drawing fails + */ + public void drawImageElement( + PDPageContentStream contentStream, + PDDocument document, + PdfJsonImageElement element, + Map cache) + throws IOException { + if (element == null || element.getImageData() == null || element.getImageData().isBlank()) { + return; + } + + String cacheKey = + element.getId() != null && !element.getId().isBlank() + ? element.getId() + : Integer.toHexString(System.identityHashCode(element)); + PDImageXObject image = cache.get(cacheKey); + if (image == null) { + image = createImageXObject(document, element); + if (image == null) { + return; + } + cache.put(cacheKey, image); + } + + List transform = element.getTransform(); + if (transform != null && transform.size() == 6) { + Matrix matrix = + new Matrix( + safeFloat(transform.get(0), 1f), + safeFloat(transform.get(1), 0f), + safeFloat(transform.get(2), 0f), + safeFloat(transform.get(3), 1f), + safeFloat(transform.get(4), 0f), + safeFloat(transform.get(5), 0f)); + contentStream.drawImage(image, matrix); + return; + } + + float width = safeFloat(element.getWidth(), fallbackWidth(element)); + float height = safeFloat(element.getHeight(), fallbackHeight(element)); + if (width <= 0f) { + width = Math.max(1f, fallbackWidth(element)); + } + if (height <= 0f) { + height = Math.max(1f, fallbackHeight(element)); + } + float left = resolveLeft(element, width); + float bottom = resolveBottom(element, height); + + contentStream.drawImage(image, left, bottom, width, height); + } + + /** + * Creates a PDImageXObject from a PdfJsonImageElement. + * + * @param document The PDF document + * @param element The image element with base64 data + * @return The created image XObject + * @throws IOException If image creation fails + */ + public PDImageXObject createImageXObject(PDDocument document, PdfJsonImageElement element) + throws IOException { + byte[] data; + try { + data = Base64.getDecoder().decode(element.getImageData()); + } catch (IllegalArgumentException ex) { + log.debug("Failed to decode image element: {}", ex.getMessage()); + return null; + } + String name = element.getId() != null ? element.getId() : UUID.randomUUID().toString(); + return PDImageXObject.createFromByteArray(document, data, name); + } + + private EncodedImage encodeImage(PDImage image) { + try { + BufferedImage bufferedImage = image.getImage(); + if (bufferedImage == null) { + return null; + } + String format = resolveImageFormat(image); + if (format == null || format.isBlank()) { + format = "png"; + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + boolean written = ImageIO.write(bufferedImage, format, baos); + if (!written) { + if (!"png".equalsIgnoreCase(format)) { + baos.reset(); + if (!ImageIO.write(bufferedImage, "png", baos)) { + return null; + } + format = "png"; + } else { + return null; + } + } + return new EncodedImage(Base64.getEncoder().encodeToString(baos.toByteArray()), format); + } catch (IOException ex) { + log.debug("Failed to encode image: {}", ex.getMessage()); + return null; + } + } + + private String resolveImageFormat(PDImage image) { + if (image instanceof PDImageXObject xObject) { + String suffix = xObject.getSuffix(); + if (suffix != null && !suffix.isBlank()) { + return suffix.toLowerCase(Locale.ROOT); + } + } + return "png"; + } + + private float fallbackWidth(PdfJsonImageElement element) { + if (element.getRight() != null && element.getLeft() != null) { + return Math.max(0f, element.getRight() - element.getLeft()); + } + if (element.getNativeWidth() != null) { + return element.getNativeWidth(); + } + return 1f; + } + + private float fallbackHeight(PdfJsonImageElement element) { + if (element.getTop() != null && element.getBottom() != null) { + return Math.max(0f, element.getTop() - element.getBottom()); + } + if (element.getNativeHeight() != null) { + return element.getNativeHeight(); + } + return 1f; + } + + private float resolveLeft(PdfJsonImageElement element, float width) { + if (element.getLeft() != null) { + return element.getLeft(); + } + if (element.getX() != null) { + return element.getX(); + } + if (element.getRight() != null) { + return element.getRight() - width; + } + return 0f; + } + + private float resolveBottom(PdfJsonImageElement element, float height) { + if (element.getBottom() != null) { + return element.getBottom(); + } + if (element.getY() != null) { + return element.getY(); + } + if (element.getTop() != null) { + return element.getTop() - height; + } + return 0f; + } + + private List toMatrixValues(Matrix matrix) { + List values = new ArrayList<>(6); + values.add(matrix.getValue(0, 0)); + values.add(matrix.getValue(0, 1)); + values.add(matrix.getValue(1, 0)); + values.add(matrix.getValue(1, 1)); + values.add(matrix.getValue(2, 0)); + values.add(matrix.getValue(2, 1)); + return values; + } + + private float safeFloat(Float value, float defaultValue) { + if (value == null || Float.isNaN(value) || Float.isInfinite(value)) { + return defaultValue; + } + return value; + } + + /** + * Inner engine that extends PDFGraphicsStreamEngine to collect images from PDF content streams. + */ + private class ImageCollectingEngine extends PDFGraphicsStreamEngine { + + private final int pageNumber; + private final Map> imagesByPage; + private final Map imageCache; + + private COSName currentXObjectName; + private int imageCounter = 0; + + protected ImageCollectingEngine( + PDPage page, + int pageNumber, + Map> imagesByPage, + Map imageCache) + throws IOException { + super(page); + this.pageNumber = pageNumber; + this.imagesByPage = imagesByPage; + this.imageCache = imageCache; + } + + @Override + public void processPage(PDPage page) throws IOException { + super.processPage(page); + } + + @Override + public void drawImage(PDImage pdImage) throws IOException { + EncodedImage encoded = getOrEncodeImage(pdImage); + if (encoded == null) { + return; + } + Matrix ctm = getGraphicsState().getCurrentTransformationMatrix(); + Bounds bounds = computeBounds(ctm); + List matrixValues = toMatrixValues(ctm); + + PdfJsonImageElement element = + PdfJsonImageElement.builder() + .id(UUID.randomUUID().toString()) + .objectName( + currentXObjectName != null + ? currentXObjectName.getName() + : null) + .inlineImage(!(pdImage instanceof PDImageXObject)) + .nativeWidth(pdImage.getWidth()) + .nativeHeight(pdImage.getHeight()) + .x(bounds.left) + .y(bounds.bottom) + .width(bounds.width()) + .height(bounds.height()) + .left(bounds.left) + .right(bounds.right) + .top(bounds.top) + .bottom(bounds.bottom) + .transform(matrixValues) + .zOrder(-1_000_000 + imageCounter) + .imageData(encoded.base64()) + .imageFormat(encoded.format()) + .build(); + imageCounter++; + imagesByPage.computeIfAbsent(pageNumber, key -> new ArrayList<>()).add(element); + } + + @Override + public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3) + throws IOException { + // Not needed for image extraction + } + + @Override + public void clip(int windingRule) throws IOException { + // Not needed for image extraction + } + + @Override + public void moveTo(float x, float y) throws IOException { + // Not needed for image extraction + } + + @Override + public void lineTo(float x, float y) throws IOException { + // Not needed for image extraction + } + + @Override + public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3) + throws IOException { + // Not needed for image extraction + } + + @Override + public Point2D getCurrentPoint() throws IOException { + return new Point2D.Float(); + } + + @Override + public void closePath() throws IOException { + // Not needed for image extraction + } + + @Override + public void endPath() throws IOException { + // Not needed for image extraction + } + + @Override + public void shadingFill(COSName shadingName) throws IOException { + // Not needed for image extraction + } + + @Override + public void fillAndStrokePath(int windingRule) throws IOException { + // Not needed for image extraction + } + + @Override + public void fillPath(int windingRule) throws IOException { + // Not needed for image extraction + } + + @Override + public void strokePath() throws IOException { + // Not needed for image extraction + } + + @Override + protected void processOperator(Operator operator, List operands) + throws IOException { + if (OperatorName.DRAW_OBJECT.equals(operator.getName()) + && !operands.isEmpty() + && operands.get(0) instanceof COSName name) { + currentXObjectName = name; + } + super.processOperator(operator, operands); + currentXObjectName = null; + } + + private EncodedImage getOrEncodeImage(PDImage pdImage) { + if (pdImage == null) { + return null; + } + if (pdImage instanceof PDImageXObject xObject) { + if (xObject.isStencil()) { + return encodeImage(pdImage); + } + COSBase key = xObject.getCOSObject(); + EncodedImage cached = imageCache.get(key); + if (cached != null) { + return cached; + } + EncodedImage encoded = encodeImage(pdImage); + if (encoded != null) { + imageCache.put(key, encoded); + } + return encoded; + } + return encodeImage(pdImage); + } + + private Bounds computeBounds(Matrix ctm) { + AffineTransform transform = ctm.createAffineTransform(); + Point2D.Float p0 = new Point2D.Float(0, 0); + Point2D.Float p1 = new Point2D.Float(1, 0); + Point2D.Float p2 = new Point2D.Float(0, 1); + Point2D.Float p3 = new Point2D.Float(1, 1); + transform.transform(p0, p0); + transform.transform(p1, p1); + transform.transform(p2, p2); + transform.transform(p3, p3); + + float minX = Math.min(Math.min(p0.x, p1.x), Math.min(p2.x, p3.x)); + float maxX = Math.max(Math.max(p0.x, p1.x), Math.max(p2.x, p3.x)); + float minY = Math.min(Math.min(p0.y, p1.y), Math.min(p2.y, p3.y)); + float maxY = Math.max(Math.max(p0.y, p1.y), Math.max(p2.y, p3.y)); + + if (!Float.isFinite(minX) || !Float.isFinite(minY)) { + return new Bounds(0f, 0f, 0f, 0f); + } + return new Bounds(minX, maxX, minY, maxY); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonMetadataService.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonMetadataService.java new file mode 100644 index 0000000000..8cbffd538e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonMetadataService.java @@ -0,0 +1,148 @@ +package stirling.software.SPDF.service.pdfjson; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Base64; +import java.util.Calendar; +import java.util.Optional; +import java.util.TimeZone; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.common.PDMetadata; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.json.PdfJsonMetadata; + +/** Service for extracting and applying PDF metadata (document info and XMP) for JSON conversion. */ +@Service +@Slf4j +public class PdfJsonMetadataService { + + /** + * Extracts document information metadata from a PDF. + * + * @param document The PDF document + * @return Metadata model with document info + */ + public PdfJsonMetadata extractMetadata(PDDocument document) { + PdfJsonMetadata metadata = new PdfJsonMetadata(); + PDDocumentInformation info = document.getDocumentInformation(); + if (info != null) { + metadata.setTitle(info.getTitle()); + metadata.setAuthor(info.getAuthor()); + metadata.setSubject(info.getSubject()); + metadata.setKeywords(info.getKeywords()); + metadata.setCreator(info.getCreator()); + metadata.setProducer(info.getProducer()); + metadata.setCreationDate(formatCalendar(info.getCreationDate())); + metadata.setModificationDate(formatCalendar(info.getModificationDate())); + metadata.setTrapped(info.getTrapped()); + } + metadata.setNumberOfPages(document.getNumberOfPages()); + return metadata; + } + + /** + * Extracts XMP metadata from a PDF as base64-encoded string. + * + * @param document The PDF document + * @return Base64-encoded XMP metadata, or null if not present + */ + public String extractXmpMetadata(PDDocument document) { + if (document.getDocumentCatalog() == null) { + return null; + } + PDMetadata metadata = document.getDocumentCatalog().getMetadata(); + if (metadata == null) { + return null; + } + try (InputStream inputStream = metadata.createInputStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + inputStream.transferTo(baos); + byte[] data = baos.toByteArray(); + if (data.length == 0) { + return null; + } + return Base64.getEncoder().encodeToString(data); + } catch (IOException ex) { + log.debug("Failed to extract XMP metadata: {}", ex.getMessage()); + return null; + } + } + + /** + * Applies metadata to a PDF document. + * + * @param document The PDF document + * @param metadata The metadata to apply + */ + public void applyMetadata(PDDocument document, PdfJsonMetadata metadata) { + if (metadata == null) { + return; + } + PDDocumentInformation info = document.getDocumentInformation(); + info.setTitle(metadata.getTitle()); + info.setAuthor(metadata.getAuthor()); + info.setSubject(metadata.getSubject()); + info.setKeywords(metadata.getKeywords()); + info.setCreator(metadata.getCreator()); + info.setProducer(metadata.getProducer()); + if (metadata.getCreationDate() != null) { + parseInstant(metadata.getCreationDate()) + .ifPresent(instant -> info.setCreationDate(toCalendar(instant))); + } + if (metadata.getModificationDate() != null) { + parseInstant(metadata.getModificationDate()) + .ifPresent(instant -> info.setModificationDate(toCalendar(instant))); + } + info.setTrapped(metadata.getTrapped()); + } + + /** + * Applies XMP metadata to a PDF document from base64-encoded string. + * + * @param document The PDF document + * @param base64 Base64-encoded XMP metadata + */ + public void applyXmpMetadata(PDDocument document, String base64) { + if (base64 == null || base64.isBlank()) { + return; + } + try (InputStream inputStream = + new ByteArrayInputStream(Base64.getDecoder().decode(base64))) { + PDMetadata metadata = new PDMetadata(document, inputStream); + document.getDocumentCatalog().setMetadata(metadata); + } catch (IllegalArgumentException | IOException ex) { + log.debug("Failed to apply XMP metadata: {}", ex.getMessage()); + } + } + + private String formatCalendar(Calendar calendar) { + if (calendar == null) { + return null; + } + return calendar.toInstant().toString(); + } + + private Optional parseInstant(String value) { + try { + return Optional.of(Instant.parse(value)); + } catch (DateTimeParseException ex) { + log.warn("Failed to parse instant '{}': {}", value, ex.getMessage()); + return Optional.empty(); + } + } + + private Calendar toCalendar(Instant instant) { + Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + calendar.setTimeInMillis(instant.toEpochMilli()); + return calendar; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfLazyLoadingService.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfLazyLoadingService.java new file mode 100644 index 0000000000..cd843ee077 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/PdfLazyLoadingService.java @@ -0,0 +1,308 @@ +package stirling.software.SPDF.service.pdfjson; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.api.PdfJsonConversionProgress; +import stirling.software.SPDF.model.json.PdfJsonAnnotation; +import stirling.software.SPDF.model.json.PdfJsonCosValue; +import stirling.software.SPDF.model.json.PdfJsonDocumentMetadata; +import stirling.software.SPDF.model.json.PdfJsonFont; +import stirling.software.SPDF.model.json.PdfJsonImageElement; +import stirling.software.SPDF.model.json.PdfJsonPage; +import stirling.software.SPDF.model.json.PdfJsonPageDimension; +import stirling.software.SPDF.model.json.PdfJsonStream; +import stirling.software.SPDF.model.json.PdfJsonTextElement; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.TaskManager; +import stirling.software.common.util.ExceptionUtils; + +/** + * Service for lazy loading PDF pages. Caches PDF documents and extracts pages on-demand to reduce + * memory usage for large PDFs. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class PdfLazyLoadingService { + + private final CustomPDFDocumentFactory pdfDocumentFactory; + private final ObjectMapper objectMapper; + private final TaskManager taskManager; + private final PdfJsonMetadataService metadataService; + private final PdfJsonImageService imageService; + + /** Cache for storing PDDocuments for lazy page loading. Key is jobId. */ + private final Map documentCache = new ConcurrentHashMap<>(); + + /** + * Stores PDF file bytes for lazy page loading. Each page is extracted on-demand by re-loading + * the PDF from bytes. + */ + @Data + private static class CachedPdfDocument { + private final byte[] pdfBytes; + private final PdfJsonDocumentMetadata metadata; + private final long timestamp; + + public CachedPdfDocument(byte[] pdfBytes, PdfJsonDocumentMetadata metadata) { + this.pdfBytes = pdfBytes; + this.metadata = metadata; + this.timestamp = System.currentTimeMillis(); + } + } + + /** + * Extracts document metadata, fonts, and page dimensions without page content. Caches the PDF + * bytes for subsequent page requests. + * + * @param file The uploaded PDF file + * @param jobId The job ID for caching + * @param fonts Font map (will be populated) + * @param pageFontResources Page font resources map (will be populated) + * @return Serialized metadata JSON + * @throws IOException If extraction fails + */ + public byte[] extractDocumentMetadata( + MultipartFile file, + String jobId, + Map fonts, + Map> pageFontResources) + throws IOException { + if (file == null) { + throw ExceptionUtils.createNullArgumentException("fileInput"); + } + + Consumer progress = + jobId != null + ? (p) -> { + log.info( + "Progress: [{}%] {} - {}{}", + p.getPercent(), + p.getStage(), + p.getMessage(), + (p.getCurrent() != null && p.getTotal() != null) + ? String.format( + " (%d/%d)", p.getCurrent(), p.getTotal()) + : ""); + reportProgressToTaskManager(jobId, p); + } + : (p) -> {}; + + // Read PDF bytes once for processing and caching + byte[] pdfBytes = file.getBytes(); + + try (PDDocument document = pdfDocumentFactory.load(pdfBytes, true)) { + int totalPages = document.getNumberOfPages(); + + // Build metadata response + progress.accept(PdfJsonConversionProgress.of(90, "metadata", "Extracting metadata")); + PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata(); + docMetadata.setMetadata(metadataService.extractMetadata(document)); + docMetadata.setXmpMetadata(metadataService.extractXmpMetadata(document)); + docMetadata.setLazyImages(Boolean.TRUE); + + List serializedFonts = new ArrayList<>(fonts.values()); + serializedFonts.sort( + Comparator.comparing( + PdfJsonFont::getUid, Comparator.nullsLast(Comparator.naturalOrder()))); + docMetadata.setFonts(serializedFonts); + + // Extract page dimensions + List pageDimensions = new ArrayList<>(); + int pageIndex = 0; + for (PDPage page : document.getPages()) { + PdfJsonPageDimension dim = new PdfJsonPageDimension(); + dim.setPageNumber(pageIndex + 1); + PDRectangle mediaBox = page.getMediaBox(); + dim.setWidth(mediaBox.getWidth()); + dim.setHeight(mediaBox.getHeight()); + dim.setRotation(page.getRotation()); + pageDimensions.add(dim); + pageIndex++; + } + docMetadata.setPageDimensions(pageDimensions); + + // Cache PDF bytes and metadata for lazy page loading + if (jobId != null) { + CachedPdfDocument cached = new CachedPdfDocument(pdfBytes, docMetadata); + documentCache.put(jobId, cached); + log.info( + "Cached PDF bytes ({} bytes) for lazy loading, jobId: {}", + pdfBytes.length, + jobId); + + // Schedule cleanup after 30 minutes + scheduleDocumentCleanup(jobId); + } + + progress.accept( + PdfJsonConversionProgress.of(100, "complete", "Metadata extraction complete")); + + return objectMapper.writeValueAsBytes(docMetadata); + } + } + + /** + * Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. + * + * @param jobId The job ID + * @param pageNumber The page number (1-indexed) + * @param serializeCosValue Function to serialize COS values + * @param extractContentStreams Function to extract content streams + * @param filterImageXObjectsFromResources Function to filter image XObjects + * @param extractText Function to extract text elements for the page + * @param extractAnnotations Function to extract annotations for the page + * @return Serialized page JSON + * @throws IOException If extraction fails + */ + public byte[] extractSinglePage( + String jobId, + int pageNumber, + java.util.function.Function serializeCosValue, + java.util.function.Function> extractContentStreams, + java.util.function.Function filterImageXObjectsFromResources, + java.util.function.BiFunction> + extractText, + java.util.function.BiFunction> + extractAnnotations) + throws IOException { + CachedPdfDocument cached = documentCache.get(jobId); + if (cached == null) { + throw new IllegalArgumentException("No cached document found for jobId: " + jobId); + } + + int pageIndex = pageNumber - 1; + int totalPages = cached.getMetadata().getPageDimensions().size(); + + if (pageIndex < 0 || pageIndex >= totalPages) { + throw new IllegalArgumentException( + "Page number " + pageNumber + " out of range (1-" + totalPages + ")"); + } + + log.debug("Loading PDF from bytes to extract page {} (jobId: {})", pageNumber, jobId); + + // Re-load PDF from cached bytes and extract the single page + try (PDDocument document = pdfDocumentFactory.load(cached.getPdfBytes(), true)) { + PDPage page = document.getPage(pageIndex); + PdfJsonPage pageModel = new PdfJsonPage(); + pageModel.setPageNumber(pageNumber); + PDRectangle mediaBox = page.getMediaBox(); + pageModel.setWidth(mediaBox.getWidth()); + pageModel.setHeight(mediaBox.getHeight()); + pageModel.setRotation(page.getRotation()); + + // Extract text on-demand + pageModel.setTextElements(extractText.apply(document, pageNumber)); + + // Extract annotations on-demand + pageModel.setAnnotations(extractAnnotations.apply(document, pageNumber)); + + // Extract images on-demand + List images = + imageService.extractImagesForPage(document, page, pageNumber); + pageModel.setImageElements(images); + + // Extract resources and content streams + COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES); + COSBase filteredResources = filterImageXObjectsFromResources.apply(resourcesBase); + pageModel.setResources(serializeCosValue.apply(filteredResources)); + pageModel.setContentStreams(extractContentStreams.apply(page)); + + log.debug( + "Extracted page {} (text: {}, images: {}, annotations: {}) for jobId: {}", + pageNumber, + pageModel.getTextElements().size(), + images.size(), + pageModel.getAnnotations().size(), + jobId); + + return objectMapper.writeValueAsBytes(pageModel); + } + } + + /** Clears a cached document. */ + public void clearCachedDocument(String jobId) { + CachedPdfDocument cached = documentCache.remove(jobId); + if (cached != null) { + log.info( + "Removed cached PDF bytes ({} bytes) for jobId: {}", + cached.getPdfBytes().length, + jobId); + } + } + + /** Schedules automatic cleanup of cached documents after 30 minutes. */ + private void scheduleDocumentCleanup(String jobId) { + new Thread( + () -> { + try { + Thread.sleep(TimeUnit.MINUTES.toMillis(30)); + clearCachedDocument(jobId); + log.info("Auto-cleaned cached document for jobId: {}", jobId); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }) + .start(); + } + + /** + * Report progress to TaskManager for async jobs + * + * @param jobId The job ID + * @param progress The progress update + */ + private void reportProgressToTaskManager(String jobId, PdfJsonConversionProgress progress) { + try { + log.info( + "Reporting progress for job {}: {}% - {}", + jobId, progress.getPercent(), progress.getStage()); + String note; + if (progress.getCurrent() != null && progress.getTotal() != null) { + note = + String.format( + "[%d%%] %s: %s (%d/%d)", + progress.getPercent(), + progress.getStage(), + progress.getMessage(), + progress.getCurrent(), + progress.getTotal()); + } else { + note = + String.format( + "[%d%%] %s: %s", + progress.getPercent(), progress.getStage(), progress.getMessage()); + } + boolean added = taskManager.addNote(jobId, note); + if (!added) { + log.warn("Failed to add note - job {} not found in TaskManager", jobId); + } else { + log.info("Successfully added progress note for job {}: {}", jobId, note); + } + } catch (Exception e) { + log.error("Exception reporting progress for job {}: {}", jobId, e.getMessage(), e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3ConversionRequest.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3ConversionRequest.java new file mode 100644 index 0000000000..7a811f8156 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3ConversionRequest.java @@ -0,0 +1,17 @@ +package stirling.software.SPDF.service.pdfjson.type3; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.font.PDType3Font; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class Type3ConversionRequest { + private final PDDocument document; + private final PDType3Font font; + private final String fontId; + private final int pageNumber; + private final String fontUid; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3ConversionStrategy.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3ConversionStrategy.java new file mode 100644 index 0000000000..9f62dea7e5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3ConversionStrategy.java @@ -0,0 +1,32 @@ +package stirling.software.SPDF.service.pdfjson.type3; + +import java.io.IOException; + +import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate; + +public interface Type3ConversionStrategy { + + /** Unique identifier used when reporting results. */ + String getId(); + + /** Human-readable label for UI toggles or logs. */ + String getLabel(); + + /** True when the underlying tooling is usable on this host. */ + boolean isAvailable(); + + /** Quick predicate to avoid running on unsupported Type3 shapes. */ + default boolean supports(Type3ConversionRequest request, Type3GlyphContext context) + throws IOException { + return request != null && request.getFont() != null; + } + + /** + * Attempt to synthesise a font program for the supplied Type3 font. + * + * @param request contextual information for the conversion attempt + * @return a candidate describing the outcome, never {@code null} + */ + PdfJsonFontConversionCandidate convert( + Type3ConversionRequest request, Type3GlyphContext context) throws IOException; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3FontConversionService.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3FontConversionService.java new file mode 100644 index 0000000000..a77c36ba5e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3FontConversionService.java @@ -0,0 +1,133 @@ +package stirling.software.SPDF.service.pdfjson.type3; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate; +import stirling.software.SPDF.model.json.PdfJsonFontConversionStatus; + +@Slf4j +@Service +@RequiredArgsConstructor +public class Type3FontConversionService { + + private final List strategies; + private final Type3GlyphExtractor glyphExtractor; + + public List synthesize(Type3ConversionRequest request) { + if (request == null || request.getFont() == null) { + return Collections.emptyList(); + } + if (strategies == null || strategies.isEmpty()) { + log.debug( + "[TYPE3] No conversion strategies registered for font {}", request.getFontId()); + return Collections.emptyList(); + } + + List candidates = new ArrayList<>(); + Type3GlyphContext glyphContext = null; + for (Type3ConversionStrategy strategy : strategies) { + if (strategy == null) { + continue; + } + PdfJsonFontConversionCandidate candidate = + runStrategy( + strategy, + request, + glyphContext == null + ? (glyphContext = + new Type3GlyphContext(request, glyphExtractor)) + : glyphContext); + if (candidate != null) { + candidates.add(candidate); + } + } + return candidates; + } + + private PdfJsonFontConversionCandidate runStrategy( + Type3ConversionStrategy strategy, + Type3ConversionRequest request, + Type3GlyphContext glyphContext) { + if (!strategy.isAvailable()) { + return PdfJsonFontConversionCandidate.builder() + .strategyId(strategy.getId()) + .strategyLabel(strategy.getLabel()) + .status(PdfJsonFontConversionStatus.SKIPPED) + .message("Strategy unavailable on current host") + .build(); + } + try { + if (!strategy.supports(request, glyphContext)) { + return PdfJsonFontConversionCandidate.builder() + .strategyId(strategy.getId()) + .strategyLabel(strategy.getLabel()) + .status(PdfJsonFontConversionStatus.UNSUPPORTED) + .message("Font not supported by strategy") + .build(); + } + } catch (IOException supportCheckException) { + log.warn( + "[TYPE3] Strategy {} support check failed for font {}: {}", + strategy.getId(), + request.getFontUid(), + supportCheckException.getMessage(), + supportCheckException); + return PdfJsonFontConversionCandidate.builder() + .strategyId(strategy.getId()) + .strategyLabel(strategy.getLabel()) + .status(PdfJsonFontConversionStatus.UNSUPPORTED) + .message("Support check failed: " + supportCheckException.getMessage()) + .build(); + } + + try { + PdfJsonFontConversionCandidate result = strategy.convert(request, glyphContext); + if (result == null) { + log.info( + "[TYPE3] Strategy {} returned null result for font {}", + strategy.getId(), + request.getFontUid()); + return PdfJsonFontConversionCandidate.builder() + .strategyId(strategy.getId()) + .strategyLabel(strategy.getLabel()) + .status(PdfJsonFontConversionStatus.FAILURE) + .message("Strategy returned null result") + .build(); + } + if (result.getStrategyId() == null) { + result.setStrategyId(strategy.getId()); + } + if (result.getStrategyLabel() == null) { + result.setStrategyLabel(strategy.getLabel()); + } + log.debug( + "[TYPE3] Strategy {} finished with status {} (message: {}) for font {}", + strategy.getId(), + result.getStatus(), + result.getMessage(), + request.getFontUid()); + return result; + } catch (IOException ex) { + log.warn( + "[TYPE3] Strategy {} failed for font {}: {}", + strategy.getId(), + request.getFontUid(), + ex.getMessage(), + ex); + return PdfJsonFontConversionCandidate.builder() + .strategyId(strategy.getId()) + .strategyLabel(strategy.getLabel()) + .status(PdfJsonFontConversionStatus.FAILURE) + .message(ex.getMessage()) + .build(); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3FontSignatureCalculator.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3FontSignatureCalculator.java new file mode 100644 index 0000000000..23634a7a3d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3FontSignatureCalculator.java @@ -0,0 +1,218 @@ +package stirling.software.SPDF.service.pdfjson.type3; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSNumber; +import org.apache.pdfbox.cos.COSStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType3CharProc; +import org.apache.pdfbox.pdmodel.font.PDType3Font; +import org.apache.pdfbox.pdmodel.font.encoding.Encoding; +import org.apache.pdfbox.util.Matrix; + +/** + * Computes a reproducible hash for Type3 fonts so we can match them against a pre-built library of + * converted programs. The signature intentionally combines multiple aspects of the font (encoding, + * CharProc streams, glyph widths, font metrics) to minimise collisions between unrelated fonts that + * coincidentally share glyph names. + */ +public final class Type3FontSignatureCalculator { + + private Type3FontSignatureCalculator() {} + + public static String computeSignature(PDType3Font font) throws IOException { + if (font == null) { + return null; + } + MessageDigest digest = newDigest(); + updateMatrix(digest, font.getFontMatrix()); + updateRectangle(digest, font.getFontBBox()); + updateEncoding(digest, font.getEncoding()); + updateCharProcs(digest, font); + byte[] hash = digest.digest(); + return "sha256:" + toHex(hash); + } + + private static void updateEncoding(MessageDigest digest, Encoding encoding) { + if (encoding == null) { + updateInt(digest, -1); + return; + } + for (int code = 0; code <= 0xFF; code++) { + String name = encoding.getName(code); + if (name != null) { + updateInt(digest, code); + updateString(digest, name); + } + } + } + + private static void updateCharProcs(MessageDigest digest, PDType3Font font) throws IOException { + COSDictionary charProcs = + (COSDictionary) font.getCOSObject().getDictionaryObject(COSName.CHAR_PROCS); + if (charProcs == null || charProcs.size() == 0) { + updateInt(digest, 0); + return; + } + List glyphNames = new ArrayList<>(charProcs.keySet()); + glyphNames.sort(Comparator.comparing(COSName::getName, String.CASE_INSENSITIVE_ORDER)); + for (COSName glyphName : glyphNames) { + updateString(digest, glyphName.getName()); + int code = resolveCharCode(font, glyphName.getName()); + updateInt(digest, code); + if (code >= 0) { + try { + updateFloat(digest, font.getWidthFromFont(code)); + } catch (IOException ignored) { + updateFloat(digest, 0f); + } + } else { + updateFloat(digest, 0f); + } + + COSStream stream = + charProcs.getDictionaryObject(glyphName) instanceof COSStream cosStream + ? cosStream + : null; + if (stream != null) { + byte[] payload = readAllBytes(stream); + updateInt(digest, payload.length); + digest.update(payload); + PDType3CharProc charProc = new PDType3CharProc(font, stream); + updateRectangle(digest, extractGlyphBoundingBox(font, charProc)); + } else { + updateInt(digest, -1); + } + } + updateInt(digest, glyphNames.size()); + } + + private static byte[] readAllBytes(COSStream stream) throws IOException { + try (InputStream inputStream = stream.createInputStream()) { + return inputStream.readAllBytes(); + } + } + + private static COSArray extractGlyphBoundingBox(PDType3Font font, PDType3CharProc charProc) { + if (charProc == null) { + return null; + } + COSStream stream = charProc.getCOSObject(); + if (stream != null) { + COSArray bboxArray = (COSArray) stream.getDictionaryObject(COSName.BBOX); + if (bboxArray != null && bboxArray.size() == 4) { + return bboxArray; + } + } + return font.getCOSObject().getCOSArray(COSName.BBOX); + } + + private static int resolveCharCode(PDType3Font font, String glyphName) { + if (glyphName == null || font.getEncoding() == null) { + return -1; + } + Encoding encoding = font.getEncoding(); + for (int code = 0; code <= 0xFF; code++) { + String name = encoding.getName(code); + if (glyphName.equals(name)) { + return code; + } + } + return -1; + } + + private static void updateMatrix(MessageDigest digest, Matrix matrix) { + if (matrix == null) { + updateInt(digest, -1); + return; + } + float[][] values = matrix.getValues(); + updateInt(digest, values.length); + for (float[] row : values) { + if (row == null) { + updateInt(digest, -1); + continue; + } + updateInt(digest, row.length); + for (float value : row) { + updateFloat(digest, value); + } + } + } + + private static void updateRectangle(MessageDigest digest, PDRectangle rectangle) { + if (rectangle == null) { + updateInt(digest, -1); + return; + } + updateFloat(digest, rectangle.getLowerLeftX()); + updateFloat(digest, rectangle.getLowerLeftY()); + updateFloat(digest, rectangle.getUpperRightX()); + updateFloat(digest, rectangle.getUpperRightY()); + } + + private static void updateRectangle(MessageDigest digest, COSArray array) { + if (array == null) { + updateInt(digest, -1); + return; + } + updateInt(digest, array.size()); + for (int i = 0; i < array.size(); i++) { + COSBase value = array.getObject(i); + if (value instanceof COSNumber number) { + updateFloat(digest, number.floatValue()); + } else { + updateFloat(digest, 0f); + } + } + } + + private static void updateString(MessageDigest digest, String value) { + if (value == null) { + updateInt(digest, -1); + return; + } + byte[] bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + updateInt(digest, bytes.length); + digest.update(bytes); + } + + private static void updateInt(MessageDigest digest, int value) { + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array()); + } + + private static void updateFloat(MessageDigest digest, float value) { + if (Float.isNaN(value) || Float.isInfinite(value)) { + value = 0f; + } + digest.update(ByteBuffer.allocate(Float.BYTES).putFloat(value).array()); + } + + private static MessageDigest newDigest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("Missing SHA-256 MessageDigest", ex); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + builder.append(String.format(Locale.ROOT, "%02x", Byte.toUnsignedInt(value))); + } + return builder.toString(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3GlyphContext.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3GlyphContext.java new file mode 100644 index 0000000000..082813cc36 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3GlyphContext.java @@ -0,0 +1,38 @@ +package stirling.software.SPDF.service.pdfjson.type3; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.pdfbox.pdmodel.font.PDType3Font; + +import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline; + +class Type3GlyphContext { + private final Type3ConversionRequest request; + private final Type3GlyphExtractor extractor; + private final AtomicReference> glyphs = new AtomicReference<>(); + + Type3GlyphContext(Type3ConversionRequest request, Type3GlyphExtractor extractor) { + this.request = request; + this.extractor = extractor; + } + + public List getGlyphs() throws IOException { + List cached = glyphs.get(); + if (cached == null) { + cached = + extractor.extractGlyphs( + request.getDocument(), + request.getFont(), + request.getFontId(), + request.getPageNumber()); + glyphs.compareAndSet(null, cached); + } + return cached; + } + + public PDType3Font getFont() { + return request.getFont(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3GlyphExtractor.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3GlyphExtractor.java new file mode 100644 index 0000000000..b662f5683f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3GlyphExtractor.java @@ -0,0 +1,126 @@ +package stirling.software.SPDF.service.pdfjson.type3; + +import java.awt.geom.GeneralPath; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSStream; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType3CharProc; +import org.apache.pdfbox.pdmodel.font.PDType3Font; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline; + +@Slf4j +@Component +public class Type3GlyphExtractor { + + public List extractGlyphs( + PDDocument document, PDType3Font font, String fontId, int pageNumber) + throws IOException { + Objects.requireNonNull(font, "font"); + COSDictionary charProcs = + (COSDictionary) font.getCOSObject().getDictionaryObject(COSName.CHAR_PROCS); + if (charProcs == null || charProcs.size() == 0) { + return List.of(); + } + List outlines = new ArrayList<>(); + for (COSName glyphName : charProcs.keySet()) { + COSStream stream = + charProcs.getDictionaryObject(glyphName) instanceof COSStream cosStream + ? cosStream + : null; + if (stream == null) { + continue; + } + PDType3CharProc charProc = new PDType3CharProc(font, stream); + outlines.add(analyseGlyph(document, font, glyphName, charProc, fontId, pageNumber)); + } + return outlines; + } + + private Type3GlyphOutline analyseGlyph( + PDDocument document, + PDType3Font font, + COSName glyphName, + PDType3CharProc charProc, + String fontId, + int pageNumber) + throws IOException { + int code = resolveCharCode(font, glyphName.getName()); + float advanceWidth = 0f; + if (code >= 0) { + advanceWidth = font.getWidthFromFont(code); + } + + PDRectangle glyphBBox = extractGlyphBoundingBox(font, charProc); + PDRectangle bbox = font.getFontBBox(); + GlyphGraphicsExtractor extractor = + new GlyphGraphicsExtractor(new PDPage(bbox != null ? bbox : new PDRectangle())); + extractor.process(charProc); + GeneralPath outline = extractor.getAccumulatedPath(); + Integer unicodeValue = null; + if (code >= 0) { + String unicode = font.toUnicode(code); + if (unicode != null && !unicode.isEmpty()) { + unicodeValue = unicode.codePointAt(0); + } else { + unicodeValue = code; + } + } + return Type3GlyphOutline.builder() + .glyphName(glyphName.getName()) + .charCode(code) + .advanceWidth(advanceWidth) + .boundingBox(glyphBBox) + .outline(outline) + .hasFill(extractor.isSawFill()) + .hasStroke(extractor.isSawStroke()) + .hasImages(extractor.isSawImage()) + .hasText(extractor.isSawText()) + .hasShading(extractor.isSawShading()) + .warnings(extractor.getWarnings()) + .unicode(unicodeValue) + .build(); + } + + private PDRectangle extractGlyphBoundingBox(PDType3Font font, PDType3CharProc charProc) { + COSStream stream = charProc != null ? charProc.getCOSObject() : null; + if (stream != null) { + COSArray bboxArray = (COSArray) stream.getDictionaryObject(COSName.BBOX); + if (bboxArray != null && bboxArray.size() == 4) { + return new PDRectangle(bboxArray); + } + } + return font.getFontBBox(); + } + + private int resolveCharCode(PDType3Font font, String glyphName) { + if (glyphName == null || font.getEncoding() == null) { + return -1; + } + for (int code = 0; code <= 0xFF; code++) { + String name = font.getEncoding().getName(code); + if (glyphName.equals(name)) { + return code; + } + } + return -1; + } + + private static final class GlyphGraphicsExtractor extends Type3GraphicsEngine { + GlyphGraphicsExtractor(PDPage page) { + super(page); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3GraphicsEngine.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3GraphicsEngine.java new file mode 100644 index 0000000000..d4e3cd6e95 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3GraphicsEngine.java @@ -0,0 +1,164 @@ +package stirling.software.SPDF.service.pdfjson.type3; + +import java.awt.geom.GeneralPath; +import java.awt.geom.Point2D; +import java.io.IOException; + +import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine; +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType3CharProc; +import org.apache.pdfbox.pdmodel.graphics.image.PDImage; +import org.apache.pdfbox.util.Matrix; +import org.apache.pdfbox.util.Vector; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class Type3GraphicsEngine extends PDFGraphicsStreamEngine { + + private final GeneralPath accumulatedPath = new GeneralPath(); + private final GeneralPath linePath = new GeneralPath(); + private final Point2D.Float currentPoint = new Point2D.Float(); + private boolean hasCurrentPoint; + @Getter private boolean sawStroke; + @Getter private boolean sawFill; + @Getter private boolean sawImage; + @Getter private boolean sawText; + @Getter private boolean sawShading; + @Getter private String warnings; + + protected Type3GraphicsEngine(PDPage page) { + super(page); + } + + public GeneralPath getAccumulatedPath() { + return (GeneralPath) accumulatedPath.clone(); + } + + public void process(PDType3CharProc charProc) throws IOException { + accumulatedPath.reset(); + linePath.reset(); + sawStroke = false; + sawFill = false; + sawImage = false; + sawText = false; + sawShading = false; + warnings = null; + if (charProc != null) { + processChildStream(charProc, getPage()); + } + } + + @Override + public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3) throws IOException { + moveTo((float) p0.getX(), (float) p0.getY()); + lineTo((float) p1.getX(), (float) p1.getY()); + lineTo((float) p2.getX(), (float) p2.getY()); + lineTo((float) p3.getX(), (float) p3.getY()); + closePath(); + } + + @Override + public void drawImage(PDImage pdImage) throws IOException { + sawImage = true; + } + + @Override + public void shadingFill(COSName shadingName) throws IOException { + sawShading = true; + } + + @Override + public void strokePath() throws IOException { + accumulatedPath.append(linePath, false); + linePath.reset(); + sawStroke = true; + } + + @Override + public void fillPath(int windingRule) throws IOException { + linePath.setWindingRule( + windingRule == 0 ? GeneralPath.WIND_EVEN_ODD : GeneralPath.WIND_NON_ZERO); + accumulatedPath.append(linePath, false); + linePath.reset(); + sawFill = true; + } + + @Override + public void fillAndStrokePath(int windingRule) throws IOException { + fillPath(windingRule); + sawStroke = true; + } + + @Override + public void clip(int windingRule) throws IOException { + // ignore + } + + @Override + public void moveTo(float x, float y) throws IOException { + linePath.moveTo(x, y); + currentPoint.setLocation(x, y); + hasCurrentPoint = true; + } + + @Override + public void lineTo(float x, float y) throws IOException { + linePath.lineTo(x, y); + currentPoint.setLocation(x, y); + hasCurrentPoint = true; + } + + @Override + public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3) + throws IOException { + linePath.curveTo(x1, y1, x2, y2, x3, y3); + currentPoint.setLocation(x3, y3); + hasCurrentPoint = true; + } + + @Override + public Point2D getCurrentPoint() throws IOException { + return hasCurrentPoint ? (Point2D) currentPoint.clone() : null; + } + + @Override + public void closePath() throws IOException { + linePath.closePath(); + } + + @Override + public void endPath() throws IOException { + linePath.reset(); + hasCurrentPoint = false; + } + + @Override + protected void showText(byte[] string) throws IOException { + sawText = true; + super.showText(string); + } + + @Override + protected void showFontGlyph( + Matrix textRenderingMatrix, PDFont font, int code, Vector displacement) + throws IOException { + sawText = true; + super.showFontGlyph(textRenderingMatrix, font, code, displacement); + } + + @Override + protected void processOperator( + Operator operator, java.util.List operands) + throws IOException { + if ("cm".equals(operator.getName())) { + warnings = + warnings == null ? "Encountered CTM concatenation" : warnings + "; CTM concat"; + } + super.processOperator(operator, operands); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java new file mode 100644 index 0000000000..4385e57251 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java @@ -0,0 +1,113 @@ +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; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate; +import stirling.software.SPDF.model.json.PdfJsonFontConversionStatus; +import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibrary; +import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryEntry; +import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryMatch; +import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayload; + +@Slf4j +@Component +@Order(0) +@RequiredArgsConstructor +public class Type3LibraryStrategy implements Type3ConversionStrategy { + + private final Type3FontLibrary fontLibrary; + + @Value("${stirling.pdf.json.type3.library.enabled:true}") + private boolean enabled; + + @Override + public String getId() { + return "type3-library"; + } + + @Override + public String getLabel() { + return "Type3 Font Library"; + } + + @Override + public boolean isAvailable() { + return enabled && fontLibrary != null && fontLibrary.isLoaded(); + } + + @Override + public PdfJsonFontConversionCandidate convert( + Type3ConversionRequest request, Type3GlyphContext context) throws IOException { + if (request == null || request.getFont() == null) { + return PdfJsonFontConversionCandidate.builder() + .strategyId(getId()) + .strategyLabel(getLabel()) + .status(PdfJsonFontConversionStatus.FAILURE) + .message("No font supplied") + .build(); + } + if (!isAvailable()) { + return PdfJsonFontConversionCandidate.builder() + .strategyId(getId()) + .strategyLabel(getLabel()) + .status(PdfJsonFontConversionStatus.SKIPPED) + .message("Library disabled") + .build(); + } + + Type3FontLibraryMatch match = fontLibrary.match(request.getFont(), request.getFontUid()); + if (match == null || match.getEntry() == null) { + return PdfJsonFontConversionCandidate.builder() + .strategyId(getId()) + .strategyLabel(getLabel()) + .status(PdfJsonFontConversionStatus.UNSUPPORTED) + .message("No library entry found") + .build(); + } + + Type3FontLibraryEntry entry = match.getEntry(); + if (!entry.hasAnyPayload()) { + return PdfJsonFontConversionCandidate.builder() + .strategyId(getId()) + .strategyLabel(getLabel()) + .status(PdfJsonFontConversionStatus.FAILURE) + .message("Library entry has no payloads") + .build(); + } + + String message = + String.format( + "Matched %s via %s", + entry.getLabel(), + match.getMatchType() != null ? match.getMatchType() : "alias"); + + return PdfJsonFontConversionCandidate.builder() + .strategyId(getId()) + .strategyLabel(getLabel()) + .status(PdfJsonFontConversionStatus.SUCCESS) + .program(toBase64(entry.getProgram())) + .programFormat(toFormat(entry.getProgram())) + .webProgram(toBase64(entry.getWebProgram())) + .webProgramFormat(toFormat(entry.getWebProgram())) + .pdfProgram(toBase64(entry.getPdfProgram())) + .pdfProgramFormat(toFormat(entry.getPdfProgram())) + .glyphCoverage(entry.getGlyphCoverage()) + .message(message) + .build(); + } + + private String toBase64(Type3FontLibraryPayload payload) { + return payload != null ? payload.getBase64() : null; + } + + private String toFormat(Type3FontLibraryPayload payload) { + return payload != null ? payload.getFormat() : null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java new file mode 100644 index 0000000000..32a6abec21 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java @@ -0,0 +1,299 @@ +package stirling.software.SPDF.service.pdfjson.type3.library; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +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; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator; + +@Slf4j +@Component +@RequiredArgsConstructor +public class Type3FontLibrary { + + private final ObjectMapper objectMapper; + private final ResourceLoader resourceLoader; + + @Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}") + private String indexLocation; + + private final Map signatureIndex = new ConcurrentHashMap<>(); + private final Map aliasIndex = new ConcurrentHashMap<>(); + private List entries = List.of(); + + @jakarta.annotation.PostConstruct + void initialise() { + Resource resource = resourceLoader.getResource(indexLocation); + if (!resource.exists()) { + log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation); + entries = List.of(); + return; + } + try (InputStream inputStream = resource.getInputStream()) { + List rawEntries = + objectMapper.readValue(inputStream, new TypeReference>() {}); + List loaded = new ArrayList<>(); + for (RawEntry rawEntry : rawEntries) { + Type3FontLibraryEntry entry = toEntry(rawEntry); + if (entry != null && entry.hasAnyPayload()) { + loaded.add(entry); + } + } + entries = Collections.unmodifiableList(loaded); + signatureIndex.clear(); + aliasIndex.clear(); + + for (Type3FontLibraryEntry entry : entries) { + if (entry.getSignatures() != null) { + for (String signature : entry.getSignatures()) { + if (signature == null) { + continue; + } + String key = signature.toLowerCase(Locale.ROOT); + signatureIndex.putIfAbsent(key, entry); + } + } + if (entry.getAliases() != null) { + for (String alias : entry.getAliases()) { + String normalized = normalizeAlias(alias); + if (normalized != null) { + aliasIndex.putIfAbsent(normalized, entry); + } + } + } + } + log.info( + "[TYPE3] Loaded {} Type3 library entries (signatures={}, aliases={}) from {}", + entries.size(), + signatureIndex.size(), + aliasIndex.size(), + indexLocation); + } catch (IOException ex) { + log.warn( + "[TYPE3] Failed to load Type3 library index {}: {}", + indexLocation, + ex.getMessage(), + ex); + entries = List.of(); + signatureIndex.clear(); + aliasIndex.clear(); + } + } + + public boolean isLoaded() { + return !entries.isEmpty(); + } + + public Type3FontLibraryMatch match(PDType3Font font, String fontUid) throws IOException { + if (font == null || entries.isEmpty()) { + return null; + } + String signature = Type3FontSignatureCalculator.computeSignature(font); + if (signature != null) { + Type3FontLibraryEntry entry = signatureIndex.get(signature.toLowerCase(Locale.ROOT)); + if (entry != null) { + log.debug( + "[TYPE3] Matched Type3 font {} to library entry {} via signature {}", + fontUid, + entry.getId(), + signature); + return Type3FontLibraryMatch.builder() + .entry(entry) + .matchType("signature") + .signature(signature) + .build(); + } + log.debug( + "[TYPE3] No library entry for signature {} (font {})", + signature, + fontUid != null ? fontUid : font.getName()); + } + + String aliasKey = normalizeAlias(resolveBaseFontName(font)); + if (aliasKey != null) { + Type3FontLibraryEntry entry = aliasIndex.get(aliasKey); + if (entry != null) { + log.debug( + "[TYPE3] Matched Type3 font {} to library entry {} via alias {}", + fontUid, + entry.getId(), + aliasKey); + return Type3FontLibraryMatch.builder() + .entry(entry) + .matchType("alias:" + aliasKey) + .signature(signature) + .build(); + } + } + + if (signature != null) { + log.debug( + "[TYPE3] Library had no alias match for signature {} (font {})", + signature, + fontUid != null ? fontUid : font.getName()); + } + return null; + } + + private Type3FontLibraryEntry toEntry(RawEntry rawEntry) { + if (rawEntry == null || rawEntry.id == null) { + return null; + } + try { + Type3FontLibraryEntry.Type3FontLibraryEntryBuilder builder = + Type3FontLibraryEntry.builder() + .id(rawEntry.id) + .label(rawEntry.label != null ? rawEntry.label : rawEntry.id) + .signatures(normalizeList(rawEntry.signatures)) + .aliases(normalizeList(rawEntry.aliases)) + .program(loadPayload(rawEntry.program)) + .webProgram(loadPayload(rawEntry.webProgram)) + .pdfProgram(loadPayload(rawEntry.pdfProgram)) + .source(rawEntry.source); + if (rawEntry.glyphCoverage != null && !rawEntry.glyphCoverage.isEmpty()) { + for (Integer codePoint : rawEntry.glyphCoverage) { + if (codePoint != null) { + builder.glyphCode(codePoint); + } + } + } + return builder.build(); + } catch (IOException ex) { + log.warn( + "[TYPE3] Failed to load Type3 library entry {}: {}", + rawEntry.id, + ex.getMessage()); + return null; + } + } + + private Type3FontLibraryPayload loadPayload(RawPayload payload) throws IOException { + if (payload == null) { + return null; + } + byte[] data = null; + if (payload.base64 != null && !payload.base64.isBlank()) { + try { + data = Base64.getDecoder().decode(payload.base64); + } catch (IllegalArgumentException ex) { + log.warn("[TYPE3] Invalid base64 payload in Type3 library: {}", ex.getMessage()); + } + } else if (payload.resource != null && !payload.resource.isBlank()) { + data = loadResourceBytes(payload.resource); + } + if (data == null || data.length == 0) { + return null; + } + String base64 = Base64.getEncoder().encodeToString(data); + return new Type3FontLibraryPayload(base64, normalizeFormat(payload.format)); + } + + private byte[] loadResourceBytes(String location) throws IOException { + String resolved = resolveLocation(location); + Resource resource = resourceLoader.getResource(resolved); + if (!resource.exists()) { + throw new IOException("Resource not found: " + resolved); + } + try (InputStream inputStream = resource.getInputStream()) { + return inputStream.readAllBytes(); + } + } + + private String resolveLocation(String location) { + if (location == null || location.isBlank()) { + return location; + } + if (location.contains(":")) { + return location; + } + if (location.startsWith("/")) { + return "classpath:" + location; + } + return "classpath:/" + location; + } + + private List normalizeList(List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + return values.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + + private String normalizeAlias(String alias) { + if (alias == null) { + return null; + } + String value = alias.trim(); + int plus = value.indexOf('+'); + if (plus >= 0 && plus < value.length() - 1) { + value = value.substring(plus + 1); + } + return value.isEmpty() ? null : value.toLowerCase(Locale.ROOT); + } + + private String normalizeFormat(String format) { + if (format == null) { + return null; + } + return format.trim().toLowerCase(Locale.ROOT); + } + + private String resolveBaseFontName(PDType3Font font) { + if (font == null) { + return null; + } + String baseName = null; + try { + baseName = font.getName(); + } catch (Exception ignored) { + // Some Type3 fonts throw when resolving names; fall back to COS dictionary. + } + if (baseName == null && font.getCOSObject() != null) { + baseName = font.getCOSObject().getNameAsString(COSName.BASE_FONT); + } + return baseName; + } + + private static final class RawEntry { + public String id; + public String label; + public List signatures; + public List aliases; + public RawPayload program; + public RawPayload webProgram; + public RawPayload pdfProgram; + public List glyphCoverage; + public String source; + } + + private static final class RawPayload { + public String resource; + public String format; + public String base64; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryEntry.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryEntry.java new file mode 100644 index 0000000000..6163998cf6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryEntry.java @@ -0,0 +1,30 @@ +package stirling.software.SPDF.service.pdfjson.type3.library; + +import java.util.List; + +import lombok.Builder; +import lombok.Singular; +import lombok.Value; + +@Value +@Builder +public class Type3FontLibraryEntry { + String id; + String label; + @Singular List signatures; + @Singular List aliases; + Type3FontLibraryPayload program; + Type3FontLibraryPayload webProgram; + Type3FontLibraryPayload pdfProgram; + + @Singular("glyphCode") + List glyphCoverage; + + String source; + + public boolean hasAnyPayload() { + return (program != null && program.hasPayload()) + || (webProgram != null && webProgram.hasPayload()) + || (pdfProgram != null && pdfProgram.hasPayload()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryMatch.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryMatch.java new file mode 100644 index 0000000000..9f1255b2c5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryMatch.java @@ -0,0 +1,12 @@ +package stirling.software.SPDF.service.pdfjson.type3.library; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class Type3FontLibraryMatch { + Type3FontLibraryEntry entry; + String matchType; + String signature; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryPayload.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryPayload.java new file mode 100644 index 0000000000..45f7a3d99d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryPayload.java @@ -0,0 +1,13 @@ +package stirling.software.SPDF.service.pdfjson.type3.library; + +import lombok.Value; + +@Value +public class Type3FontLibraryPayload { + String base64; + String format; + + public boolean hasPayload() { + return base64 != null && !base64.isBlank(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/model/Type3GlyphOutline.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/model/Type3GlyphOutline.java new file mode 100644 index 0000000000..32bbe4f836 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/model/Type3GlyphOutline.java @@ -0,0 +1,25 @@ +package stirling.software.SPDF.service.pdfjson.type3.model; + +import java.awt.geom.GeneralPath; + +import org.apache.pdfbox.pdmodel.common.PDRectangle; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class Type3GlyphOutline { + String glyphName; + int charCode; + float advanceWidth; + PDRectangle boundingBox; + GeneralPath outline; + boolean hasStroke; + boolean hasFill; + boolean hasImages; + boolean hasText; + boolean hasShading; + String warnings; + Integer unicode; +} diff --git a/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/tool/Type3SignatureTool.java b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/tool/Type3SignatureTool.java new file mode 100644 index 0000000000..9687174119 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/SPDF/service/pdfjson/type3/tool/Type3SignatureTool.java @@ -0,0 +1,299 @@ +package stirling.software.SPDF.service.pdfjson.type3.tool; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType3Font; +import org.apache.pdfbox.pdmodel.graphics.PDXObject; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializationFeature; + +import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator; +import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor; +import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline; + +/** + * Small CLI helper that scans a PDF for Type3 fonts, computes their signatures, and optionally + * emits JSON describing the glyph coverage. This allows Type3 library entries to be added without + * digging through backend logs. + * + *

Usage: + * + *

+ * ./gradlew :proprietary:type3SignatureTool --args="--pdf path/to/sample.pdf --output type3.json --pretty"
+ * 
+ */ +public final class Type3SignatureTool { + + private static final ObjectMapper OBJECT_MAPPER = + new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + private Type3SignatureTool() {} + + public static void main(String[] args) throws Exception { + Arguments arguments = Arguments.parse(args); + if (arguments.showHelp || arguments.pdf == null) { + printUsage(); + return; + } + + Path pdfPath = arguments.pdf.toAbsolutePath(); + if (!Files.exists(pdfPath)) { + throw new IOException("PDF not found: " + pdfPath); + } + + List> fonts; + try (PDDocument document = Loader.loadPDF(pdfPath.toFile())) { + fonts = collectType3Fonts(document); + } + + Map output = new LinkedHashMap<>(); + output.put("pdf", pdfPath.toString()); + output.put("fonts", fonts); + ObjectWriter writer = + arguments.pretty + ? OBJECT_MAPPER.writerWithDefaultPrettyPrinter() + : OBJECT_MAPPER.writer(); + if (arguments.output != null) { + Path parent = arguments.output.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + writer.writeValue(arguments.output.toFile(), output); + verifyOutput(arguments.output, fonts.size()); + } else { + writer.writeValue(System.out, output); + } + } + + private static List> collectType3Fonts(PDDocument document) + throws IOException { + if (document == null || document.getNumberOfPages() == 0) { + return List.of(); + } + List> fonts = new ArrayList<>(); + Type3GlyphExtractor glyphExtractor = new Type3GlyphExtractor(); + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + + for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) { + PDPage page = document.getPage(pageIndex); + PDResources resources = page.getResources(); + if (resources == null) { + continue; + } + scanResources(document, pageIndex + 1, resources, glyphExtractor, visited, fonts); + } + return fonts; + } + + private static void scanResources( + PDDocument document, + int pageNumber, + PDResources resources, + Type3GlyphExtractor glyphExtractor, + Set visited, + List> fonts) + throws IOException { + if (resources == null) { + return; + } + + for (COSName name : resources.getFontNames()) { + PDFont font = resources.getFont(name); + if (!(font instanceof PDType3Font type3Font)) { + continue; + } + Object cosObject = type3Font.getCOSObject(); + if (cosObject != null && !visited.add(cosObject)) { + continue; + } + fonts.add( + describeFont(document, pageNumber, name.getName(), type3Font, glyphExtractor)); + } + + Deque embedded = new ArrayDeque<>(); + for (COSName name : resources.getXObjectNames()) { + PDXObject xobject = resources.getXObject(name); + if (xobject instanceof PDFormXObject form && form.getResources() != null) { + embedded.add(form.getResources()); + } + } + while (!embedded.isEmpty()) { + scanResources(document, pageNumber, embedded.pop(), glyphExtractor, visited, fonts); + } + } + + private static Map describeFont( + PDDocument document, + int pageNumber, + String fontId, + PDType3Font font, + Type3GlyphExtractor glyphExtractor) + throws IOException { + Map payload = new LinkedHashMap<>(); + payload.put("pageNumber", pageNumber); + payload.put("fontId", fontId); + payload.put("baseName", safeFontName(font)); + payload.put("alias", normalizeAlias(safeFontName(font))); + payload.put("encoding", resolveEncoding(font)); + payload.put("signature", Type3FontSignatureCalculator.computeSignature(font)); + + List glyphs = + glyphExtractor.extractGlyphs(document, font, fontId, pageNumber); + payload.put("glyphCount", glyphs != null ? glyphs.size() : 0); + + Set coverage = new TreeSet<>(); + if (glyphs != null) { + for (Type3GlyphOutline glyph : glyphs) { + if (glyph == null) { + continue; + } + if (glyph.getUnicode() != null) { + coverage.add(glyph.getUnicode()); + } else if (glyph.getCharCode() >= 0) { + coverage.add(0xF000 | (glyph.getCharCode() & 0xFF)); + } + } + List> warnings = new ArrayList<>(); + for (Type3GlyphOutline glyph : glyphs) { + if (glyph != null && glyph.getWarnings() != null) { + Map warning = new LinkedHashMap<>(); + warning.put("glyphName", glyph.getGlyphName()); + warning.put("message", glyph.getWarnings()); + warnings.add(warning); + } + } + if (!warnings.isEmpty()) { + payload.put("warnings", warnings); + } + } + if (!coverage.isEmpty()) { + payload.put("glyphCoverage", new ArrayList<>(coverage)); + } + return payload; + } + + private static void verifyOutput(Path output, int fontCount) throws IOException { + Path absolute = output.toAbsolutePath(); + if (!Files.exists(absolute)) { + throw new IOException("Expected output file not found: " + absolute); + } + long size = Files.size(absolute); + if (size == 0) { + throw new IOException("Output file is empty: " + absolute); + } + System.out.println( + "Wrote " + fontCount + " fonts to " + absolute + " (" + size + " bytes, verified)"); + } + + private static String resolveEncoding(PDType3Font font) { + if (font == null || font.getEncoding() == null) { + return null; + } + Object encoding = font.getCOSObject().getDictionaryObject(COSName.ENCODING); + return encoding != null + ? encoding.toString() + : font.getEncoding().getClass().getSimpleName(); + } + + private static String safeFontName(PDType3Font font) { + if (font == null) { + return null; + } + try { + if (font.getName() != null) { + return font.getName(); + } + } catch (Exception ignored) { + // ignore + } + if (font.getCOSObject() != null) { + return font.getCOSObject().getNameAsString(COSName.BASE_FONT); + } + return null; + } + + private static String normalizeAlias(String name) { + if (name == null) { + return null; + } + int plus = name.indexOf('+'); + String normalized = plus >= 0 ? name.substring(plus + 1) : name; + normalized = normalized.trim(); + return normalized.isEmpty() ? null : normalized.toLowerCase(Locale.ROOT); + } + + private static void printUsage() { + System.out.println( + """ + Type3SignatureTool - dump Type3 font signatures for library building + Usage: + --pdf Input PDF to analyse (required) + --output Optional output file (defaults to stdout) + --pretty Pretty-print JSON output + --help Show this help + + Example: + ./gradlew :proprietary:type3SignatureTool --args="--pdf samples/foo.pdf --output foo.json --pretty" + """); + } + + private static final class Arguments { + private final Path pdf; + private final Path output; + private final boolean pretty; + private final boolean showHelp; + + private Arguments(Path pdf, Path output, boolean pretty, boolean showHelp) { + this.pdf = pdf; + this.output = output; + this.pretty = pretty; + this.showHelp = showHelp; + } + + static Arguments parse(String[] args) { + if (args == null || args.length == 0) { + return new Arguments(null, null, true, true); + } + Path pdf = null; + Path output = null; + boolean pretty = false; + boolean showHelp = false; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if ("--pdf".equals(arg) && i + 1 < args.length) { + pdf = Paths.get(args[++i]); + } else if ("--output".equals(arg) && i + 1 < args.length) { + output = Paths.get(args[++i]); + } else if ("--pretty".equals(arg)) { + pretty = true; + } else if ("--help".equals(arg) || "-h".equals(arg)) { + showHelp = true; + } + } + return new Arguments(pdf, output, pretty, showHelp); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index a65c79665a..061b063aab 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -39,7 +39,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin public class JwtService implements JwtServiceInterface { private static final String ISSUER = "https://stirling.com"; - private static final long EXPIRATION = 3600000; + private static final long EXPIRATION = 43200000; private final KeyPersistenceServiceInterface keyPersistenceService; private final boolean v2Enabled; diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 666e18bd38..67fbd488f1 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -28,9 +28,6 @@ FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8 # Copy necessary files COPY scripts /scripts COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ -# first /app directory is for the build stage, second is for the final image -COPY --from=build /app/app/core/build/libs/*.jar app.jar -COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar ARG VERSION_TAG @@ -84,6 +81,8 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a gcompat \ libc6-compat \ libreoffice \ + ghostscript \ + fontforge \ # pdftohtml poppler-utils \ # OCR MY PDF (unpaper for descew and other advanced features) @@ -113,11 +112,16 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a chmod +x /scripts/init.sh && \ # User permissions addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ - chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \ - chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf + +# first /app directory is for the build stage, second is for the final image +COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/app/core/build/libs/*.jar /app.jar +COPY --from=build --chown=stirlingpdfuser:stirlingpdfgroup /app/build/libs/restart-helper.jar /restart-helper.jar + +RUN chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar EXPOSE 8080/tcp # Set user and run command ENTRYPOINT ["tini", "--", "/scripts/init.sh"] -CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"] \ No newline at end of file +CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"] diff --git a/docker/backend/Dockerfile.fat b/docker/backend/Dockerfile.fat index 4e63393e8d..028350a1c7 100644 --- a/docker/backend/Dockerfile.fat +++ b/docker/backend/Dockerfile.fat @@ -74,6 +74,8 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a gcompat \ libc6-compat \ libreoffice \ + ghostscript \ + fontforge \ # pdftohtml poppler-utils \ # OCR MY PDF (unpaper for descew and other advanced featues) @@ -110,4 +112,4 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a EXPOSE 8080/tcp # Set user and run command ENTRYPOINT ["tini", "--", "/scripts/init.sh"] -CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"] \ No newline at end of file +CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"] diff --git a/docker/backend/Dockerfile.ultra-lite b/docker/backend/Dockerfile.ultra-lite index 0b4b7a9391..264cad765b 100644 --- a/docker/backend/Dockerfile.ultra-lite +++ b/docker/backend/Dockerfile.ultra-lite @@ -60,7 +60,9 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et curl \ shadow \ su-exec \ - openjdk21-jre && \ + openjdk21-jre \ + ghostscript \ + fontforge && \ # User permissions mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \ chmod +x /scripts/*.sh && \ diff --git a/docker/compose/docker-compose.yml b/docker/compose/docker-compose.yml index 6f8b1ace80..713bfec790 100644 --- a/docker/compose/docker-compose.yml +++ b/docker/compose/docker-compose.yml @@ -5,10 +5,6 @@ services: dockerfile: docker/backend/Dockerfile container_name: stirling-pdf-backend restart: on-failure:5 - deploy: - resources: - limits: - memory: 4G healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"] interval: 5s diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index ffe913738f..3be5ec9005 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -24,7 +24,7 @@ http { index index.html index.htm; # Global settings for file uploads - client_max_body_size 100m; + client_max_body_size 0; # Handle client-side routing - support subpaths location / { @@ -48,12 +48,12 @@ http { proxy_cache off; # Timeout settings for large file uploads - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + # Request size limits for file uploads - client_max_body_size 100m; + client_max_body_size 0; proxy_request_buffering off; } diff --git a/docs/type3_fallback_plan.md b/docs/type3_fallback_plan.md new file mode 100644 index 0000000000..3ef7e6ad2a --- /dev/null +++ b/docs/type3_fallback_plan.md @@ -0,0 +1,150 @@ +# Type3 Font Library & Matching Plan + +This file documents where we are with Type3 font handling, what tooling already exists, and what remains to be done so future work (or another Codex session) can pick it up quickly. + +## Goal +Ensure Type3 fonts keep their appearance when users edit/export PDFs. That means: +1. Identifying common Type3 fonts we encounter (Matplotlib, LaTeX, etc.). +2. Capturing their glyph outlines once, converting them to reusable TTF/OTF binaries. +3. At runtime, matching Type3 fonts in incoming PDFs against that library (by signature) so we can embed the canonical TTF instead of falling back to generic fonts. +4. Using the captured char-code sequences so regeneration and editing preserves glyphs even when original fonts had no ToUnicode map. + +## Current State +- **Extraction**: `PdfJsonTextElement` now stores raw Type3 char codes; `encodeTextWithFont` can use them so token-level rewrites keep original glyphs. +- **Regeneration**: Page regeneration now uses those char codes when writing new content streams, so existing text should remain visible even when tokens must be rebuilt. +- **Scripts**: `scripts/index_type3_catalogue.py` scans PDFs in `app/core/src/main/resources/type3/samples` with `pdffonts` and writes `catalogue.json` (basic list of Type3 fonts encountered). This is only the first step; we still need per-font signatures and converted binaries. +- **Samples**: There are sample PDFs under `app/core/src/main/resources/type3/samples/` (Matplotlib slides, etc.) that we can mine for common Type3 fonts. +- **Library matching**: `Type3FontLibrary` loads `type3/library/index.json`, and `Type3LibraryStrategy` injects the prebuilt TTF/OTF payloads straight into `PdfJsonFont` conversion candidates. At runtime this is now the *only* conversion path; if the library does not recognise a signature we fall back to the captured Type3 glyph codes instead of trying to synthesize a font on the fly. +- **Offline conversion helpers**: `scripts/type3_to_cff.py` is still available for developers who need to turn a Type3-only PDF into a reusable TTF/OTF, but it is no longer wired into the server lifecycle. Everything shipped to users must be backed by the curated library. +- **Signature CLI**: `Type3SignatureTool` (`./gradlew :proprietary:type3SignatureTool --args="--pdf sample.pdf --output meta.json --pretty"`) dumps every Type3 font in a PDF along with its signature + glyph coverage. Use this to extend `index.json` without touching the backend. +- **Signature inventory**: `docs/type3/signatures/` stores the captured dumps, and `scripts/summarize_type3_signatures.py` keeps `docs/type3/signature_inventory.md` up to date so we know which aliases still need binaries. + +## Remaining Work +1. **Signature capture tooling** + - ✅ `Type3SignatureTool` (`./gradlew :proprietary:type3SignatureTool`) dumps signature + glyph coverage JSON; keep them under `docs/type3/signatures`. + - ✅ `scripts/summarize_type3_signatures.py` produces `docs/type3/signature_inventory.md` to highlight remaining gaps. + - ✅ `scripts/harvest_type3_fonts.py --input ` bulk-processes entire PDF folders, reusing cached signature JSON files and writing `docs/type3/harvest_report.json` so you can keep adding new samples over time. + - ✅ `scripts/download_pdf_samples.py` downloads large batches of PDF URLs into a staging folder that can immediately be fed to the harvester. + - ⏱️ Extend `scripts/index_type3_catalogue.py` to read those dumps so the catalogue and library stay in sync. + +2. **Library coverage** + - ✅ Added CM (cmr10/cmmi10/cmex10/cmsy10), STIX Size Three symbols, and SourceCodePro (SauceCode) using upstream TTF/OTF payloads. + - 🔜 Add Matplotlib-only subsets (F36/F59). For proprietary Type3 shapes, use the offline FontTools helper (`scripts/type3_to_cff.py`) to generate TTF/OTF payloads, drop them under `type3/library/fonts//`, and reference them from `index.json`. + - Each entry in `type3/library/index.json` should contain `{id, aliases, signatures, glyphCoverage, program/web/pdf payloads, source PDF}`. + +3. **Glyph coverage metadata** + - ✅ When adding a library entry, copy the `glyphCoverage` array from the signature JSON so runtime preflight knows exactly which code points exist. The backend now consults this data while building new text runs so characters stay on the original Type3 font whenever it supports them. + +4. **Automation** + - ✅ `scripts/update_type3_library.py` ingests the captured signature JSON files, merges their signatures/aliases/glyph coverage into `app/core/src/main/resources/type3/library/index.json`, and reports any fonts that still lack entries. Run it with `--apply` after harvesting new samples. + +5. **Validation** + - 🔁 After each new library entry, run a JSON→PDF roundtrip on the source PDF to confirm edited text sticks with the canonical font (FontTools stays disabled unless the font is missing). + +## Tooling/Dependencies +- Requires `pdffonts` (poppler-utils) for the current indexing script. +- Optional: `scripts/type3_to_cff.py` (fontTools) when you need to manufacture a TTF/OTF for an otherwise Type3-only font before adding it to the library. +- Backend relies on PDFBox 3.x. + +## Library Onboarding Workflow +Follow this loop whenever you encounter a new Type3 face that is missing from the library: + +1. **Capture signatures** + Run `./gradlew :proprietary:type3SignatureTool --args="--pdf path/to/sample.pdf --output docs/type3/signatures/.json --pretty"` to dump the font’s signature, glyph coverage, and aliases. Commit the JSON under `docs/type3/signatures/`. + +2. **Harvest more samples (optional)** + Use `scripts/harvest_type3_fonts.py --input ` to bulk-run the signature tool across a directory of PDFs. This keeps `docs/type3/signature_inventory.md` fresh so you can see how often each alias appears. + +3. **Collect a canonical TTF/OTF** + - If the font is really just a subset of a known family (DejaVu, Computer Modern, STIX, etc.), copy the upstream TTF/OTF into `app/core/src/main/resources/type3/library/fonts//`. + - If no canonical binary exists, feed the sample PDF through `scripts/type3_to_cff.py --input glyphs.json --ttf-output ` to synthesize one offline. Review the glyphs visually before committing. + +4. **Update the library index** + Reference the binary from `app/core/src/main/resources/type3/library/index.json` (use the `resource` field so the build packs the raw TTF/OTF). Add the captured signatures, aliases, glyph coverage, and the PDF you mined as `source`. + +5. **Apply bulk edits automatically** + After dropping new signature dumps, run `scripts/update_type3_library.py --apply` to merge any missing signatures/aliases/coverage entries into `index.json`. The script prints a list of fonts that still lack binaries so you know what to tackle next. + +6. **Verify the round-trip** + Convert the sample PDF to JSON through the app, edit text to introduce new characters, and export it back to PDF. The logs should show `[TYPE3] Strategy type3-library finished with status SUCCESS`, and the output should keep the original styling even for the new glyphs. + +Because the server no longer attempts runtime synthesis, once a font lands in the library it will stay stable across every deployment. Missing fonts simply fall back to their Type3 glyph codes until you add them to the index, so there is always a deterministic path forward. + +## How to Use the Existing Script +``` +# From repo root +scripts/index_type3_catalogue.py \ + --samples app/core/src/main/resources/type3/samples \ + --output app/core/src/main/resources/type3/catalogue.json +``` +Output is a simple JSON array with `source`, `fontName`, and `encoding`. This needs to be extended with signatures and references to the converted TTFs once that tooling is in place. + +## Expected Outcomes +- A deduplicated library of the most common Type3 fonts we encounter, each with a stable signature and prebuilt TTF/OTF. +- Backend automatically matches a Type3 font to its library entry and embeds the canonical TTF during edit/export. +- Fallback font usage drops dramatically; edited PDFs retain the original look with Type3Synth fonts only used when genuinely necessary. +- Additional metrics (e.g., glyph coverage) stored in the catalogue so we can diagnose gaps quickly. + +## Next Steps Checklist +1. Capture signatures for every sample font and add them to `type3/library/index.json`. +2. Extend catalogue JSON to include signatures + metadata. +3. Batch-convert the remaining samples into the Type3 library (TTF/OTF files under `resources/type3/library/`). +4. Provide doc or script for adding new fonts to the library. +5. Run regression tests on sample PDFs to ensure original text remains visible and new text matches the Type3 font whenever possible. + +## Library Layout Cheat Sheet +- **Index**: `app/core/src/main/resources/type3/library/index.json`. +- **Font payloads**: drop TTF/OTF data under `type3/library/fonts//.ttf`. +- **Entry schema**: + ```json + { + "id": "unique-id", + "label": "Human readable name", + "signatures": ["sha256:..."], + "aliases": ["SubsetPrefix+RealName"], + "program": {"resource": "type3/library/fonts/family/font.otf", "format": "otf"}, + "webProgram": {"resource": "...", "format": "ttf"}, + "pdfProgram": {"resource": "...", "format": "ttf"}, + "glyphCoverage": [32,65,66], + "source": "Where the sample came from" + } + ``` +- **Runtime flow**: + 1. `Type3FontConversionService` builds a `Type3ConversionRequest`. + 2. `Type3LibraryStrategy` hashes the font via `Type3FontSignatureCalculator`. + 3. If the signature/alias exists in the index, it injects the canonical payload as a `PdfJsonFontConversionCandidate`. + 4. `PdfJsonConversionService` prefers conversion candidates over embedded Type3 programs when reloading fonts, so new text uses the canonical TTF automatically. + +### Signature Capture Tool +``` +# Dump all Type3 fonts in a PDF, their signatures, and glyph coverage +./gradlew :proprietary:type3SignatureTool \ + --args="--pdf app/core/src/main/resources/type3/samples/01_Matplotlib.pdf --output tmp/signatures.json --pretty" +``` +Use the resulting JSON to fill `signatures`, `aliases`, and `glyphCoverage` in `type3/library/index.json`. Once an entry exists, runtime conversion will reuse that payload and skip the costly FontTools synthesis. + +--- +Feel free to expand this plan or add notes as the work progresses. + +--- + +## Practical Workflow (from PDF ingestion to runtime use) + +| Stage | Tool / Command | Output | +| --- | --- | --- | +| 1. Collect PDFs | `python scripts/download_pdf_collection.py --output scripts/pdf-collection` (or drop your own PDFs anywhere) | Raw PDFs ready for harvesting | +| 2. Harvest signatures | `python scripts/harvest_type3_fonts.py --input scripts/pdf-collection --pretty` | Per-PDF dumps in `docs/type3/signatures/…` + global summary `docs/type3/harvest_report.json` | +| 3. Summarize backlog | `python scripts/summarize_type3_signatures.py` | `docs/type3/signature_inventory.md` (human checklist of aliases/signatures) | +| 4. Convert fonts | Either copy the upstream TTF/OTF for the font (DejaVu, CM, STIX, etc.) or run `scripts/type3_to_cff.py` against the harvested glyph JSON to synthesize one offline; store the result under `app/core/src/main/resources/type3/library/fonts//`. | Canonical font binaries | +| 5. Register entry | Edit `app/core/src/main/resources/type3/library/index.json` (add `id`, `aliases`, `signatures`, `glyphCoverage`, and point `program/web/pdf` to the binaries). | Runtime-ready index | +| 6. Verify in app | Run a PDF→JSON→PDF roundtrip on a sample containing the font; check logs for `[TYPE3] Strategy type3-library finished with status SUCCESS`. | Confidence that edits use the canonical TTF | + +### Expected artifacts in the repo +- `scripts/pdf-collection/` — downloaded PDFs (input to the pipeline). +- `docs/type3/signatures/<...>.json` — raw signature dumps (one per PDF). +- `docs/type3/harvest_report.json` — deduplicated list of every signature encountered to date. +- `docs/type3/signature_inventory.md` — Markdown table summarizing signatures/aliases for triage. +- `app/core/src/main/resources/type3/library/fonts//.ttf` — curated binaries. +- `app/core/src/main/resources/type3/library/index.json` — mapping used at runtime. + +Once an entry exists in `index.json`, the backend automatically attaches that TTF/OTF during PDF→JSON, caches a normalized PDFont, and uses it for JSON→PDF regeneration. This eliminates the `PDType3Font.encode` limitation and keeps edited text visually identical to the original Type3 output. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86f3f9a363..312163d4f1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-bookmark": "^1.4.1", "@embedpdf/plugin-export": "^1.4.1", "@embedpdf/plugin-history": "^1.4.1", "@embedpdf/plugin-interaction-manager": "^1.4.1", @@ -61,6 +62,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-i18next": "^15.7.3", + "react-rnd": "^10.5.2", "react-router-dom": "^7.9.1", "signature_pad": "^5.0.4", "tailwindcss": "^4.1.13", @@ -633,6 +635,22 @@ "vue": ">=3.2.0" } }, + "node_modules/@embedpdf/plugin-bookmark": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz", + "integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "1.4.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.4.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, "node_modules/@embedpdf/plugin-export": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz", @@ -12064,6 +12082,16 @@ "node": ">=0.10.0" } }, + "node_modules/re-resizable": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz", + "integrity": "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -12085,6 +12113,29 @@ "react": "^19.2.0" } }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react-dropzone": { "version": "14.3.8", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", @@ -12203,6 +12254,27 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/react-rnd": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.5.2.tgz", + "integrity": "sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==", + "license": "MIT", + "dependencies": { + "re-resizable": "6.11.2", + "react-draggable": "4.4.6", + "tslib": "2.6.2" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/react-rnd/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "license": "0BSD" + }, "node_modules/react-router": { "version": "7.9.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index dbfd9d3479..d47f423c1d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-bookmark": "^1.4.1", "@embedpdf/plugin-export": "^1.4.1", "@embedpdf/plugin-history": "^1.4.1", "@embedpdf/plugin-interaction-manager": "^1.4.1", @@ -56,6 +57,7 @@ "posthog-js": "^1.268.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-rnd": "^10.5.2", "react-i18next": "^15.7.3", "react-router-dom": "^7.9.1", "signature_pad": "^5.0.4", diff --git a/frontend/public/Login/Firstpage.png b/frontend/public/Login/Firstpage.png index f12133f4f7..3cee859e7f 100644 Binary files a/frontend/public/Login/Firstpage.png and b/frontend/public/Login/Firstpage.png differ diff --git a/frontend/public/branding/StirlingPDFLogoNoTextDark.svg b/frontend/public/branding/StirlingPDFLogoNoTextDark.svg index a6f82dd6f6..271fb343e3 100644 --- a/frontend/public/branding/StirlingPDFLogoNoTextDark.svg +++ b/frontend/public/branding/StirlingPDFLogoNoTextDark.svg @@ -1,4 +1,5 @@ - - - + + + + diff --git a/frontend/public/branding/StirlingPDFLogoNoTextLight.svg b/frontend/public/branding/StirlingPDFLogoNoTextLight.svg index 62a5b38387..dee8650333 100644 --- a/frontend/public/branding/StirlingPDFLogoNoTextLight.svg +++ b/frontend/public/branding/StirlingPDFLogoNoTextLight.svg @@ -1,4 +1,34 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 6d6c8521c1..0d7774d32a 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index e0a3f7cf90..d067b42518 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -17,6 +17,8 @@ "comingSoon": "Coming soon:", "favorite": "Add to favourites", "favorites": "Favourites", + "unavailable": "Disabled by server administrator:", + "unavailableDependency": "Unavailable - required tool missing on server:", "heading": "All tools (fullscreen view)", "noResults": "Try adjusting your search or toggle descriptions to find what you need.", "recommended": "Recommended", @@ -26,7 +28,10 @@ "toggle": { "fullscreen": "Switch to fullscreen mode", "sidebar": "Switch to sidebar mode" - } + }, + "alpha": "Alpha", + "premiumFeature": "Premium feature:", + "comingSoon": "Coming soon:" }, "unsavedChanges": "You have unsaved changes to your PDF.", "areYouSure": "Are you sure you want to leave?", @@ -854,6 +859,15 @@ "overlay-pdfs": { "desc": "Overlay one PDF on top of another", "title": "Overlay PDFs" + }, + "pdfTextEditor": { + "title": "PDF Text Editor", + "desc": "Review and edit Stirling PDF JSON exports with grouped text editing and PDF regeneration" + }, + "addText": { + "tags": "text,annotation,label", + "title": "Add Text", + "desc": "Add custom text anywhere in your PDF" } }, "landing": { @@ -919,6 +933,11 @@ }, "error": { "failed": "An error occurred while merging the PDFs." + }, + "tooltip": { + "header": { + "title": "Merge Settings Overview" + } } }, "split": { @@ -1251,10 +1270,44 @@ "addImage": { "tags": "img,jpg,picture,photo", "title": "Add Image", - "header": "Add image to PDF", - "everyPage": "Every Page?", - "upload": "Add image", - "submit": "Add image" + "header": "Add images to PDFs", + "image": { + "name": "Image", + "placeholder": "Upload an image", + "label": "Image file" + }, + "steps": { + "configure": "Configure Image" + }, + "step": { + "createDesc": "Upload the image you want to add", + "place": "Place image", + "placeDesc": "Click on the PDF to add your image" + }, + "instructions": { + "title": "How to add images", + "text": "After uploading your image above, click anywhere on the PDF to place it.", + "paused": "Placement paused", + "resumeHint": "Resume placement to click and add your image.", + "noSignature": "Upload an image above to enable placement." + }, + "mode": { + "move": "Move Image", + "place": "Place Image", + "pause": "Pause placement", + "resume": "Resume placement" + }, + "results": { + "title": "Add Image Results" + }, + "error": { + "failed": "An error occurred while adding image to the PDF." + }, + "saved": { + "defaultLabel": "Image", + "defaultImageLabel": "Uploaded image" + }, + "applySignatures": "Apply Images" }, "attachments": { "tags": "attachments,add,remove,embed,file", @@ -2146,8 +2199,8 @@ "colorPickerTitle": "Choose stroke colour" }, "text": { - "name": "Signer Name", - "placeholder": "Enter your full name", + "name": "Text", + "placeholder": "Enter text", "fontLabel": "Font", "fontSizeLabel": "Font size", "fontSizePlaceholder": "Type or select font size (8-200)", @@ -2373,6 +2426,14 @@ "title": "About Remove Annotations", "description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents." }, + "tooltip": { + "header": { + "title": "About Remove Annotations" + }, + "description": { + "title": "What it does" + } + }, "error": { "failed": "An error occurred while removing annotations from the PDF." } @@ -2407,8 +2468,12 @@ "selection": { "originalEditedTitle": "Select Original and Edited PDFs" }, - "original": { "label": "Original PDF" }, - "edited": { "label": "Edited PDF" }, + "original": { + "label": "Original PDF" + }, + "edited": { + "label": "Edited PDF" + }, "swap": { "confirmTitle": "Re-run comparison?", "confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?", @@ -2835,6 +2900,9 @@ "header": { "title": "How Auto-Rename Works" }, + "description": { + "title": "What it does" + }, "howItWorks": { "title": "Smart Renaming", "text": "Automatically finds the title from your PDF content and uses it as the filename.", @@ -2842,6 +2910,9 @@ "bullet2": "Creates a clean, valid filename from the detected title", "bullet3": "Keeps the original name if no suitable title is found" } + }, + "settings": { + "title": "About" } }, "adjust-contrast": { @@ -3807,6 +3878,7 @@ "downloadSelected": "Download Selected Files", "downloadAll": "Download All", "toggleTheme": "Toggle Theme", + "toggleBookmarks": "Toggle Bookmarks", "language": "Language", "search": "Search PDF", "panMode": "Pan Mode", @@ -4841,7 +4913,9 @@ "secureWorkflow": "Security Workflow", "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default.", "processImages": "Process Images", - "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." + "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images.", + "prePublishSanitization": "Pre-publish Sanitization", + "prePublishSanitizationDesc": "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online." } }, "colorPicker": { @@ -4933,6 +5007,14 @@ "addMoreFiles": "Add more files...", "selectedFiles": "Selected Files", "submit": "Add Attachments", + "tooltip": { + "header": { + "title": "About Add Attachments" + }, + "description": { + "title": "What it does" + } + }, "results": { "title": "Attachment Results" }, @@ -5606,6 +5688,24 @@ "starting": "Backend starting up...", "wait": "Please wait for the backend to finish launching and try again." }, + "encryptedPdfUnlock": { + "unlockPrompt": "Unlock PDF to continue", + "title": "Remove password to continue", + "description": "This PDF is password protected. Enter the password so you can continue working with it.", + "password": { + "label": "PDF password", + "placeholder": "Enter the PDF password" + }, + "skip": "Skip for now", + "unlock": "Unlock & Continue", + "incorrectPassword": "Incorrect password", + "missingFile": "The selected file is no longer available.", + "emptyResponse": "Password removal did not produce a file.", + "required": "Enter the password to continue.", + "successTitle": "Password removed", + "successBodyWithName": "Password removed from {{fileName}}", + "successBody": "Password removed successfully." + }, "setup": { "welcome": "Welcome to Stirling PDF", "description": "Get started by choosing how you want to use Stirling PDF", @@ -5622,15 +5722,23 @@ "description": "Enter credentials" }, "mode": { - "offline": { - "title": "Use Offline", - "description": "Run locally without an internet connection" + "saas": { + "title": "Stirling Cloud", + "description": "Sign in with your Stirling account" }, - "server": { - "title": "Connect to Server", - "description": "Connect to a remote Stirling PDF server" + "selfhosted": { + "title": "Self-Hosted Server", + "description": "Connect to your own Stirling PDF server" } }, + "saas": { + "title": "Sign in to Stirling", + "subtitle": "Sign in with your Stirling account" + }, + "selfhosted": { + "title": "Sign in to Server", + "subtitle": "Enter your server credentials" + }, "server": { "title": "Connect to Server", "subtitle": "Enter your self-hosted server URL", @@ -5672,16 +5780,12 @@ "connection": { "title": "Connection Mode", "mode": { - "offline": "Offline", - "server": "Server" + "saas": "Stirling Cloud", + "selfhosted": "Self-Hosted" }, "server": "Server", "user": "Logged in as", - "switchToServer": "Connect to Server", - "switchToOffline": "Switch to Offline", - "logout": "Logout", - "selectServer": "Select Server", - "login": "Login" + "logout": "Log Out" }, "general": { "title": "General", @@ -5722,7 +5826,11 @@ "latestVersion": "Latest Version", "checkForUpdates": "Check for Updates", "viewDetails": "View Details" - } + }, + "hideUnavailableTools": "Hide unavailable tools", + "hideUnavailableToolsDescription": "Remove tools that have been disabled by your server instead of showing them greyed out.", + "hideUnavailableConversions": "Hide unavailable conversions", + "hideUnavailableConversionsDescription": "Remove disabled conversion options in the Convert tool instead of showing them greyed out." }, "hotkeys": { "errorConflict": "Shortcut already used by {{tool}}.", @@ -5737,10 +5845,173 @@ "noShortcut": "No shortcut set" } }, + "pdfTextEditor": { + "title": "PDF JSON Editor", + "viewLabel": "PDF Editor", + "converting": "Converting PDF to editable format...", + "conversionFailed": "Failed to convert PDF. Please try again.", + "currentFile": "Current file: {{name}}", + "pageSummary": "Page {{number}} of {{total}}", + "pagePreviewAlt": "Page preview", + "imageLabel": "Placed image", + "noTextOnPage": "No editable text was detected on this page.", + "pageType": { + "paragraph": "Paragraph page", + "sparse": "Sparse text" + }, + "groupingMode": { + "auto": "Auto", + "paragraph": "Paragraph", + "singleLine": "Single Line" + }, + "badges": { + "unsaved": "Edited", + "modified": "Edited", + "earlyAccess": "Early Access" + }, + "actions": { + "reset": "Reset Changes", + "downloadJson": "Download JSON", + "generatePdf": "Generate PDF" + }, + "options": { + "autoScaleText": { + "title": "Auto-scale text to fit boxes", + "description": "Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF." + }, + "groupingMode": { + "title": "Text Grouping Mode", + "autoDescription": "Automatically detects page type and groups text appropriately.", + "paragraphDescription": "Groups aligned lines into multi-line paragraph text boxes.", + "singleLineDescription": "Keeps each PDF text line as a separate text box." + }, + "manualGrouping": { + "descriptionInline": "Tip: Hold Ctrl (Cmd) or Shift to multi-select text boxes. A floating toolbar will appear above the selection so you can merge, ungroup, or adjust widths." + }, + "forceSingleElement": { + "title": "Lock edited text to a single PDF element", + "description": "When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts." + } + }, + "manual": { + "mergeTooltip": "Merge selected boxes", + "merge": "Merge selection", + "ungroupTooltip": "Split paragraph back into lines", + "ungroup": "Ungroup selection", + "widthMenu": "Width options", + "expandWidth": "Expand to page edge", + "resetWidth": "Reset width", + "resizeHandle": "Adjust text width" + }, + "disclaimer": { + "heading": "Preview Limitations", + "textFocus": "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here.", + "previewVariance": "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible.", + "alpha": "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." + }, + "empty": { + "title": "No document loaded", + "subtitle": "Load a PDF or JSON file to begin editing text content." + }, + "welcomeBanner": { + "title": "Welcome to PDF Text Editor (Early Access)", + "experimental": "This is an experimental feature in active development. Expect some instability and issues during use.", + "howItWorks": "This tool converts your PDF to an editable format where you can modify text content and reposition images. Changes are saved back as a new PDF.", + "bestFor": "Works Best With:", + "bestFor1": "Simple PDFs containing primarily text and images", + "bestFor2": "Documents with standard paragraph formatting", + "bestFor3": "Letters, essays, reports, and basic documents", + "notIdealFor": "Not Ideal For:", + "notIdealFor1": "PDFs with special formatting like bullet points, tables, or multi-column layouts", + "notIdealFor2": "Magazines, brochures, or heavily designed documents", + "notIdealFor3": "Instruction manuals with complex layouts", + "limitations": "Current Limitations:", + "limitation1": "Font rendering may differ slightly from the original PDF", + "limitation2": "Complex graphics, form fields, and annotations are preserved but not editable", + "limitation3": "Large files may take time to convert and process", + "knownIssues": "Known Issues (Being Fixed):", + "issue1": "Text colour is not currently preserved (will be added soon)", + "issue2": "Paragraph mode has more alignment and spacing issues - Single Line mode recommended", + "issue3": "The preview display differs from the exported PDF - exported PDFs are closer to the original", + "issue4": "Rotated text alignment may need manual adjustment", + "issue5": "Transparency and layering effects may vary from original", + "feedback": "This is an early access feature. Please report any issues you encounter to help us improve!", + "gotIt": "Got it", + "dontShowAgain": "Don't show again" + }, + "modeChange": { + "title": "Confirm Mode Change", + "warning": "Changing the text grouping mode will reset all unsaved changes. Are you sure you want to continue?", + "cancel": "Cancel", + "confirm": "Reset and Change Mode" + }, + "fontAnalysis": { + "details": "Font Details", + "embedded": "Embedded", + "type": "Type", + "webFormat": "Web Format", + "warnings": "Warnings", + "suggestions": "Notes", + "currentPageFonts": "Fonts on this page", + "allFonts": "All fonts", + "fallback": "fallback", + "missing": "missing", + "perfectMessage": "All fonts can be reproduced perfectly.", + "warningMessage": "Some fonts may not render correctly.", + "infoMessage": "Font reproduction information available.", + "perfect": "perfect", + "subset": "subset" + }, + "errors": { + "invalidJson": "Unable to read the JSON file. Ensure it was generated by the PDF to JSON tool.", + "pdfConversion": "Unable to convert the edited JSON back into a PDF." + } + }, "auth": { "sessionExpired": "Session Expired", "pleaseLoginAgain": "Please login again.", "accessDenied": "Access Denied", "insufficientPermissions": "You do not have permission to perform this action." + }, + "addText": { + "title": "Add Text", + "header": "Add text to PDFs", + "text": { + "name": "Text content", + "placeholder": "Enter the text you want to add", + "fontLabel": "Font", + "fontSizeLabel": "Font size", + "fontSizePlaceholder": "Type or select font size (8-200)", + "colorLabel": "Text colour" + }, + "steps": { + "configure": "Configure Text" + }, + "step": { + "createDesc": "Enter the text you want to add", + "place": "Place text", + "placeDesc": "Click on the PDF to add your text" + }, + "instructions": { + "title": "How to add text", + "text": "After entering your text above, click anywhere on the PDF to place it.", + "paused": "Placement paused", + "resumeHint": "Resume placement to click and add your text.", + "noSignature": "Enter text above to enable placement." + }, + "mode": { + "move": "Move Text", + "place": "Place Text", + "pause": "Pause placement", + "resume": "Resume placement" + }, + "results": { + "title": "Add Text Results" + }, + "error": { + "failed": "An error occurred while adding text to the PDF." + }, + "tags": "text,annotation,label", + "applySignatures": "Apply Text" } -} +} \ No newline at end of file diff --git a/frontend/public/logo-tooltip.svg b/frontend/public/logo-tooltip.svg index 2d53f287c2..ae4b75a211 100644 --- a/frontend/public/logo-tooltip.svg +++ b/frontend/public/logo-tooltip.svg @@ -1,4 +1,34 @@ - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png index 2994ca293a..08101ad33c 100644 Binary files a/frontend/public/logo192.png and b/frontend/public/logo192.png differ diff --git a/frontend/public/logo512.png b/frontend/public/logo512.png index b481550734..1f7fe384fd 100644 Binary files a/frontend/public/logo512.png and b/frontend/public/logo512.png differ diff --git a/frontend/public/og_images/add-attachments.png b/frontend/public/og_images/add-attachments.png index 6fa6c30adc..a512c63fd2 100644 Binary files a/frontend/public/og_images/add-attachments.png and b/frontend/public/og_images/add-attachments.png differ diff --git a/frontend/public/og_images/add-image.png b/frontend/public/og_images/add-image.png index a440568a8c..517df62c3e 100644 Binary files a/frontend/public/og_images/add-image.png and b/frontend/public/og_images/add-image.png differ diff --git a/frontend/public/og_images/add-page-numbers.png b/frontend/public/og_images/add-page-numbers.png index 13a5ff64e5..f56e6812a6 100644 Binary files a/frontend/public/og_images/add-page-numbers.png and b/frontend/public/og_images/add-page-numbers.png differ diff --git a/frontend/public/og_images/add-password.png b/frontend/public/og_images/add-password.png index 2da6fffafa..d62c36cf99 100644 Binary files a/frontend/public/og_images/add-password.png and b/frontend/public/og_images/add-password.png differ diff --git a/frontend/public/og_images/add-stamp.png b/frontend/public/og_images/add-stamp.png index 6427e67fa9..3ea3898d3b 100644 Binary files a/frontend/public/og_images/add-stamp.png and b/frontend/public/og_images/add-stamp.png differ diff --git a/frontend/public/og_images/add-watermark.png b/frontend/public/og_images/add-watermark.png index eded1f3523..3b0d1d2c35 100644 Binary files a/frontend/public/og_images/add-watermark.png and b/frontend/public/og_images/add-watermark.png differ diff --git a/frontend/public/og_images/adjust-colors-contrast.png b/frontend/public/og_images/adjust-colors-contrast.png index 9a9164c9f3..3dbd697e31 100644 Binary files a/frontend/public/og_images/adjust-colors-contrast.png and b/frontend/public/og_images/adjust-colors-contrast.png differ diff --git a/frontend/public/og_images/adjust-page-size-scale.png b/frontend/public/og_images/adjust-page-size-scale.png index ac17d0b540..6f0fb3bb95 100644 Binary files a/frontend/public/og_images/adjust-page-size-scale.png and b/frontend/public/og_images/adjust-page-size-scale.png differ diff --git a/frontend/public/og_images/auto-rename-pdf-file.png b/frontend/public/og_images/auto-rename-pdf-file.png index 5407807985..884f5a8d18 100644 Binary files a/frontend/public/og_images/auto-rename-pdf-file.png and b/frontend/public/og_images/auto-rename-pdf-file.png differ diff --git a/frontend/public/og_images/auto-split-by-size-count.png b/frontend/public/og_images/auto-split-by-size-count.png index 59c7ed77ca..b8983e3780 100644 Binary files a/frontend/public/og_images/auto-split-by-size-count.png and b/frontend/public/og_images/auto-split-by-size-count.png differ diff --git a/frontend/public/og_images/auto-split-pages.png b/frontend/public/og_images/auto-split-pages.png index 6929078e7c..5d9f99be62 100644 Binary files a/frontend/public/og_images/auto-split-pages.png and b/frontend/public/og_images/auto-split-pages.png differ diff --git a/frontend/public/og_images/automate.png b/frontend/public/og_images/automate.png index a063107011..83b2d28e39 100644 Binary files a/frontend/public/og_images/automate.png and b/frontend/public/og_images/automate.png differ diff --git a/frontend/public/og_images/certSign.png b/frontend/public/og_images/certSign.png index ce587e2ef2..83e708ddf4 100644 Binary files a/frontend/public/og_images/certSign.png and b/frontend/public/og_images/certSign.png differ diff --git a/frontend/public/og_images/change-metadata.png b/frontend/public/og_images/change-metadata.png index fb283aa8cb..8951d0325c 100644 Binary files a/frontend/public/og_images/change-metadata.png and b/frontend/public/og_images/change-metadata.png differ diff --git a/frontend/public/og_images/change-permissions.png b/frontend/public/og_images/change-permissions.png index c4e1a11e7b..407319b13d 100644 Binary files a/frontend/public/og_images/change-permissions.png and b/frontend/public/og_images/change-permissions.png differ diff --git a/frontend/public/og_images/compare.png b/frontend/public/og_images/compare.png index 708c288379..1734a9ddfd 100644 Binary files a/frontend/public/og_images/compare.png and b/frontend/public/og_images/compare.png differ diff --git a/frontend/public/og_images/compress.png b/frontend/public/og_images/compress.png index 1a75b905f0..d48c34a062 100644 Binary files a/frontend/public/og_images/compress.png and b/frontend/public/og_images/compress.png differ diff --git a/frontend/public/og_images/convert.png b/frontend/public/og_images/convert.png index 3c2210ca00..07332ce8dc 100644 Binary files a/frontend/public/og_images/convert.png and b/frontend/public/og_images/convert.png differ diff --git a/frontend/public/og_images/cropPdf.png b/frontend/public/og_images/cropPdf.png index 59590d4df9..509e7f62a7 100644 Binary files a/frontend/public/og_images/cropPdf.png and b/frontend/public/og_images/cropPdf.png differ diff --git a/frontend/public/og_images/detect-split-scanned-photos.png b/frontend/public/og_images/detect-split-scanned-photos.png index 2ba6cf63f7..fcd6beab51 100644 Binary files a/frontend/public/og_images/detect-split-scanned-photos.png and b/frontend/public/og_images/detect-split-scanned-photos.png differ diff --git a/frontend/public/og_images/edit-table-of-contents.png b/frontend/public/og_images/edit-table-of-contents.png index 64092cbbe6..79f1a5987c 100644 Binary files a/frontend/public/og_images/edit-table-of-contents.png and b/frontend/public/og_images/edit-table-of-contents.png differ diff --git a/frontend/public/og_images/extract-images.png b/frontend/public/og_images/extract-images.png index 5d403dbc37..69905c6e02 100644 Binary files a/frontend/public/og_images/extract-images.png and b/frontend/public/og_images/extract-images.png differ diff --git a/frontend/public/og_images/extract-pages.png b/frontend/public/og_images/extract-pages.png index 075c8f7631..d9ff2f58cc 100644 Binary files a/frontend/public/og_images/extract-pages.png and b/frontend/public/og_images/extract-pages.png differ diff --git a/frontend/public/og_images/flatten.png b/frontend/public/og_images/flatten.png index f9a449a20c..f5d9ba133d 100644 Binary files a/frontend/public/og_images/flatten.png and b/frontend/public/og_images/flatten.png differ diff --git a/frontend/public/og_images/get-all-info-on-pdf.png b/frontend/public/og_images/get-all-info-on-pdf.png index 77fc4cf0f3..0d19ab6e23 100644 Binary files a/frontend/public/og_images/get-all-info-on-pdf.png and b/frontend/public/og_images/get-all-info-on-pdf.png differ diff --git a/frontend/public/og_images/home.png b/frontend/public/og_images/home.png index 577f8f4765..b8256245b1 100644 Binary files a/frontend/public/og_images/home.png and b/frontend/public/og_images/home.png differ diff --git a/frontend/public/og_images/manage-certificates.png b/frontend/public/og_images/manage-certificates.png index da02e0847d..824b26a4ee 100644 Binary files a/frontend/public/og_images/manage-certificates.png and b/frontend/public/og_images/manage-certificates.png differ diff --git a/frontend/public/og_images/mergePdfs.png b/frontend/public/og_images/mergePdfs.png index cd496d70ee..e798b7b2e3 100644 Binary files a/frontend/public/og_images/mergePdfs.png and b/frontend/public/og_images/mergePdfs.png differ diff --git a/frontend/public/og_images/multi-page-layout.png b/frontend/public/og_images/multi-page-layout.png index e6eb5514bb..5dd42a04b0 100644 Binary files a/frontend/public/og_images/multi-page-layout.png and b/frontend/public/og_images/multi-page-layout.png differ diff --git a/frontend/public/og_images/multi-tool.png b/frontend/public/og_images/multi-tool.png index b9f0812bfb..634e8ab232 100644 Binary files a/frontend/public/og_images/multi-tool.png and b/frontend/public/og_images/multi-tool.png differ diff --git a/frontend/public/og_images/ocr.png b/frontend/public/og_images/ocr.png index caf133e92f..cb9fade26c 100644 Binary files a/frontend/public/og_images/ocr.png and b/frontend/public/og_images/ocr.png differ diff --git a/frontend/public/og_images/overlay-pdfs.png b/frontend/public/og_images/overlay-pdfs.png index da5484ba9c..c1b15ac39a 100644 Binary files a/frontend/public/og_images/overlay-pdfs.png and b/frontend/public/og_images/overlay-pdfs.png differ diff --git a/frontend/public/og_images/read.png b/frontend/public/og_images/read.png index 3f88441b0a..17292db7d5 100644 Binary files a/frontend/public/og_images/read.png and b/frontend/public/og_images/read.png differ diff --git a/frontend/public/og_images/redact.png b/frontend/public/og_images/redact.png index 69d7d5b5da..bb9941ba2c 100644 Binary files a/frontend/public/og_images/redact.png and b/frontend/public/og_images/redact.png differ diff --git a/frontend/public/og_images/remove-annotations.png b/frontend/public/og_images/remove-annotations.png index 12b7de6719..52281c5280 100644 Binary files a/frontend/public/og_images/remove-annotations.png and b/frontend/public/og_images/remove-annotations.png differ diff --git a/frontend/public/og_images/remove-blank-pages.png b/frontend/public/og_images/remove-blank-pages.png index 1675c07544..3a68923011 100644 Binary files a/frontend/public/og_images/remove-blank-pages.png and b/frontend/public/og_images/remove-blank-pages.png differ diff --git a/frontend/public/og_images/remove-certificate-sign.png b/frontend/public/og_images/remove-certificate-sign.png index 0a97e97f3b..e350f3f47b 100644 Binary files a/frontend/public/og_images/remove-certificate-sign.png and b/frontend/public/og_images/remove-certificate-sign.png differ diff --git a/frontend/public/og_images/remove-image.png b/frontend/public/og_images/remove-image.png index 74a7067f29..f60c6f47f6 100644 Binary files a/frontend/public/og_images/remove-image.png and b/frontend/public/og_images/remove-image.png differ diff --git a/frontend/public/og_images/remove-password.png b/frontend/public/og_images/remove-password.png index 7022b7e4d6..c8fdc26eec 100644 Binary files a/frontend/public/og_images/remove-password.png and b/frontend/public/og_images/remove-password.png differ diff --git a/frontend/public/og_images/remove.png b/frontend/public/og_images/remove.png index a0e66d1fd5..7e5472ea5f 100644 Binary files a/frontend/public/og_images/remove.png and b/frontend/public/og_images/remove.png differ diff --git a/frontend/public/og_images/reorganize-pages.png b/frontend/public/og_images/reorganize-pages.png index 7dbaf68241..8294ff8b3f 100644 Binary files a/frontend/public/og_images/reorganize-pages.png and b/frontend/public/og_images/reorganize-pages.png differ diff --git a/frontend/public/og_images/repair.png b/frontend/public/og_images/repair.png index a531c1ef8b..02ed341c7c 100644 Binary files a/frontend/public/og_images/repair.png and b/frontend/public/og_images/repair.png differ diff --git a/frontend/public/og_images/replace-and-invert-color.png b/frontend/public/og_images/replace-and-invert-color.png index d476a9d170..46f2e496fc 100644 Binary files a/frontend/public/og_images/replace-and-invert-color.png and b/frontend/public/og_images/replace-and-invert-color.png differ diff --git a/frontend/public/og_images/rotate.png b/frontend/public/og_images/rotate.png index c2eeb81702..95a1274493 100644 Binary files a/frontend/public/og_images/rotate.png and b/frontend/public/og_images/rotate.png differ diff --git a/frontend/public/og_images/sanitize.png b/frontend/public/og_images/sanitize.png index efceca8b0b..884c533312 100644 Binary files a/frontend/public/og_images/sanitize.png and b/frontend/public/og_images/sanitize.png differ diff --git a/frontend/public/og_images/scanner-effect.png b/frontend/public/og_images/scanner-effect.png index b46275cd86..182d19b376 100644 Binary files a/frontend/public/og_images/scanner-effect.png and b/frontend/public/og_images/scanner-effect.png differ diff --git a/frontend/public/og_images/show-javascript.png b/frontend/public/og_images/show-javascript.png index 812e06553b..3e89269678 100644 Binary files a/frontend/public/og_images/show-javascript.png and b/frontend/public/og_images/show-javascript.png differ diff --git a/frontend/public/og_images/sign.png b/frontend/public/og_images/sign.png index 773a5e37fd..13ce770672 100644 Binary files a/frontend/public/og_images/sign.png and b/frontend/public/og_images/sign.png differ diff --git a/frontend/public/og_images/single-large-page.png b/frontend/public/og_images/single-large-page.png index 3bc457a99a..8b8c630c88 100644 Binary files a/frontend/public/og_images/single-large-page.png and b/frontend/public/og_images/single-large-page.png differ diff --git a/frontend/public/og_images/split-by-chapters.png b/frontend/public/og_images/split-by-chapters.png index 26db04b4cf..54bc8ac449 100644 Binary files a/frontend/public/og_images/split-by-chapters.png and b/frontend/public/og_images/split-by-chapters.png differ diff --git a/frontend/public/og_images/split-by-sections.png b/frontend/public/og_images/split-by-sections.png index e3601ddda3..98615352c2 100644 Binary files a/frontend/public/og_images/split-by-sections.png and b/frontend/public/og_images/split-by-sections.png differ diff --git a/frontend/public/og_images/split.png b/frontend/public/og_images/split.png deleted file mode 100644 index a77a065b42..0000000000 Binary files a/frontend/public/og_images/split.png and /dev/null differ diff --git a/frontend/public/og_images/splitPdf.png b/frontend/public/og_images/splitPdf.png index a77a065b42..1e425d857e 100644 Binary files a/frontend/public/og_images/splitPdf.png and b/frontend/public/og_images/splitPdf.png differ diff --git a/frontend/public/og_images/unlock-pdf-forms.png b/frontend/public/og_images/unlock-pdf-forms.png index 3e637cc4e6..2f6d49f110 100644 Binary files a/frontend/public/og_images/unlock-pdf-forms.png and b/frontend/public/og_images/unlock-pdf-forms.png differ diff --git a/frontend/public/og_images/validate-pdf-signature.png b/frontend/public/og_images/validate-pdf-signature.png index 020ccd8830..d113de8ae8 100644 Binary files a/frontend/public/og_images/validate-pdf-signature.png and b/frontend/public/og_images/validate-pdf-signature.png differ diff --git a/frontend/public/og_images/view-pdf.png b/frontend/public/og_images/view-pdf.png index bef62ad513..bde21682a6 100644 Binary files a/frontend/public/og_images/view-pdf.png and b/frontend/public/og_images/view-pdf.png differ diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs index 9ce4c3cdaf..64437a1e20 100644 --- a/frontend/src-tauri/src/commands/auth.rs +++ b/frontend/src-tauri/src/commands/auth.rs @@ -127,7 +127,7 @@ pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> { Ok(()) } -// Response types for Spring Boot login +// Response types for Spring Boot login (self-hosted) #[derive(Debug, Deserialize)] struct SpringBootSession { access_token: String, @@ -145,6 +145,24 @@ struct SpringBootLoginResponse { user: SpringBootUser, } +// Response types for Supabase login (SaaS) +#[derive(Debug, Deserialize)] +struct SupabaseUserMetadata { + full_name: Option, +} + +#[derive(Debug, Deserialize)] +struct SupabaseUser { + email: Option, + user_metadata: Option, +} + +#[derive(Debug, Deserialize)] +struct SupabaseLoginResponse { + access_token: String, + user: SupabaseUser, +} + #[derive(Debug, Serialize)] pub struct LoginResponse { pub token: String, @@ -153,7 +171,7 @@ pub struct LoginResponse { } /// Login command - makes HTTP request from Rust to bypass CORS -/// Supports Spring Boot authentication (self-hosted) +/// Supports both Supabase authentication (SaaS) and Spring Boot authentication (self-hosted) #[tauri::command] pub async fn login( server_url: String, @@ -162,54 +180,124 @@ pub async fn login( ) -> Result { log::info!("Login attempt for user: {} to server: {}", username, server_url); - // Build login URL - let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/')); - log::debug!("Login URL: {}", login_url); + // Detect if this is Supabase (SaaS) or Spring Boot (self-hosted) + // Compare against the configured SaaS server URL from environment + let saas_server_url = env!("VITE_SAAS_SERVER_URL"); + let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/'); + log::info!("Authentication type: {}", if is_supabase { "Supabase (SaaS)" } else { "Spring Boot (Self-hosted)" }); // Create HTTP client let client = reqwest::Client::new(); - // Make login request - let response = client - .post(&login_url) - .json(&serde_json::json!({ - "username": username, + if is_supabase { + // Supabase authentication flow + let login_url = format!("{}/auth/v1/token?grant_type=password", server_url.trim_end_matches('/')); + + // Supabase public API key from environment variable (required at compile time) + // Set VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY before building + let supabase_key = env!("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY"); + + let request_body = serde_json::json!({ + "email": username, "password": password, - })) - .send() - .await - .map_err(|e| format!("Network error: {}", e))?; - - let status = response.status(); - log::debug!("Login response status: {}", status); - - if !status.is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - log::error!("Login failed with status {}: {}", status, error_text); - - return Err(if status.as_u16() == 401 { - "Invalid username or password".to_string() - } else if status.as_u16() == 403 { - "Access denied".to_string() - } else { - format!("Login failed: {}", status) }); + + let response = client + .post(&login_url) + .header("Content-Type", "application/json;charset=UTF-8") + .header("apikey", supabase_key) + .header("Authorization", format!("Bearer {}", supabase_key)) + .header("X-Client-Info", "supabase-js-web/2.58.0") + .header("X-Supabase-Api-Version", "2024-01-01") + .json(&request_body) + .send() + .await + .map_err(|e| format!("Network error: {}", e))?; + + let status = response.status(); + + if !status.is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + log::error!("Supabase login failed with status {}: {}", status, error_text); + + return Err(if status.as_u16() == 400 || status.as_u16() == 401 { + "Invalid username or password".to_string() + } else if status.as_u16() == 403 { + "Access denied".to_string() + } else { + format!("Login failed: {}", status) + }); + } + + // Parse Supabase response format + let login_response: SupabaseLoginResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse Supabase response: {}", e))?; + + let email = login_response.user.email.clone(); + let username = login_response.user.user_metadata + .as_ref() + .and_then(|m| m.full_name.clone()) + .or_else(|| email.clone()) + .unwrap_or_else(|| username); + + log::info!("Supabase login successful for user: {}", username); + + Ok(LoginResponse { + token: login_response.access_token, + username, + email, + }) + } else { + // Spring Boot authentication flow + let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/')); + log::debug!("Spring Boot login URL: {}", login_url); + + let response = client + .post(&login_url) + .json(&serde_json::json!({ + "username": username, + "password": password, + })) + .send() + .await + .map_err(|e| format!("Network error: {}", e))?; + + let status = response.status(); + log::debug!("Spring Boot login response status: {}", status); + + if !status.is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + log::error!("Spring Boot login failed with status {}: {}", status, error_text); + + return Err(if status.as_u16() == 401 { + "Invalid username or password".to_string() + } else if status.as_u16() == 403 { + "Access denied".to_string() + } else { + format!("Login failed: {}", status) + }); + } + + // Parse Spring Boot response format + let login_response: SpringBootLoginResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse Spring Boot response: {}", e))?; + + log::info!("Spring Boot login successful for user: {}", login_response.user.username); + + Ok(LoginResponse { + token: login_response.session.access_token, + username: login_response.user.username, + email: login_response.user.email, + }) } - - // Parse Spring Boot response format - let login_response: SpringBootLoginResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse response: {}", e))?; - - log::info!("Login successful for user: {}", login_response.user.username); - - Ok(LoginResponse { - token: login_response.session.access_token, - username: login_response.user.username, - email: login_response.user.email, - }) } diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index 27a4a7dfd2..af2b149bc9 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -349,11 +349,11 @@ pub async fn start_backend( }; match mode { - ConnectionMode::Offline => { - add_log("🔌 Running in Offline mode - starting local backend".to_string()); + ConnectionMode::SaaS => { + add_log("☁️ Running in SaaS mode - starting local backend".to_string()); } - ConnectionMode::Server => { - add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string()); + ConnectionMode::SelfHosted => { + add_log("🌐 Running in Self-Hosted mode - starting local backend (for hybrid execution support)".to_string()); } } diff --git a/frontend/src-tauri/src/commands/connection.rs b/frontend/src-tauri/src/commands/connection.rs index 097d411b50..f2accec1f2 100644 --- a/frontend/src-tauri/src/commands/connection.rs +++ b/frontend/src-tauri/src/commands/connection.rs @@ -31,7 +31,7 @@ pub async fn get_connection_config( let mode = store .get(CONNECTION_MODE_KEY) .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or(ConnectionMode::Offline); + .unwrap_or(ConnectionMode::SaaS); let server_config: Option = store .get(SERVER_CONFIG_KEY) @@ -109,3 +109,22 @@ pub async fn is_first_launch(app_handle: AppHandle) -> Result { Ok(!setup_completed) } + +#[tauri::command] +pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> { + log::info!("Resetting setup completion flag"); + + let store = app_handle + .store(STORE_FILE) + .map_err(|e| format!("Failed to access store: {}", e))?; + + // Reset setup completion flag to force SetupWizard on next launch + store.set(FIRST_LAUNCH_KEY, serde_json::json!(false)); + + store + .save() + .map_err(|e| format!("Failed to save store: {}", e))?; + + log::info!("Setup completion flag reset successfully"); + Ok(()) +} diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index ecf2f0a8dd..0f58f2a1da 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ pub use files::{add_opened_file, clear_opened_files, get_opened_files}; pub use connection::{ get_connection_config, is_first_launch, + reset_setup_completion, set_connection_mode, }; pub use auth::{ diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index cc8e5b65fe..f3b8259dc4 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ use commands::{ get_user_info, is_first_launch, login, + reset_setup_completion, save_auth_token, save_user_info, set_connection_mode, @@ -85,6 +86,7 @@ pub fn run() { is_default_pdf_handler, set_as_default_pdf_handler, is_first_launch, + reset_setup_completion, check_backend_health, login, save_auth_token, diff --git a/frontend/src-tauri/src/state/connection_state.rs b/frontend/src-tauri/src/state/connection_state.rs index e6a924956e..61db28cbeb 100644 --- a/frontend/src-tauri/src/state/connection_state.rs +++ b/frontend/src-tauri/src/state/connection_state.rs @@ -4,13 +4,6 @@ use std::sync::Mutex; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum ConnectionMode { - Offline, - Server, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum ServerType { SaaS, SelfHosted, } @@ -18,7 +11,6 @@ pub enum ServerType { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { pub url: String, - pub server_type: ServerType, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -30,7 +22,7 @@ pub struct ConnectionState { impl Default for ConnectionState { fn default() -> Self { Self { - mode: ConnectionMode::Offline, + mode: ConnectionMode::SaaS, server_config: None, } } diff --git a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index cf13532f17..7e3324a97b 100644 --- a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -206,7 +206,6 @@ export const DrawingCanvas: React.FC = ({ if (!initialSignatureData) { ctx.clearRect(0, 0, canvas.width, canvas.height); setSavedSignatureData(null); - return; } diff --git a/frontend/src/core/components/annotation/shared/ImageUploader.tsx b/frontend/src/core/components/annotation/shared/ImageUploader.tsx index d339eb4be9..9b49451007 100644 --- a/frontend/src/core/components/annotation/shared/ImageUploader.tsx +++ b/frontend/src/core/components/annotation/shared/ImageUploader.tsx @@ -43,16 +43,18 @@ export const ImageUploader: React.FC = ({ - - {hint || t('sign.image.hint', 'Upload an image of your signature')} - + {hint && ( + + {hint} + + )} ); }; \ No newline at end of file diff --git a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index c700d4d056..c93a89be00 100644 --- a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -15,6 +15,7 @@ interface TextInputWithFontProps { disabled?: boolean; label?: string; placeholder?: string; + onAnyChange?: () => void; } export const TextInputWithFont: React.FC = ({ @@ -28,7 +29,8 @@ export const TextInputWithFont: React.FC = ({ onTextColorChange, disabled = false, label, - placeholder + placeholder, + onAnyChange }) => { const { t } = useTranslation(); const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString()); @@ -67,7 +69,10 @@ export const TextInputWithFont: React.FC = ({ label={label || t('sign.text.name', 'Signer name')} placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')} value={text} - onChange={(e) => onTextChange(e.target.value)} + onChange={(e) => { + onTextChange(e.target.value); + onAnyChange?.(); + }} disabled={disabled} required /> @@ -76,7 +81,10 @@ export const TextInputWithFont: React.FC = ({
+ {isEncrypted && ( + + { + e.stopPropagation(); + openEncryptedUnlockPrompt(file.id); + }} + > + + + + )} {/* Pin/Unpin icon */} void; + onUnlock: () => void; + onSkip: () => void; +} + +const EncryptedPdfUnlockModal = ({ + opened, + fileName, + password, + errorMessage, + isProcessing, + onPasswordChange, + onUnlock, + onSkip, +}: EncryptedPdfUnlockModalProps) => { + const { t } = useTranslation(); + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) { + onUnlock(); + } + }; + + return ( + + + {fileName} + + {t( + 'encryptedPdfUnlock.description', + 'This PDF is password protected. Enter the password so you can continue working with it.' + )} + + + + onPasswordChange(event.currentTarget.value)} + onKeyDown={handleKeyDown} + disabled={isProcessing} + autoFocus + /> + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + + + + + + + ); +}; + +export default EncryptedPdfUnlockModal; diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index 448b4d9130..a2767e1766 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -342,7 +342,7 @@ export const Tooltip: React.FC = ({
{header.logo || ( Stirling PDF diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx index e80a1fb07d..e8f37a2866 100644 --- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -53,11 +53,17 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => }; const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo); - if (summary) { + if (summary && summary.latest_version) { const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0; if (isNewerVersion) { setUpdateSummary(summary); + } else { + // Clear any existing update summary if user is on latest version + setUpdateSummary(null); } + } else { + // No update available (latest_version is null) - clear any existing update summary + setUpdateSummary(null); } setCheckingUpdate(false); }; @@ -128,83 +134,6 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => )} - - -
-
- - {t('settings.general.defaultToolPickerMode', 'Default tool picker mode')} - - - {t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')} - -
- updatePreference('defaultToolPanelMode', val as ToolPanelMode)} - data={[ - { label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' }, - { label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' }, - ]} - /> -
- -
-
- - {t('settings.general.autoUnzip', 'Auto-unzip API responses')} - - - {t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')} - -
- updatePreference('autoUnzip', event.currentTarget.checked)} - /> -
-
- - -
-
- - {t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')} - - - {t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')} - -
- { - const numValue = Number(fileLimitInput); - const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue; - setFileLimitInput(finalValue); - updatePreference('autoUnzipFileLimit', finalValue); - }} - min={1} - max={100} - step={1} - disabled={!preferences.autoUnzip} - style={{ width: 90 }} - /> -
-
-
-
- {/* Update Check Section */} {config?.appVersion && ( @@ -292,6 +221,111 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => )} + + +
+
+ + {t('settings.general.defaultToolPickerMode', 'Default tool picker mode')} + + + {t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')} + +
+ updatePreference('defaultToolPanelMode', val as ToolPanelMode)} + data={[ + { label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' }, + { label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' }, + ]} + /> +
+
+
+ + {t('settings.general.hideUnavailableTools', 'Hide unavailable tools')} + + + {t('settings.general.hideUnavailableToolsDescription', 'Remove tools that have been disabled by your server instead of showing them greyed out.')} + +
+ updatePreference('hideUnavailableTools', event.currentTarget.checked)} + /> +
+
+
+ + {t('settings.general.hideUnavailableConversions', 'Hide unavailable conversions')} + + + {t('settings.general.hideUnavailableConversionsDescription', 'Remove disabled conversion options in the Convert tool instead of showing them greyed out.')} + +
+ updatePreference('hideUnavailableConversions', event.currentTarget.checked)} + /> +
+ +
+
+ + {t('settings.general.autoUnzip', 'Auto-unzip API responses')} + + + {t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')} + +
+ updatePreference('autoUnzip', event.currentTarget.checked)} + /> +
+
+ + +
+
+ + {t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')} + + + {t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')} + +
+ { + const numValue = Number(fileLimitInput); + const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue; + setFileLimitInput(finalValue); + updatePreference('autoUnzipFileLimit', finalValue); + }} + min={1} + max={100} + step={1} + disabled={!preferences.autoUnzip} + style={{ width: 90 }} + /> +
+
+
+
+ {/* Update Modal */} {updateSummary && config?.appVersion && config?.machineType && ( - - - {t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")} - - - - - {t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")} - { case 'arabic': return 'Noto Sans Arabic, Arial Unicode MS, sans-serif'; case 'japanese': - return 'Meiryo, Yu Gothic, Hiragino Sans, sans-serif'; + return 'Noto Sans JP, Yu Gothic, Hiragino Sans, sans-serif'; case 'korean': - return 'Malgun Gothic, Dotum, sans-serif'; + return 'Noto Sans KR, Malgun Gothic, Dotum, sans-serif'; case 'chinese': - return 'SimSun, Microsoft YaHei, sans-serif'; + return 'Noto Sans SC, Microsoft YaHei, SimSun, sans-serif'; case 'thai': return 'Noto Sans Thai, Tahoma, sans-serif'; case 'roman': diff --git a/frontend/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/src/core/components/tools/convert/ConvertSettings.tsx index d9727b0789..50c01365bd 100644 --- a/frontend/src/core/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/core/components/tools/convert/ConvertSettings.tsx @@ -8,6 +8,7 @@ import { getConversionEndpoints } from "@app/data/toolsTaxonomy"; import { useFileSelection } from "@app/contexts/FileContext"; import { useFileState } from "@app/contexts/FileContext"; import { detectFileExtension } from "@app/utils/fileUtils"; +import { usePreferences } from "@app/contexts/PreferencesContext"; import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown"; import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings"; import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings"; @@ -47,8 +48,12 @@ const ConvertSettings = ({ const { setSelectedFiles } = useFileSelection(); const { state, selectors } = useFileState(); const activeFiles = state.files.ids; + const { preferences } = usePreferences(); - const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []); + const allEndpoints = useMemo(() => { + const endpoints = getConversionEndpoints(EXTENSION_TO_ENDPOINT); + return endpoints; + }, []); const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints); @@ -56,7 +61,8 @@ const ConvertSettings = ({ const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt]; if (!endpointKey) return false; - return endpointStatus[endpointKey] === true; + const isAvailable = endpointStatus[endpointKey] === true; + return isAvailable; }; // Enhanced FROM options with endpoint availability @@ -74,6 +80,12 @@ const ConvertSettings = ({ }; }); + // Filter out unavailable source formats if preference is enabled + let filteredOptions = baseOptions; + if (preferences.hideUnavailableConversions) { + filteredOptions = baseOptions.filter(opt => opt.enabled !== false); + } + // Add dynamic format option if current selection is a file- format if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) { const extension = parameters.fromExtension.replace('file-', ''); @@ -85,22 +97,32 @@ const ConvertSettings = ({ }; // Add the dynamic option at the beginning - return [dynamicOption, ...baseOptions]; + return [dynamicOption, ...filteredOptions]; } - return baseOptions; - }, [parameters.fromExtension, endpointStatus]); + return filteredOptions; + }, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]); // Enhanced TO options with endpoint availability const enhancedToOptions = useMemo(() => { if (!parameters.fromExtension) return []; const availableOptions = getAvailableToExtensions(parameters.fromExtension) || []; - return availableOptions.map(option => ({ - ...option, - enabled: isConversionAvailable(parameters.fromExtension, option.value) - })); - }, [parameters.fromExtension, endpointStatus]); + const enhanced = availableOptions.map(option => { + const enabled = isConversionAvailable(parameters.fromExtension, option.value); + return { + ...option, + enabled + }; + }); + + // Filter out unavailable conversions if preference is enabled + if (preferences.hideUnavailableConversions) { + return enhanced.filter(opt => opt.enabled !== false); + } + + return enhanced; + }, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]); const resetParametersToDefaults = () => { onParameterChange('imageOptions', { diff --git a/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx b/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx index 648f79267e..e89587c66a 100644 --- a/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { Text } from '@mantine/core'; +import { Text, Badge } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { Tooltip } from '@app/components/shared/Tooltip'; import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay'; import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar'; import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy'; -import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared'; +import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared'; interface CompactToolItemProps { id: string; @@ -17,7 +17,7 @@ interface CompactToolItemProps { const CompactToolItem: React.FC = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => { const { t } = useTranslation(); - const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool); + const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool); const categoryColor = getSubcategoryColor(tool.subcategoryId); const iconBg = getIconBackground(categoryColor, false); const iconClasses = 'tool-panel__fullscreen-list-icon'; @@ -57,9 +57,20 @@ const CompactToolItem: React.FC = ({ id, tool, isSelected, ) : null} - - {tool.name} - +
+ + {tool.name} + + {tool.versionStatus === 'alpha' && ( + + {t('toolPanel.alpha', 'Alpha')} + + )} +
{!disabled && (
@@ -73,9 +84,12 @@ const CompactToolItem: React.FC = ({ id, tool, isSelected, ); + const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason); + const disabledMessage = t(disabledKey, disabledFallback); + const tooltipContent = disabled ? ( - {t('toolPanel.fullscreen.comingSoon', 'Coming soon:')} {tool.description} + {disabledMessage} {tool.description} ) : (
diff --git a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx index 2ebf93b357..19866e41ca 100644 --- a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { Text } from '@mantine/core'; +import { Text, Badge } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay'; import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar'; import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy'; -import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared'; +import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared'; interface DetailedToolItemProps { id: string; @@ -15,7 +15,7 @@ interface DetailedToolItemProps { const DetailedToolItem: React.FC = ({ id, tool, isSelected, onClick }) => { const { t } = useTranslation(); - const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool); + const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool); const categoryColor = getSubcategoryColor(tool.subcategoryId); const iconBg = getIconBackground(categoryColor, true); @@ -34,6 +34,9 @@ const DetailedToolItem: React.FC = ({ id, tool, isSelecte iconNode = tool.icon; } + const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason); + const disabledMessage = t(disabledKey, disabledFallback); + return ( ); @@ -304,14 +331,33 @@ const SignSettings = ({ return button; }; + const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => { + const button = renderSaveButton(type, isReady, onClick); + if (!button) { + return null; + } + return ( + + {button} + + ); + }; + useEffect(() => { + if (signatureSource === 'saved' && !canUseSavedLibrary) { + setSignatureSource(effectiveDefaultSource); + return; + } if (signatureSource === 'saved') { return; } - if (signatureSource !== parameters.signatureType) { - setSignatureSource(parameters.signatureType); + const nextSource = allowedSignatureSources.includes(parameters.signatureType as SignatureSource) + ? (parameters.signatureType as SignatureSource) + : effectiveDefaultSource; + if (signatureSource !== nextSource) { + setSignatureSource(nextSource); } - }, [parameters.signatureType, signatureSource]); + }, [parameters.signatureType, signatureSource, allowedSignatureSources, effectiveDefaultSource, canUseSavedLibrary]); useEffect(() => { if (!disabled) { @@ -376,26 +422,47 @@ const SignSettings = ({ reader.readAsDataURL(file); }); + // Reset pause state and directly activate placement + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; setImageSignatureData(result); + + // Directly activate placement on image upload + if (typeof window !== 'undefined') { + window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY); + } else { + onActivateSignaturePlacement?.(); + } } catch (error) { console.error('Error reading file:', error); } } else if (!file) { setImageSignatureData(undefined); onDeactivateSignature?.(); + setImageSignatureData(undefined); + onDeactivateSignature?.(); } }; // Handle signature data changes - const handleCanvasSignatureChange = (data: string | null) => { + const handleCanvasSignatureChange = useCallback((data: string | null) => { const nextValue = data ?? undefined; - setCanvasSignatureData(prev => { - if (prev === nextValue) { - return prev; + setCanvasSignatureData(prevData => { + // Reset pause state and trigger placement for signature changes + // (onDrawingComplete handles initial activation) + if (prevData && prevData !== nextValue && nextValue) { + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; + // Directly activate placement on signature change + if (typeof window !== 'undefined') { + window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY); + } else { + onActivateSignaturePlacement?.(); + } } return nextValue; }); - }; + }, [onActivateSignaturePlacement]); const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]); const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]); @@ -588,9 +655,7 @@ const SignSettings = ({ const timer = window.setTimeout(() => { onActivateSignaturePlacement?.(); }, PLACEMENT_ACTIVATION_DELAY); - return () => { - window.clearTimeout(timer); - }; + return () => window.clearTimeout(timer); } onActivateSignaturePlacement?.(); @@ -601,6 +666,7 @@ const SignSettings = ({ isPlacementManuallyPaused, onActivateSignaturePlacement, onDeactivateSignature, + placementSignatureKey, ]); useEffect(() => { @@ -627,9 +693,7 @@ const SignSettings = ({ if (typeof window !== 'undefined') { const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY); - return () => { - window.clearTimeout(timer); - }; + return () => window.clearTimeout(timer); } trigger(); @@ -652,16 +716,29 @@ const SignSettings = ({ const timer = window.setTimeout(() => { onActivateSignaturePlacement?.(); }, FILE_SWITCH_ACTIVATION_DELAY); - return () => { - window.clearTimeout(timer); - }; + return () => window.clearTimeout(timer); } onActivateSignaturePlacement?.(); }, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]); + const sourceLabels: Record = { + canvas: translate('type.canvas', 'Draw'), + image: translate('type.image', 'Upload'), + text: translate('type.text', 'Type'), + saved: translate('type.saved', 'Saved'), + }; + + const sourceOptions = allowedSignatureSources.map(source => ({ + label: sourceLabels[source], + value: source, + })); + const renderSignatureBuilder = () => { if (signatureSource === 'saved') { + if (!canUseSavedLibrary) { + return null; + } return ( ); } @@ -691,9 +769,7 @@ const SignSettings = ({ disabled={disabled} initialSignatureData={canvasSignatureData} /> - - {renderSaveButton('canvas', hasCanvasSignature, handleSaveCanvasSignature)} - + {renderSaveButtonRow('canvas', hasCanvasSignature, handleSaveCanvasSignature)} ); } @@ -705,9 +781,7 @@ const SignSettings = ({ onImageChange={handleImageChange} disabled={disabled} /> - - {renderSaveButton('image', hasImageSignature, handleSaveImageSignature)} - + {renderSaveButtonRow('image', hasImageSignature, handleSaveImageSignature)} ); } @@ -724,35 +798,43 @@ const SignSettings = ({ textColor={parameters.textColor || '#000000'} onTextColorChange={(color) => onParameterChange('textColor', color)} disabled={disabled} + onAnyChange={() => { + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; + // Directly activate placement on text changes + if (typeof window !== 'undefined') { + window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY); + } else { + onActivateSignaturePlacement?.(); + } + }} /> - - {renderSaveButton('text', hasTextSignature, handleSaveTextSignature)} - + {renderSaveButtonRow('text', hasTextSignature, handleSaveTextSignature)} ); }; const placementInstructions = () => { if (signatureSource === 'saved') { - return t( - 'sign.instructions.saved', + return translate( + 'instructions.saved', 'Select a saved signature above, then click anywhere on the PDF to place it.' ); } if (parameters.signatureType === 'canvas') { - return t( - 'sign.instructions.canvas', + return translate( + 'instructions.canvas', 'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.' ); } if (parameters.signatureType === 'image') { - return t( - 'sign.instructions.image', + return translate( + 'instructions.image', 'After uploading your signature image, click anywhere on the PDF to place it.' ); } - return t( - 'sign.instructions.text', + return translate( + 'instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.' ); }; @@ -761,16 +843,16 @@ const SignSettings = ({ ? { color: isPlacementMode ? 'blue' : 'teal', title: isPlacementMode - ? t('sign.instructions.title', 'How to add your signature') - : t('sign.instructions.paused', 'Placement paused'), + ? translate('instructions.title', 'How to add your signature') + : translate('instructions.paused', 'Placement paused'), message: isPlacementMode ? placementInstructions() - : t('sign.instructions.resumeHint', 'Resume placement to click and add your signature.'), + : translate('instructions.resumeHint', 'Resume placement to click and add your signature.'), } : { color: 'yellow', - title: t('sign.instructions.title', 'How to add your signature'), - message: t('sign.instructions.noSignature', 'Create a signature above to enable placement tools.'), + title: translate('instructions.title', 'How to add your signature'), + message: translate('instructions.noSignature', 'Create a signature above to enable placement tools.'), }; const handlePausePlacement = () => { @@ -806,11 +888,11 @@ const SignSettings = ({ onActivateSignaturePlacement || onDeactivateSignature ? isPlacementMode ? ( - + - {t('sign.mode.pause', 'Pause placement')} + {translate('mode.pause', 'Pause placement')} ) : ( - + - {t('sign.mode.resume', 'Resume placement')} + {translate('mode.resume', 'Resume placement')} @@ -857,19 +939,16 @@ const SignSettings = ({ - {t('sign.step.createDesc', 'Choose how you want to create the signature')} + {translate('step.createDesc', 'Choose how you want to create the signature')} - handleSignatureSourceChange(value as SignatureSource)} - data={[ - { label: t('sign.type.canvas', 'Draw'), value: 'canvas' }, - { label: t('sign.type.image', 'Upload'), value: 'image' }, - { label: t('sign.type.text', 'Type'), value: 'text' }, - { label: t('sign.type.saved', 'Saved'), value: 'saved' }, - ]} - /> + {sourceOptions.length > 1 && ( + handleSignatureSourceChange(value as SignatureSource)} + data={sourceOptions} + /> + )} {renderSignatureBuilder()} @@ -877,10 +956,10 @@ const SignSettings = ({ - {t('sign.step.place', 'Place & save')} + {translate('step.place', 'Place & save')} - {t('sign.step.placeDesc', 'Position the signature on your PDF')} + {translate('step.placeDesc', 'Position the signature on your PDF')} @@ -911,7 +990,7 @@ const SignSettings = ({ onClose={() => setIsColorPickerOpen(false)} selectedColor={selectedColor} onColorChange={setSelectedColor} - title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')} + title={translate('canvas.colorPickerTitle', 'Choose stroke colour')} /> {onSave && ( @@ -921,7 +1000,7 @@ const SignSettings = ({ variant="filled" fullWidth > - {t('sign.applySignatures', 'Apply Signatures')} + {translate('applySignatures', 'Apply Signatures')} )} diff --git a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx index 5b756c8baa..7fc8d21f00 100644 --- a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Button } from "@mantine/core"; +import { Button, Badge } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Tooltip } from "@app/components/shared/Tooltip"; import { ToolIcon } from "@app/components/shared/ToolIcon"; @@ -12,6 +12,8 @@ import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay"; import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { ToolId } from "@app/types/toolId"; +import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; interface ToolButtonProps { id: ToolId; @@ -26,12 +28,14 @@ interface ToolButtonProps { const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => { const { t } = useTranslation(); - // Special case: read and multiTool are navigational tools that are always available - const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool'; + const { config } = useAppConfig(); + const premiumEnabled = config?.premiumEnabled; + const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow(); + const disabledReason = getToolDisabledReason(id, tool, toolAvailability, premiumEnabled); + const isUnavailable = disabledReason !== null; const { hotkeys } = useHotkeys(); const binding = hotkeys[id]; const { getToolNavigation } = useToolNavigation(); - const { isFavorite, toggleFavorite } = useToolWorkflow(); const fav = isFavorite(id as ToolId); const handleClick = (id: ToolId) => { @@ -48,8 +52,11 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, // Get navigation props for URL support (only if navigation is not disabled) const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null; + const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason); + const disabledMessage = t(disabledKey, disabledFallback); + const tooltipContent = isUnavailable - ? (Coming soon: {tool.description}) + ? ({disabledMessage} {tool.description}) : (
{tool.description} @@ -73,13 +80,25 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, opacity={isUnavailable ? 0.25 : 1} />
- +
+ + {tool.versionStatus === 'alpha' && ( + + {t('toolPanel.alpha', 'Alpha')} + + )} +
{matchedSynonym && ( { + const { t } = useTranslation(); + + return { + header: { + title: t("AddAttachmentsRequest.tooltip.header.title", "About Add Attachments") + }, + tips: [ + { + title: t("AddAttachmentsRequest.tooltip.description.title", "What it does"), + description: t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel."), + } + ] + }; +}; diff --git a/frontend/src/core/components/tooltips/useAutoRenameTips.ts b/frontend/src/core/components/tooltips/useAutoRenameTips.ts index 8b77e39b70..d46c86c75f 100644 --- a/frontend/src/core/components/tooltips/useAutoRenameTips.ts +++ b/frontend/src/core/components/tooltips/useAutoRenameTips.ts @@ -9,6 +9,10 @@ export const useAutoRenameTips = (): TooltipContent => { title: t("auto-rename.tooltip.header.title", "How Auto-Rename Works") }, tips: [ + { + title: t("auto-rename.tooltip.description.title", "What it does"), + description: t("auto-rename.description", "Automatically finds the title from your PDF content and uses it as the filename."), + }, { title: t("auto-rename.tooltip.howItWorks.title", "Smart Renaming"), bullets: [ diff --git a/frontend/src/core/components/tooltips/useMergeTips.tsx b/frontend/src/core/components/tooltips/useMergeTips.tsx index decd109ef0..741adccce5 100644 --- a/frontend/src/core/components/tooltips/useMergeTips.tsx +++ b/frontend/src/core/components/tooltips/useMergeTips.tsx @@ -5,6 +5,9 @@ export const useMergeTips = (): TooltipContent => { const { t } = useTranslation(); return { + header: { + title: t('merge.tooltip.header.title', 'Merge Settings Overview') + }, tips: [ { title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'), diff --git a/frontend/src/core/components/tooltips/useRemoveAnnotationsTips.ts b/frontend/src/core/components/tooltips/useRemoveAnnotationsTips.ts new file mode 100644 index 0000000000..edaa039236 --- /dev/null +++ b/frontend/src/core/components/tooltips/useRemoveAnnotationsTips.ts @@ -0,0 +1,20 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '@app/types/tips'; + +export const useRemoveAnnotationsTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("removeAnnotations.tooltip.header.title", "About Remove Annotations") + }, + tips: [ + { + title: t("removeAnnotations.tooltip.description.title", "What it does"), + description: t('removeAnnotations.info.description', + 'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.' + ), + } + ] + }; +}; diff --git a/frontend/src/core/components/viewer/BookmarkAPIBridge.tsx b/frontend/src/core/components/viewer/BookmarkAPIBridge.tsx new file mode 100644 index 0000000000..019b809202 --- /dev/null +++ b/frontend/src/core/components/viewer/BookmarkAPIBridge.tsx @@ -0,0 +1,73 @@ +import { useEffect, useMemo, useState, useCallback } from 'react'; +import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/react'; +import { BookmarkCapability } from '@embedpdf/plugin-bookmark'; +import { useViewer } from '@app/contexts/ViewerContext'; +import { BookmarkState, BookmarkAPIWrapper } from '@app/contexts/viewer/viewerBridges'; + +export function BookmarkAPIBridge() { + const { provides: bookmarkCapability } = useBookmarkCapability(); + const { registerBridge } = useViewer(); + const [state, setState] = useState({ + bookmarks: null, + isLoading: false, + error: null, + }); + + const fetchBookmarks = useCallback( + async (capability: BookmarkCapability) => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + try { + const task = capability.getBookmarks(); + const result = await task.toPromise(); + setState({ + bookmarks: result.bookmarks ?? [], + isLoading: false, + error: null, + }); + return result.bookmarks ?? []; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load bookmarks'; + setState({ + bookmarks: null, + isLoading: false, + error: message, + }); + throw error; + } + }, + [] + ); + + const api = useMemo(() => { + if (!bookmarkCapability) return null; + + return { + fetchBookmarks: () => fetchBookmarks(bookmarkCapability), + clearBookmarks: () => { + setState({ + bookmarks: null, + isLoading: false, + error: null, + }); + }, + setLocalBookmarks: (bookmarks, error = null) => { + setState({ + bookmarks, + isLoading: false, + error, + }); + }, + }; + }, [bookmarkCapability, fetchBookmarks]); + + useEffect(() => { + if (!api) return; + + registerBridge('bookmark', { + state, + api, + }); + }, [api, state, registerBridge]); + + return null; +} diff --git a/frontend/src/core/components/viewer/BookmarkSidebar.css b/frontend/src/core/components/viewer/BookmarkSidebar.css new file mode 100644 index 0000000000..da130a9570 --- /dev/null +++ b/frontend/src/core/components/viewer/BookmarkSidebar.css @@ -0,0 +1,196 @@ +/* Bookmark Sidebar - Modern styling to match AllTools section */ + +.bookmark-sidebar { + display: flex; + flex-direction: column; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--bg-toolbar) 96%, transparent), + color-mix(in srgb, var(--bg-background) 90%, transparent) + ); + border-left: 1px solid color-mix(in srgb, var(--border-subtle) 75%, transparent); + box-shadow: -2px 0 16px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 20%, transparent); + backdrop-filter: blur(12px); +} + +/* Header Section */ +.bookmark-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0.875rem; + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border-subtle); +} + +.bookmark-sidebar__header-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.bookmark-sidebar__header-icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--mantine-color-blue-6); + font-size: 1.1rem; +} + +/* Search Section */ +.bookmark-sidebar__search { + background: var(--tool-panel-search-bg, var(--bg-toolbar)); + border-bottom: 1px solid var(--tool-panel-search-border-bottom, var(--border-subtle)); + padding-top: 0.75rem !important; +} + +/* Content Section */ +.bookmark-sidebar__content { + padding-top: 0.5rem !important; +} + +.bookmark-sidebar__empty-state, +.bookmark-sidebar__error { + padding: 2rem 1rem; + text-align: center; +} + +.bookmark-sidebar__loading { + padding: 2rem 1rem !important; +} + +/* Bookmark List */ +.bookmark-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +/* Bookmark Items */ +.bookmark-item-wrapper { + display: flex; + flex-direction: column; +} + +.bookmark-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + border-radius: 0.65rem; + cursor: default; + transition: all 0.2s ease; + background: transparent; + border: 1px solid transparent; + width: 100%; + box-sizing: border-box; + position: relative; + text-align: left; + outline: none; +} + +.bookmark-item--clickable { + cursor: pointer; + background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent); +} + +.bookmark-item--clickable:hover { + background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar)); + border-color: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle)); + transform: translateX(2px); +} + +.bookmark-item--clickable:active { + transform: translateX(3px); +} + +.bookmark-item--clickable:focus-visible { + outline: 2px solid color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle)); + outline-offset: 2px; +} + +/* Expand Icon */ +.bookmark-item__expand-icon { + flex-shrink: 0; + transition: transform 0.2s ease; + width: 2rem; + height: 2rem; +} + +.bookmark-item:hover .bookmark-item__expand-icon { + transform: scale(1.05); +} + +/* Dash for items without children */ +.bookmark-item__dash { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + flex-shrink: 0; + color: var(--text-muted); + font-size: 1.25rem; + opacity: 0.5; + font-weight: 300; +} + +/* Content */ +.bookmark-item__content { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1; + min-width: 0; +} + +.bookmark-item__title { + line-height: 1.35; + overflow-wrap: anywhere; + word-break: break-word; + color: var(--text-primary); +} + +.bookmark-item--clickable:hover .bookmark-item__title { + color: var(--text-primary); +} + +.bookmark-item__page { + line-height: 1.3; + font-size: 0.75rem; +} + +/* Children Container */ +.bookmark-item__children { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.25rem; +} + +/* Smooth expand/collapse animation */ +@keyframes bookmark-fade-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.bookmark-item__children { + animation: bookmark-fade-in 0.2s ease; +} + +/* Accessibility & Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + .bookmark-item, + .bookmark-item__expand-icon, + .bookmark-item__children { + transition: none; + animation: none; + } +} + diff --git a/frontend/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/src/core/components/viewer/BookmarkSidebar.tsx new file mode 100644 index 0000000000..5c845878b8 --- /dev/null +++ b/frontend/src/core/components/viewer/BookmarkSidebar.tsx @@ -0,0 +1,551 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { Box, ScrollArea, Text, ActionIcon, Loader, Stack, TextInput, Button } from '@mantine/core'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { useViewer } from '@app/contexts/ViewerContext'; +import { PdfBookmarkObject, PdfActionType } from '@embedpdf/models'; +import BookmarksIcon from '@mui/icons-material/BookmarksRounded'; +import '@app/components/viewer/BookmarkSidebar.css'; + +interface BookmarkSidebarProps { + visible: boolean; + thumbnailVisible: boolean; + documentCacheKey?: string; + preloadCacheKeys?: string[]; +} + +const SIDEBAR_WIDTH = '15rem'; + +type BookmarkNode = PdfBookmarkObject & { id: string }; + +type BookmarkCacheStatus = 'idle' | 'loading' | 'success' | 'error'; + +interface BookmarkCacheEntry { + status: BookmarkCacheStatus; + bookmarks: PdfBookmarkObject[] | null; + error: string | null; + lastFetched: number | null; +} + +const createEntry = (overrides: Partial = {}): BookmarkCacheEntry => ({ + status: 'idle', + bookmarks: null, + error: null, + lastFetched: null, + ...overrides, +}); + +const resolvePageNumber = (bookmark: PdfBookmarkObject): number | null => { + const target = bookmark.target; + if (!target) return null; + + if (target.type === 'destination') { + return target.destination.pageIndex + 1; + } + + if (target.type === 'action') { + const action = target.action; + if ( + action.type === PdfActionType.Goto || + action.type === PdfActionType.RemoteGoto + ) { + return action.destination?.pageIndex !== undefined + ? action.destination.pageIndex + 1 + : null; + } + } + + return null; +}; + +export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, preloadCacheKeys = [] }: BookmarkSidebarProps) => { + const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer(); + const [expanded, setExpanded] = useState>({}); + const [searchTerm, setSearchTerm] = useState(''); + const [bookmarkSupport, setBookmarkSupport] = useState(() => hasBookmarkSupport()); + const [activeEntry, setActiveEntry] = useState(() => createEntry()); + const cacheRef = useRef>(new Map()); + const [fetchNonce, setFetchNonce] = useState(0); + const currentKeyRef = useRef(documentCacheKey ?? null); + + useEffect(() => { + currentKeyRef.current = documentCacheKey ?? null; + }, [documentCacheKey]); + + // Poll once until the bookmark bridge registers + useEffect(() => { + if (bookmarkSupport) return; + let cancelled = false; + const id = setInterval(() => { + if (!cancelled && hasBookmarkSupport()) { + setBookmarkSupport(true); + clearInterval(id); + } + }, 250); + + return () => { + cancelled = true; + clearInterval(id); + }; + }, [bookmarkSupport, hasBookmarkSupport]); + + // Reset UI and load cached entry (if any) when switching documents + useEffect(() => { + setExpanded({}); + setSearchTerm(''); + + if (!documentCacheKey) { + setActiveEntry(createEntry()); + bookmarkActions.clearBookmarks(); + return; + } + + const cached = cacheRef.current.get(documentCacheKey); + if (cached) { + setActiveEntry(cached); + if (cached.status === 'success') { + bookmarkActions.setLocalBookmarks(cached.bookmarks ?? [], null); + } else if (cached.status === 'error') { + bookmarkActions.setLocalBookmarks(cached.bookmarks ?? null, cached.error); + } else { + bookmarkActions.clearBookmarks(); + } + } else { + setActiveEntry(createEntry()); + bookmarkActions.clearBookmarks(); + } + }, [documentCacheKey, bookmarkActions]); + + // Keep cache bounded to the currently relevant keys + useEffect(() => { + const allowed = new Set(); + if (documentCacheKey) { + allowed.add(documentCacheKey); + } + preloadCacheKeys.forEach(key => { + if (key) { + allowed.add(key); + } + }); + + cacheRef.current.forEach((_entry, key) => { + if (!allowed.has(key)) { + cacheRef.current.delete(key); + } + }); + }, [documentCacheKey, preloadCacheKeys]); + + // Fetch bookmarks for the active document when needed + useEffect(() => { + if (!bookmarkSupport || !documentCacheKey) return; + + const key = documentCacheKey; + const cached = cacheRef.current.get(key); + if (cached && (cached.status === 'loading' || cached.status === 'success')) { + return; + } + + let cancelled = false; + const updateEntry = (entry: BookmarkCacheEntry) => { + cacheRef.current.set(key, entry); + if (!cancelled && currentKeyRef.current === key) { + setActiveEntry(entry); + } + }; + + updateEntry(createEntry({ + status: 'loading', + bookmarks: cached?.bookmarks ?? null, + lastFetched: cached?.lastFetched ?? null, + })); + + const fetchWithRetry = async () => { + const maxAttempts = 10; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const result = await bookmarkActions.fetchBookmarks(); + return Array.isArray(result) ? result : []; + } catch (error: any) { + const message = typeof error?.message === 'string' ? error.message.toLowerCase() : ''; + const notReady = + message.includes('document') && + message.includes('not') && + message.includes('open'); + + if (!notReady || attempt === maxAttempts - 1) { + throw error; + } + + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + return []; + }; + + fetchWithRetry() + .then(bookmarks => { + if (cancelled) return; + const entry = createEntry({ + status: 'success', + bookmarks, + lastFetched: Date.now(), + }); + updateEntry(entry); + if (currentKeyRef.current === key) { + bookmarkActions.setLocalBookmarks(bookmarks, null); + } + }) + .catch(error => { + if (cancelled) return; + const message = error instanceof Error ? error.message : 'Failed to load bookmarks'; + const fallback = cacheRef.current.get(key); + const entry = createEntry({ + status: 'error', + bookmarks: fallback?.bookmarks ?? null, + error: message, + lastFetched: fallback?.lastFetched ?? null, + }); + updateEntry(entry); + if (currentKeyRef.current === key) { + bookmarkActions.setLocalBookmarks(null, message); + } + }); + + return () => { + cancelled = true; + }; + }, [bookmarkSupport, documentCacheKey, fetchNonce, bookmarkActions]); + + const requestReload = useCallback(() => { + if (!documentCacheKey) return; + cacheRef.current.delete(documentCacheKey); + setActiveEntry(createEntry()); + bookmarkActions.clearBookmarks(); + setFetchNonce(value => value + 1); + }, [documentCacheKey, bookmarkActions]); + + const bookmarksWithIds = useMemo(() => { + const assignIds = (nodes: PdfBookmarkObject[], prefix = 'root'): BookmarkNode[] => { + if (!Array.isArray(nodes)) { + return []; + } + + return nodes.map((node, index) => { + const id = `${prefix}-${index}`; + return { + ...node, + id, + children: node.children ? assignIds(node.children, id) : undefined, + }; + }); + }; + + const bookmarks = Array.isArray(activeEntry.bookmarks) ? activeEntry.bookmarks : []; + return assignIds(bookmarks); + }, [activeEntry.bookmarks]); + + const currentStatus = activeEntry.status; + const isLocalLoading = bookmarkSupport && currentStatus === 'loading'; + const currentError = bookmarkSupport && currentStatus === 'error' ? activeEntry.error : null; + + const toggleNode = (nodeId: string) => { + setExpanded(prev => ({ + ...prev, + [nodeId]: !(prev[nodeId] ?? true), + })); + }; + + const expandAll = useCallback(() => { + const allExpanded: Record = {}; + const expandRecursive = (nodes: BookmarkNode[]) => { + nodes.forEach(node => { + if (node.children && node.children.length > 0) { + allExpanded[node.id] = true; + expandRecursive(node.children as BookmarkNode[]); + } + }); + }; + expandRecursive(bookmarksWithIds); + setExpanded(allExpanded); + }, [bookmarksWithIds]); + + const collapseAll = useCallback(() => { + const allCollapsed: Record = {}; + const collapseRecursive = (nodes: BookmarkNode[]) => { + nodes.forEach(node => { + if (node.children && node.children.length > 0) { + allCollapsed[node.id] = false; + collapseRecursive(node.children as BookmarkNode[]); + } + }); + }; + collapseRecursive(bookmarksWithIds); + setExpanded(allCollapsed); + }, [bookmarksWithIds]); + + const handleBookmarkClick = (bookmark: PdfBookmarkObject, event: React.MouseEvent) => { + const target = bookmark.target; + if (target?.type === 'action') { + const action = target.action; + if (action.type === PdfActionType.URI && action.uri) { + event.preventDefault(); + window.open(action.uri, '_blank', 'noopener'); + return; + } + if (action.type === PdfActionType.LaunchAppOrOpenFile && action.path) { + event.preventDefault(); + window.open(action.path, '_blank', 'noopener'); + return; + } + } + + const pageNumber = resolvePageNumber(bookmark); + if (pageNumber) { + scrollActions.scrollToPage(pageNumber); + } + }; + + const filteredBookmarks = useMemo(() => { + if (!searchTerm.trim()) return bookmarksWithIds; + const term = searchTerm.trim().toLowerCase(); + + const applyFilter = (nodeList: BookmarkNode[]): BookmarkNode[] => { + const results: BookmarkNode[] = []; + + for (const node of nodeList) { + const childMatches = node.children ? applyFilter(node.children as BookmarkNode[]) : []; + const matchesSelf = node.title?.toLowerCase().includes(term) ?? false; + + if (matchesSelf || childMatches.length > 0) { + results.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children }); + } + } + + return results; + }; + + return applyFilter(bookmarksWithIds); + }, [bookmarksWithIds, searchTerm]); + + const renderBookmarks = (nodes: BookmarkNode[], depth = 0) => { + if (!nodes || !Array.isArray(nodes)) { + return null; + } + + return nodes.map((node, _index) => { + if (!node || !node.id) { + return null; + } + + const hasChildren = Array.isArray(node.children) && node.children.length > 0; + const isNodeExpanded = expanded[node.id] ?? true; + + const pageNumber = resolvePageNumber(node); + + return ( +
0 ? `${depth * 0.75}rem` : '0', + }} + > +
handleBookmarkClick(node, event)} + role={pageNumber ? "button" : undefined} + tabIndex={pageNumber ? 0 : undefined} + onKeyDown={pageNumber ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleBookmarkClick(node, event as any); + } + } : undefined} + > + {hasChildren ? ( + { + event.stopPropagation(); + toggleNode(node.id); + }} + > + + + ) : ( + - + )} +
+ + {node.title || 'Untitled'} + + {pageNumber && ( + + Page {pageNumber} + + )} +
+
+ {hasChildren && isNodeExpanded && ( +
+ {renderBookmarks(node.children as BookmarkNode[], depth + 1)} +
+ )} +
+ ); + }); + }; + + const isSearchActive = searchTerm.trim().length > 0; + const hasBookmarks = bookmarksWithIds.length > 0; + const showBookmarkList = bookmarkSupport && documentCacheKey && filteredBookmarks.length > 0; + const showEmptyState = + bookmarkSupport && + documentCacheKey && + !isLocalLoading && + !currentError && + currentStatus === 'success' && + !hasBookmarks; + const showSearchEmpty = + bookmarkSupport && + documentCacheKey && + isSearchActive && + hasBookmarks && + filteredBookmarks.length === 0; + const showNoDocument = bookmarkSupport && !documentCacheKey; + + if (!visible) { + return null; + } + + return ( + +
+
+ + + + + Bookmarks + +
+ {bookmarkSupport && bookmarksWithIds.length > 0 && ( + <> + {Object.values(expanded).some(val => val === false) ? ( + + + + ) : ( + + + + )} + + )} +
+ + + setSearchTerm(event.currentTarget.value)} + leftSection={} + size="xs" + /> + + + + + {!bookmarkSupport && ( +
+ + Bookmark support is unavailable for this viewer. + +
+ )} + + {bookmarkSupport && showNoDocument && ( +
+ + Open a PDF to view its bookmarks. + +
+ )} + + {bookmarkSupport && documentCacheKey && currentError && ( + + + {currentError} + + + + )} + + {bookmarkSupport && documentCacheKey && isLocalLoading && ( + + + + Loading bookmarks... + + + )} + + {showEmptyState && ( +
+ + No bookmarks in this document + +
+ )} + + {showBookmarkList && ( +
+ {renderBookmarks(filteredBookmarks)} +
+ )} + + {showSearchEmpty && ( +
+ + No bookmarks match your search + +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index bf9af4eca5..9ca8bac0ae 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -8,13 +8,14 @@ import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF'; import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar'; import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar'; +import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar'; import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext'; import { useSignature } from '@app/contexts/SignatureContext'; import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers'; import NavigationWarningModal from '@app/components/shared/NavigationWarningModal'; import { isStirlingFile } from '@app/types/fileContext'; import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons'; -import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay'; +import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay'; import { useWheelZoom } from '@app/hooks/useWheelZoom'; export interface EmbedPdfViewerProps { @@ -38,7 +39,19 @@ const EmbedPdfViewerContent = ({ const pdfContainerRef = useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); - const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); + const { + isThumbnailSidebarVisible, + toggleThumbnailSidebar, + isBookmarkSidebarVisible, + zoomActions, + panActions: _panActions, + rotationActions: _rotationActions, + getScrollState, + getRotationState, + isAnnotationMode, + isAnnotationsVisible, + exportActions, + } = useViewer(); // Register viewer right-rail buttons useViewerRightRailButtons(); @@ -69,7 +82,8 @@ const EmbedPdfViewerContent = ({ // Check if we're in signature mode OR viewer annotation mode const { selectedTool } = useNavigationState(); - const isSignatureMode = selectedTool === 'sign'; + // Tools that use the stamp/signature placement system with hover preview + const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage'; // Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible; @@ -128,6 +142,42 @@ const EmbedPdfViewerContent = ({ } }, [previewFile, fileWithUrl]); + const bookmarkCacheKey = React.useMemo(() => { + if (currentFile && isStirlingFile(currentFile)) { + return currentFile.fileId; + } + + if (previewFile) { + const uniquePreviewId = `${previewFile.name}-${previewFile.size}-${previewFile.lastModified ?? 'na'}`; + return `preview-${uniquePreviewId}`; + } + + if (effectiveFile?.url) { + return effectiveFile.url; + } + + if (effectiveFile?.file instanceof File) { + const fileObj = effectiveFile.file; + return `file-${fileObj.name}-${fileObj.size}-${fileObj.lastModified ?? 'na'}`; + } + + return undefined; + }, [currentFile, effectiveFile, previewFile]); + + // Generate cache keys for all active files to enable preloading + const allBookmarkCacheKeys = React.useMemo(() => { + if (previewFile) { + return [bookmarkCacheKey].filter(Boolean) as string[]; + } + + return activeFiles.map(file => { + if (isStirlingFile(file)) { + return file.fileId; + } + return undefined; + }).filter(Boolean) as string[]; + }, [activeFiles, previewFile, bookmarkCacheKey]); + useWheelZoom({ ref: viewerRef, onZoomIn: zoomActions.zoomIn, @@ -219,6 +269,10 @@ const EmbedPdfViewerContent = ({ } }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]); + const sidebarWidthRem = 15; + const totalRightMargin = + (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0); + return ( - + {/* Navigation Warning Modal */} {!previewFile && ( diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index f20b68b9a9..c08eabcbb7 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -19,6 +19,7 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react'; import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react'; import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark'; // Import annotation plugins import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; @@ -39,6 +40,7 @@ import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge'; import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge'; import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes'; import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge'; +import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge'; interface LocalEmbedPDFProps { file?: File | Blob; @@ -138,6 +140,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur // Register thumbnail plugin for page thumbnails createPluginRegistration(ThumbnailPluginPackage), + // Register bookmark plugin for PDF outline support + createPluginRegistration(BookmarkPluginPackage), + // Register rotate plugin createPluginRegistration(RotatePluginPackage), @@ -272,6 +277,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur {enableAnnotations && } {enableAnnotations && } + = new Set()): string | undefined => { if (!value || depth > 6) return undefined; @@ -56,7 +48,7 @@ const extractDataUrl = (value: unknown, depth = 0, visited: Set = new S const createTextStampImage = ( config: SignParameters, displaySize?: { width: number; height: number } | null -): TextStampImageResult | null => { +): { dataUrl: string; pixelWidth: number; pixelHeight: number; displayWidth: number; displayHeight: number } | null => { const text = (config.signerName ?? '').trim(); if (!text) { return null; @@ -207,7 +199,6 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI } }, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]); - // Enable keyboard deletion of selected annotations useEffect(() => { // Always enable delete key when we have annotation API and are in sign mode @@ -436,6 +427,60 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI }; }, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]); + useEffect(() => { + if (!annotationApi?.onAnnotationEvent) { + return; + } + + const unsubscribe = annotationApi.onAnnotationEvent(event => { + if (event.type !== 'create' && event.type !== 'update') { + return; + } + + const annotation: any = event.annotation; + const annotationId: string | undefined = annotation?.id; + if (!annotationId) { + return; + } + + const directData = + extractDataUrl(annotation.imageSrc) || + extractDataUrl(annotation.imageData) || + extractDataUrl(annotation.appearance) || + extractDataUrl(annotation.stampData) || + extractDataUrl(annotation.contents) || + extractDataUrl(annotation.data) || + extractDataUrl(annotation.customData) || + extractDataUrl(annotation.asset); + + const dataToStore = directData || lastStampImageRef.current; + if (dataToStore) { + storeImageData(annotationId, dataToStore); + } + }); + + return () => { + unsubscribe?.(); + }; + }, [annotationApi, storeImageData]); + + useEffect(() => { + if (!isPlacementMode) { + return; + } + + let cancelled = false; + configureStampDefaults().catch((error) => { + if (!cancelled) { + console.error('Error updating signature defaults:', error); + } + }); + + return () => { + cancelled = true; + }; + }, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]); + return null; // This is a bridge component with no UI }); diff --git a/frontend/src/core/components/viewer/StampPlacementOverlay.tsx b/frontend/src/core/components/viewer/StampPlacementOverlay.tsx new file mode 100644 index 0000000000..92417e53b9 --- /dev/null +++ b/frontend/src/core/components/viewer/StampPlacementOverlay.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Box } from '@mantine/core'; +import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters'; +import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePreview'; +import { useSignature } from '@app/contexts/SignatureContext'; +import { + MAX_PREVIEW_WIDTH_RATIO, + MAX_PREVIEW_HEIGHT_RATIO, + MAX_PREVIEW_WIDTH_REM, + MAX_PREVIEW_HEIGHT_REM, + MIN_SIGNATURE_DIMENSION_REM, + OVERLAY_EDGE_PADDING_REM, +} from '@app/constants/signConstants'; + +// Convert rem to pixels using browser's base font size (typically 16px) +const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize); + +interface StampPlacementOverlayProps { + containerRef: React.RefObject; + isActive: boolean; + signatureConfig: SignParameters | null; +} + +export const StampPlacementOverlay: React.FC = ({ + containerRef, + isActive, + signatureConfig, +}) => { + const [preview, setPreview] = useState(null); + const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null); + const { setPlacementPreviewSize } = useSignature(); + + useEffect(() => { + let cancelled = false; + + const buildPreview = async () => { + try { + const value = await buildSignaturePreview(signatureConfig ?? null); + if (!cancelled) { + setPreview(value); + } + } catch (error) { + console.error('Failed to build signature preview:', error); + if (!cancelled) { + setPreview(null); + } + } + }; + + buildPreview(); + + return () => { + cancelled = true; + }; + }, [signatureConfig]); + + useEffect(() => { + const element = containerRef.current; + if (!isActive || !element) { + setCursor(null); + return; + } + + const handleMove = (event: MouseEvent) => { + const rect = element.getBoundingClientRect(); + setCursor({ + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }); + }; + + const handleLeave = () => setCursor(null); + + element.addEventListener('mousemove', handleMove); + element.addEventListener('mouseleave', handleLeave); + + return () => { + element.removeEventListener('mousemove', handleMove); + element.removeEventListener('mouseleave', handleLeave); + }; + }, [containerRef, isActive]); + + const scaledSize = useMemo(() => { + if (!preview || !containerRef.current) { + return null; + } + + const container = containerRef.current; + const containerWidth = container.clientWidth || 1; + const containerHeight = container.clientHeight || 1; + + const maxWidth = Math.min(containerWidth * MAX_PREVIEW_WIDTH_RATIO, remToPx(MAX_PREVIEW_WIDTH_REM)); + const maxHeight = Math.min(containerHeight * MAX_PREVIEW_HEIGHT_RATIO, remToPx(MAX_PREVIEW_HEIGHT_REM)); + + const scale = Math.min( + 1, + maxWidth / Math.max(preview.width, 1), + maxHeight / Math.max(preview.height, 1) + ); + + return { + width: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.width * scale), + height: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.height * scale), + }; + }, [preview, containerRef]); + + useEffect(() => { + if (!isActive || !scaledSize) { + setPlacementPreviewSize(null); + } else { + setPlacementPreviewSize(scaledSize); + } + }, [isActive, scaledSize, setPlacementPreviewSize]); + + useEffect(() => { + return () => { + setPlacementPreviewSize(null); + }; + }, [setPlacementPreviewSize]); + + const display = useMemo(() => { + if (!preview || !scaledSize || !cursor || !containerRef.current) { + return null; + } + + const container = containerRef.current; + const containerWidth = container.clientWidth || 1; + const containerHeight = container.clientHeight || 1; + + const width = scaledSize.width; + const height = scaledSize.height; + const edgePadding = remToPx(OVERLAY_EDGE_PADDING_REM); + + const clampedLeft = Math.max(edgePadding, Math.min(cursor.x - width / 2, containerWidth - width - edgePadding)); + const clampedTop = Math.max(edgePadding, Math.min(cursor.y - height / 2, containerHeight - height - edgePadding)); + + return { + left: clampedLeft, + top: clampedTop, + width, + height, + dataUrl: preview.dataUrl, + }; + }, [preview, scaledSize, cursor, containerRef]); + + if (!isActive || !display || !preview) { + return null; + } + + return ( + + ); +}; diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx index cabc8352cd..c853079d94 100644 --- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx +++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx @@ -19,6 +19,7 @@ export function useViewerRightRailButtons() { const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left'); const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right'); const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar'); + const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks'); const viewerButtons = useMemo(() => { return [ @@ -111,6 +112,17 @@ export function useViewerRightRailButtons() { viewer.toggleThumbnailSidebar(); } }, + { + id: 'viewer-toggle-bookmarks', + icon: , + tooltip: bookmarkLabel, + ariaLabel: bookmarkLabel, + section: 'top' as const, + order: 55, + onClick: () => { + viewer.toggleBookmarkSidebar(); + } + }, { id: 'viewer-annotation-controls', section: 'top' as const, @@ -120,7 +132,7 @@ export function useViewerRightRailButtons() { ) } ]; - }, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]); + }, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel]); useRightRailButtons(viewerButtons); } diff --git a/frontend/src/core/constants/convertConstants.ts b/frontend/src/core/constants/convertConstants.ts index 0dc6e37263..3e25a737ec 100644 --- a/frontend/src/core/constants/convertConstants.ts +++ b/frontend/src/core/constants/convertConstants.ts @@ -33,7 +33,9 @@ export const CONVERSION_ENDPOINTS = { 'pdf-pdfa': '/api/v1/convert/pdf/pdfa', 'html-pdf': '/api/v1/convert/html/pdf', 'markdown-pdf': '/api/v1/convert/markdown/pdf', - 'eml-pdf': '/api/v1/convert/eml/pdf' + 'eml-pdf': '/api/v1/convert/eml/pdf', + 'pdf-text-editor': '/api/v1/convert/pdf/text-editor', + 'text-editor-pdf': '/api/v1/convert/text-editor/pdf' } as const; export const ENDPOINT_NAMES = { @@ -52,7 +54,9 @@ export const ENDPOINT_NAMES = { 'pdf-pdfa': 'pdf-to-pdfa', 'html-pdf': 'html-to-pdf', 'markdown-pdf': 'markdown-to-pdf', - 'eml-pdf': 'eml-to-pdf' + 'eml-pdf': 'eml-to-pdf', + 'pdf-text-editor': 'pdf-to-text-editor', + 'text-editor-pdf': 'text-editor-to-pdf' } as const; diff --git a/frontend/src/core/constants/convertSupportedFornats.ts b/frontend/src/core/constants/convertSupportedFornats.ts index 86138c4e68..dc46b17e20 100644 --- a/frontend/src/core/constants/convertSupportedFornats.ts +++ b/frontend/src/core/constants/convertSupportedFornats.ts @@ -5,7 +5,7 @@ export const CONVERT_SUPPORTED_FORMATS = [ // OpenDocument 'odt', 'ott', 'ods', 'ots', 'odp', 'otp', 'odg', 'otg', // Text formats - 'txt', 'text', 'xml', 'rtf', 'html', 'lwp', 'md', + 'txt', 'text', 'xml', 'rtf', 'html', 'lwp', 'md', 'json', // Images 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'tif', 'tiff', 'pbm', 'pgm', 'ppm', 'ras', 'xbm', 'xpm', 'svg', 'svm', 'wmf', 'webp', // StarOffice diff --git a/frontend/src/core/contexts/AppConfigContext.test.tsx b/frontend/src/core/contexts/AppConfigContext.test.tsx new file mode 100644 index 0000000000..63a953d8a0 --- /dev/null +++ b/frontend/src/core/contexts/AppConfigContext.test.tsx @@ -0,0 +1,288 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { waitFor, renderHook, act } from '@testing-library/react'; +import { AppConfigProvider, useAppConfig } from '@app/contexts/AppConfigContext'; +import apiClient from '@app/services/apiClient'; +import { ReactNode } from 'react'; + +// Mock apiClient +vi.mock('@app/services/apiClient'); + +describe('AppConfigContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock window.location.pathname + Object.defineProperty(window, 'location', { + value: { pathname: '/' }, + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('should fetch and provide app config on non-auth pages', async () => { + const mockConfig = { + enableLogin: false, + appNameNavbar: 'Stirling PDF', + languages: ['en-US', 'en-GB'], + }; + + vi.mocked(apiClient.get).mockResolvedValueOnce({ + status: 200, + data: mockConfig, + } as any); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + // Initially loading + expect(result.current.loading).toBe(true); + expect(result.current.config).toBeNull(); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual(mockConfig); + expect(result.current.error).toBeNull(); + }); + + expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', { + suppressErrorToast: true, + }); + }); + + it('should skip fetch on auth pages and use default config', async () => { + // Mock being on login page + Object.defineProperty(window, 'location', { + value: { pathname: '/login' }, + writable: true, + }); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + }); + + // Should NOT call API on auth pages + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('should handle 401 error gracefully', async () => { + const mockError = Object.assign(new Error('Unauthorized'), { + response: { status: 401, data: {} }, + }); + vi.mocked(apiClient.get).mockRejectedValueOnce(mockError); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + // 401 should be handled gracefully, error may be null or set + }); + }); + + it('should handle network errors', async () => { + const errorMessage = 'Network error occurred'; + const mockError = new Error(errorMessage); + // Network errors don't have response property + // Mock rejection for all retry attempts (default is 3 attempts) + vi.mocked(apiClient.get) + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockError); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + expect(result.current.error).toBe(errorMessage); + }); + }); + + it('should skip fetch on signup page', async () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/signup' }, + writable: true, + }); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + }); + + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('should skip fetch on auth callback page', async () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/auth/callback' }, + writable: true, + }); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + }); + + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('should skip fetch on invite accept page', async () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/invite/abc123' }, + writable: true, + }); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + }); + + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('should refetch config when jwt-available event is triggered', async () => { + const initialConfig = { + enableLogin: true, + appNameNavbar: 'Stirling PDF', + }; + + const updatedConfig = { + enableLogin: true, + appNameNavbar: 'Stirling PDF', + isAdmin: true, + enableAnalytics: true, + }; + + // First call returns initial config + vi.mocked(apiClient.get).mockResolvedValueOnce({ + status: 200, + data: initialConfig, + } as any); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.config).toEqual(initialConfig); + }); + + // Setup second call for refetch + vi.mocked(apiClient.get).mockResolvedValueOnce({ + status: 200, + data: updatedConfig, + } as any); + + // Trigger jwt-available event wrapped in act + await act(async () => { + window.dispatchEvent(new CustomEvent('jwt-available')); + // Wait a tick for event handler to run + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await waitFor(() => { + expect(result.current.config).toEqual(updatedConfig); + }); + + expect(apiClient.get).toHaveBeenCalledTimes(2); + }); + + it('should provide refetch function', async () => { + const mockConfig = { + enableLogin: false, + appNameNavbar: 'Test App', + }; + + vi.mocked(apiClient.get).mockResolvedValue({ + status: 200, + data: mockConfig, + } as any); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.config).toEqual(mockConfig); + }); + + // Call refetch wrapped in act + await act(async () => { + await result.current.refetch(); + }); + + expect(apiClient.get).toHaveBeenCalledTimes(2); + }); + + it('should not fetch twice without force flag', async () => { + const mockConfig = { + enableLogin: false, + }; + + vi.mocked(apiClient.get).mockResolvedValue({ + status: 200, + data: mockConfig, + } as any); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.config).toEqual(mockConfig); + }); + + // Should only be called once (no duplicate fetches) + expect(apiClient.get).toHaveBeenCalledTimes(1); + }); + + it('should handle initial config prop', async () => { + const initialConfig = { + enableLogin: false, + appNameNavbar: 'Initial App', + }; + + const customWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAppConfig(), { + wrapper: customWrapper, + }); + + // With blocking mode (default), should still fetch even with initial config + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Should still make API call + expect(apiClient.get).toHaveBeenCalled(); + }); + + it('should use suppressErrorToast for all config requests', async () => { + const mockConfig = { enableLogin: true }; + + vi.mocked(apiClient.get).mockResolvedValueOnce({ + status: 200, + data: mockConfig, + } as any); + + renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', { + suppressErrorToast: true, + }); + }); + }); +}); diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 2f672da26a..73e50e6edd 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -114,7 +114,8 @@ export const AppConfigProvider: React.FC = ({ } // apiClient automatically adds JWT header if available via interceptors - const response = await apiClient.get('/api/v1/config/app-config', !isBlockingMode ? { suppressErrorToast: true } : undefined); + // Always suppress error toast - we handle 401 errors locally + const response = await apiClient.get('/api/v1/config/app-config', { suppressErrorToast: true }); const data = response.data; console.debug('[AppConfig] Config fetched successfully:', data); @@ -159,8 +160,25 @@ export const AppConfigProvider: React.FC = ({ }, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]); useEffect(() => { - // Always try to fetch config to check if login is disabled - // The endpoint should be public and return proper JSON + // Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*) + // Config will be fetched after successful authentication via jwt-available event + const currentPath = window.location.pathname; + const isAuthPage = currentPath.includes('/login') || + currentPath.includes('/signup') || + currentPath.includes('/auth/callback') || + currentPath.includes('/invite/'); + + // On auth pages, always skip the config fetch + // The config will be fetched after authentication via jwt-available event + if (isAuthPage) { + console.debug('[AppConfig] On auth page - using default config, skipping fetch'); + setConfig({ enableLogin: true }); + setHasResolvedConfig(true); + setLoading(false); + return; + } + + // On non-auth pages, fetch config (will validate JWT if present) if (autoFetch) { fetchConfig(); } diff --git a/frontend/src/core/contexts/FileContext.tsx b/frontend/src/core/contexts/FileContext.tsx index 56f1dcabde..378d43050e 100644 --- a/frontend/src/core/contexts/FileContext.tsx +++ b/frontend/src/core/contexts/FileContext.tsx @@ -12,7 +12,7 @@ * Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation). */ -import { useReducer, useCallback, useEffect, useRef, useMemo } from 'react'; +import { useReducer, useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { FileContextProviderProps, FileContextSelectors, @@ -22,17 +22,27 @@ import { FileId, StirlingFileStub, StirlingFile, + createStirlingFile, } from '@app/types/fileContext'; // Import modular components import { fileContextReducer, initialFileContextState } from '@app/contexts/file/FileReducer'; import { createFileSelectors } from '@app/contexts/file/fileSelectors'; -import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from '@app/contexts/file/fileActions'; +import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions, createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions'; import { FileLifecycleManager } from '@app/contexts/file/lifecycle'; import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts'; import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext'; import { useZipConfirmation } from '@app/hooks/useZipConfirmation'; import ZipWarningModal from '@app/components/shared/ZipWarningModal'; +import EncryptedPdfUnlockModal from '@app/components/shared/EncryptedPdfUnlockModal'; +import { useTranslation } from 'react-i18next'; +import { alert } from '@app/components/toast'; +import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData'; +import type { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters'; +import apiClient from '@app/services/apiClient'; +import { processResponse } from '@app/utils/toolResponseProcessor'; +import { ToolOperation } from '@app/types/file'; +import { handlePasswordError } from '@app/utils/toolErrorHandler'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -63,6 +73,98 @@ function FileContextInner({ lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch); } const lifecycleManager = lifecycleManagerRef.current; + const { t } = useTranslation(); + + const [encryptedQueue, setEncryptedQueue] = useState([]); + const [activeEncryptedFileId, setActiveEncryptedFileId] = useState(null); + const [unlockPassword, setUnlockPassword] = useState(''); + const [unlockError, setUnlockError] = useState(null); + const [isUnlocking, setIsUnlocking] = useState(false); + const dismissedEncryptedFilesRef = useRef>(new Set()); + const observedFileIdsRef = useRef>(new Set()); + + const enqueueEncryptedFiles = useCallback((fileIds: FileId[]) => { + if (fileIds.length === 0) return; + setEncryptedQueue(prevQueue => { + const existing = new Set(prevQueue); + const next = [...prevQueue]; + for (const id of fileIds) { + if (dismissedEncryptedFilesRef.current.has(id)) continue; + if (id === activeEncryptedFileId) continue; + if (existing.has(id)) continue; + existing.add(id); + next.push(id); + } + return next; + }); + }, [activeEncryptedFileId]); + + useEffect(() => { + const previousIds = observedFileIdsRef.current; + const nextIds = new Set(state.files.ids); + const newEncryptedIds: FileId[] = []; + + for (const id of state.files.ids) { + if (!previousIds.has(id)) { + const stub = state.files.byId[id]; + if ((stub?.versionNumber ?? 1) <= 1 && stub?.processedFile?.isEncrypted) { + newEncryptedIds.push(id); + } + } + } + + if (newEncryptedIds.length > 0) { + enqueueEncryptedFiles(newEncryptedIds); + } + + observedFileIdsRef.current = nextIds; + }, [state.files.ids, state.files.byId, enqueueEncryptedFiles]); + + useEffect(() => { + if (!activeEncryptedFileId && encryptedQueue.length > 0) { + setActiveEncryptedFileId(encryptedQueue[0]); + setEncryptedQueue(prev => prev.slice(1)); + } + }, [activeEncryptedFileId, encryptedQueue]); + + useEffect(() => { + if (activeEncryptedFileId && !state.files.ids.includes(activeEncryptedFileId)) { + setActiveEncryptedFileId(null); + } + }, [activeEncryptedFileId, state.files.ids]); + + useEffect(() => { + setUnlockPassword(''); + setUnlockError(null); + }, [activeEncryptedFileId]); + + const handleUnlockSkip = useCallback(() => { + if (activeEncryptedFileId) { + dismissedEncryptedFilesRef.current.add(activeEncryptedFileId); + } + setActiveEncryptedFileId(null); + }, [activeEncryptedFileId]); + + const promptEncryptedUnlock = useCallback((fileId: FileId) => { + const stub = stateRef.current.files.byId[fileId]; + if (!stub?.processedFile?.isEncrypted) { + return; + } + + dismissedEncryptedFilesRef.current.delete(fileId); + + setEncryptedQueue(prevQueue => prevQueue.filter(id => id !== fileId)); + + setActiveEncryptedFileId(currentActiveId => { + if (currentActiveId && currentActiveId !== fileId) { + setEncryptedQueue(prevQueue => { + const withoutDuplicates = prevQueue.filter(id => id !== currentActiveId && id !== fileId); + return [currentActiveId, ...withoutDuplicates]; + }); + } + return fileId; + }); + }, []); // Create stable selectors (memoized once to avoid re-renders) const selectors = useMemo(() => @@ -131,6 +233,80 @@ function FileContextInner({ return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch); }, []); + const runAutomaticPasswordRemoval = useCallback(async (fileId: FileId, password: string): Promise => { + const file = filesRef.current.get(fileId); + const parentStub = stateRef.current.files.byId[fileId]; + + if (!file || !parentStub) { + throw new Error(t('encryptedPdfUnlock.missingFile', 'The selected file is no longer available.')); + } + + const params: RemovePasswordParameters = { password }; + const formData = buildRemovePasswordFormData(params, file); + + const response = await apiClient.post('/api/v1/security/remove-password', formData, { + responseType: 'blob', + suppressErrorToast: true // Handle errors in modal UI instead of toast + }); + const responseFiles = await processResponse(response.data, [file]); + + const unlockedFile = responseFiles[0]; + if (!unlockedFile) { + throw new Error(t('encryptedPdfUnlock.emptyResponse', 'Password removal did not produce a file.')); + } + + const processedMetadata = await generateProcessedFileMetadata(unlockedFile); + const thumbnail = processedMetadata?.thumbnailUrl; + + const operation: ToolOperation = { + toolId: 'removePassword', + timestamp: Date.now() + }; + + const childStub = createChildStub(parentStub, operation, unlockedFile, thumbnail, processedMetadata); + const stirlingUnlockedFile = createStirlingFile(unlockedFile, childStub.id); + + await consumeFilesWrapper([fileId], [stirlingUnlockedFile], [childStub]); + }, [consumeFilesWrapper, t]); + + const handleUnlockSubmit = useCallback(async () => { + if (!activeEncryptedFileId) return; + if (!unlockPassword.trim()) { + setUnlockError(t('encryptedPdfUnlock.required', 'Enter the password to continue.')); + return; + } + + setIsUnlocking(true); + setUnlockError(null); + try { + await runAutomaticPasswordRemoval(activeEncryptedFileId, unlockPassword.trim()); + const fileName = stateRef.current.files.byId[activeEncryptedFileId]?.name; + alert({ + alertType: 'success', + title: t('encryptedPdfUnlock.successTitle', 'Password removed'), + body: fileName + ? t('encryptedPdfUnlock.successBodyWithName', { + defaultValue: 'Removed password from {{fileName}}', + fileName, + }) + : t('encryptedPdfUnlock.successBody', 'Password removed successfully.'), + expandable: false, + isPersistentPopup: false, + }); + dismissedEncryptedFilesRef.current.delete(activeEncryptedFileId); + setActiveEncryptedFileId(null); + } catch (error) { + const errorMessage = await handlePasswordError( + error, + t('encryptedPdfUnlock.incorrectPassword', 'Incorrect password'), + t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.') + ); + setUnlockError(errorMessage); + } finally { + setIsUnlocking(false); + } + }, [activeEncryptedFileId, unlockPassword, runAutomaticPasswordRemoval, t]); + const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise => { return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB); }, [indexedDB]); @@ -199,7 +375,8 @@ function FileContextInner({ trackBlobUrl: lifecycleManager.trackBlobUrl, cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef), scheduleCleanup: (fileId: FileId, delay?: number) => - lifecycleManager.scheduleCleanup(fileId, delay, stateRef) + lifecycleManager.scheduleCleanup(fileId, delay, stateRef), + openEncryptedUnlockPrompt: promptEncryptedUnlock }), [ baseActions, addRawFiles, @@ -211,7 +388,8 @@ function FileContextInner({ pinFileWrapper, unpinFileWrapper, indexedDB, - enablePersistence + enablePersistence, + promptEncryptedUnlock ]); // Split context values to minimize re-renders @@ -225,6 +403,9 @@ function FileContextInner({ dispatch }), [actions]); + const activeEncryptedStub = activeEncryptedFileId ? state.files.byId[activeEncryptedFileId] : undefined; + const isUnlockModalOpen = Boolean(activeEncryptedFileId && activeEncryptedStub); + // Persistence loading disabled - files only loaded on explicit user action // useEffect(() => { // if (!enablePersistence || !indexedDB) return; @@ -251,6 +432,16 @@ function FileContextInner({ fileCount={confirmationState.fileCount} zipFileName={confirmationState.fileName} /> + ); diff --git a/frontend/src/core/contexts/NavigationContext.tsx b/frontend/src/core/contexts/NavigationContext.tsx index 377ee43e6c..500a6db5e1 100644 --- a/frontend/src/core/contexts/NavigationContext.tsx +++ b/frontend/src/core/contexts/NavigationContext.tsx @@ -121,10 +121,11 @@ export const NavigationProvider: React.FC<{ hasUnsavedChanges }); - // If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation + // If we're leaving pageEditor, viewer, or custom workbench and have unsaved changes, request navigation const leavingWorkbenchWithChanges = (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) || - (state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges); + (state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges) || + (state.workbench.startsWith('custom:') && workbench !== state.workbench && hasUnsavedChanges); if (leavingWorkbenchWithChanges) { // Update state to reflect unsaved changes so modal knows @@ -132,7 +133,19 @@ export const NavigationProvider: React.FC<{ dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges: true } }); } const performWorkbenchChange = () => { - dispatch({ type: 'SET_WORKBENCH', payload: { workbench } }); + // When leaving a custom workbench, clear the selected tool + console.log('[NavigationContext] performWorkbenchChange executing', { + from: state.workbench, + to: workbench, + isCustom: state.workbench.startsWith('custom:') + }); + if (state.workbench.startsWith('custom:')) { + console.log('[NavigationContext] Clearing tool and changing workbench to:', workbench); + dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench } }); + } else { + console.log('[NavigationContext] Just changing workbench to:', workbench); + dispatch({ type: 'SET_WORKBENCH', payload: { workbench } }); + } }; dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performWorkbenchChange } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } }); @@ -149,10 +162,11 @@ export const NavigationProvider: React.FC<{ // Check for unsaved changes using registered checker or state const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; - // If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation + // If we're leaving pageEditor, viewer, or custom workbench and have unsaved changes, request navigation const leavingWorkbenchWithChanges = (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) || - (state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges); + (state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges) || + (state.workbench.startsWith('custom:') && workbench !== state.workbench && hasUnsavedChanges); if (leavingWorkbenchWithChanges) { const performWorkbenchChange = () => { @@ -192,13 +206,19 @@ export const NavigationProvider: React.FC<{ }, [state.hasUnsavedChanges]), confirmNavigation: useCallback(() => { + console.log('[NavigationContext] confirmNavigation called', { + hasPendingNav: !!state.pendingNavigation, + currentWorkbench: state.workbench, + currentTool: state.selectedTool + }); if (state.pendingNavigation) { state.pendingNavigation(); } dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); - }, [state.pendingNavigation]), + console.log('[NavigationContext] confirmNavigation completed'); + }, [state.pendingNavigation, state.workbench, state.selectedTool]), cancelNavigation: useCallback(() => { dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx index 7c657506ea..f60c28785d 100644 --- a/frontend/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx @@ -4,7 +4,7 @@ */ import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react'; -import { useToolManagement } from '@app/hooks/useToolManagement'; +import { useToolManagement, type ToolAvailabilityMap } from '@app/hooks/useToolManagement'; import { PageEditorFunctions } from '@app/types/pageEditor'; import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy'; import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; @@ -44,6 +44,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { selectedTool: ToolRegistryEntry | null; toolRegistry: Partial; getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null; + toolAvailability: ToolAvailabilityMap; // UI Actions setSidebarsVisible: (visible: boolean) => void; @@ -112,7 +113,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const navigationState = useNavigationState(); // Tool management hook - const { toolRegistry, getSelectedTool } = useToolManagement(); + const { toolRegistry, getSelectedTool, toolAvailability } = useToolManagement(); const { allTools } = useToolRegistry(); // Tool history hook @@ -258,6 +259,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { // Workflow actions (compound actions that coordinate multiple state changes) const handleToolSelect = useCallback((toolId: ToolId) => { + const availabilityInfo = toolAvailability[toolId]; + const isExplicitlyDisabled = availabilityInfo ? availabilityInfo.available === false : false; + if (toolId !== 'read' && toolId !== 'multiTool' && isExplicitlyDisabled) { + return; + } // If we're currently on a custom workbench (e.g., Validate Signature report), // selecting any tool should take the user back to the default file manager view. const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench); @@ -299,7 +305,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { setSearchQuery(''); setLeftPanelView('toolContent'); setReaderMode(false); // Disable read mode when selecting tools - }, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]); + }, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]); const handleBackToTools = useCallback(() => { setLeftPanelView('toolPicker'); @@ -354,6 +360,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { toolResetFunctions, registerToolReset, resetTool, + toolAvailability, // Workflow Actions handleToolSelect, @@ -381,6 +388,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { selectedTool, toolRegistry, getSelectedTool, + toolAvailability, setSidebarsVisible, setLeftPanelView, setReaderMode, diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index fc0f1ab70d..fc1eaf3464 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -17,6 +17,7 @@ import { RotationActions, SearchActions, ExportActions, + BookmarkActions, } from '@app/contexts/viewer/viewerActions'; import { BridgeRef, @@ -35,6 +36,7 @@ import { SearchState, ExportState, ThumbnailAPIWrapper, + BookmarkState, } from '@app/contexts/viewer/viewerBridges'; import { SpreadMode } from '@embedpdf/plugin-spread/react'; @@ -74,6 +76,8 @@ interface ViewerContextType { // UI state managed by this context isThumbnailSidebarVisible: boolean; toggleThumbnailSidebar: () => void; + isBookmarkSidebarVisible: boolean; + toggleBookmarkSidebar: () => void; // Annotation visibility toggle isAnnotationsVisible: boolean; @@ -98,6 +102,8 @@ interface ViewerContextType { getSearchState: () => SearchState; getThumbnailAPI: () => ThumbnailAPIWrapper | null; getExportState: () => ExportState; + getBookmarkState: () => BookmarkState; + hasBookmarkSupport: () => boolean; // Immediate update callbacks registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void; @@ -118,6 +124,7 @@ interface ViewerContextType { rotationActions: RotationActions; searchActions: SearchActions; exportActions: ExportActions; + bookmarkActions: BookmarkActions; // Bridge registration - internal use by bridges registerBridge: ( @@ -135,6 +142,7 @@ interface ViewerProviderProps { export const ViewerProvider: React.FC = ({ children }) => { // UI state - only state directly managed by this context const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); + const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false); const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true); const [isAnnotationMode, setIsAnnotationModeState] = useState(false); const [activeFileIndex, setActiveFileIndex] = useState(0); @@ -193,6 +201,10 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsThumbnailSidebarVisible(prev => !prev); }; + const toggleBookmarkSidebar = () => { + setIsBookmarkSidebarVisible(prev => !prev); + }; + const toggleAnnotationsVisibility = () => { setIsAnnotationsVisible(prev => !prev); }; @@ -242,6 +254,18 @@ export const ViewerProvider: React.FC = ({ children }) => { return bridgeRefs.current.export?.state || { canExport: false }; }; + const getBookmarkState = (): BookmarkState => { + return ( + bridgeRefs.current.bookmark?.state || { + bookmarks: null, + isLoading: false, + error: null, + } + ); + }; + + const hasBookmarkSupport = () => Boolean(bridgeRefs.current.bookmark); + // Action handlers - call APIs directly const { scrollActions, @@ -252,6 +276,7 @@ export const ViewerProvider: React.FC = ({ children }) => { rotationActions, searchActions, exportActions, + bookmarkActions, } = createViewerActions({ registry: bridgeRefs, getScrollState, @@ -263,6 +288,8 @@ export const ViewerProvider: React.FC = ({ children }) => { // UI state isThumbnailSidebarVisible, toggleThumbnailSidebar, + isBookmarkSidebarVisible, + toggleBookmarkSidebar, // Annotation controls isAnnotationsVisible, @@ -285,6 +312,8 @@ export const ViewerProvider: React.FC = ({ children }) => { getSearchState, getThumbnailAPI, getExportState, + getBookmarkState, + hasBookmarkSupport, // Immediate updates registerImmediateZoomUpdate, @@ -303,6 +332,7 @@ export const ViewerProvider: React.FC = ({ children }) => { rotationActions, searchActions, exportActions, + bookmarkActions, // Bridge registration registerBridge, diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index d209781c89..40f2a313d0 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -63,7 +63,7 @@ export function createProcessedFile( thumbnail?: string, pageRotations?: number[], pageDimensions?: Array<{ width: number; height: number }> -) { +): ProcessedFileMetadata { return { totalPages: pageCount, pages: Array.from({ length: pageCount }, (_, index) => ({ @@ -106,6 +106,10 @@ export async function generateProcessedFileMetadata(file: File): Promise void; @@ -58,6 +59,12 @@ export interface ExportActions { saveAsCopy: () => Promise; } +export interface BookmarkActions { + fetchBookmarks: () => Promise; + clearBookmarks: () => void; + setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void; +} + export interface ViewerActionsBundle { scrollActions: ScrollActions; zoomActions: ZoomActions; @@ -67,6 +74,7 @@ export interface ViewerActionsBundle { rotationActions: RotationActions; searchActions: SearchActions; exportActions: ExportActions; + bookmarkActions: BookmarkActions; } interface ViewerActionDependencies { @@ -307,5 +315,22 @@ export function createViewerActions({ rotationActions, searchActions, exportActions, + bookmarkActions: { + fetchBookmarks: async () => { + const api = registry.current.bookmark?.api; + if (!api?.fetchBookmarks) { + return null; + } + return api.fetchBookmarks(); + }, + clearBookmarks: () => { + const api = registry.current.bookmark?.api; + api?.clearBookmarks?.(); + }, + setLocalBookmarks: (bookmarks, error = null) => { + const api = registry.current.bookmark?.api; + api?.setLocalBookmarks?.(bookmarks ?? null, error); + }, + }, }; } diff --git a/frontend/src/core/contexts/viewer/viewerBridges.ts b/frontend/src/core/contexts/viewer/viewerBridges.ts index 032a05e18d..e7f1ebf3c0 100644 --- a/frontend/src/core/contexts/viewer/viewerBridges.ts +++ b/frontend/src/core/contexts/viewer/viewerBridges.ts @@ -1,4 +1,5 @@ import { SpreadMode } from '@embedpdf/plugin-spread/react'; +import { PdfBookmarkObject } from '@embedpdf/models'; export interface ScrollAPIWrapper { scrollToPage: (params: { pageNumber: number }) => void; @@ -59,6 +60,12 @@ export interface ExportAPIWrapper { saveAsCopy: () => { toPromise: () => Promise }; } +export interface BookmarkAPIWrapper { + fetchBookmarks: () => Promise; + clearBookmarks: () => void; + setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void; +} + export interface ScrollState { currentPage: number; totalPages: number; @@ -103,6 +110,12 @@ export interface ExportState { canExport: boolean; } +export interface BookmarkState { + bookmarks: PdfBookmarkObject[] | null; + isLoading: boolean; + error: string | null; +} + export interface BridgeRef { state: TState; api: TApi; @@ -118,6 +131,7 @@ export interface BridgeStateMap { search: SearchState; thumbnail: unknown; export: ExportState; + bookmark: BookmarkState; } export interface BridgeApiMap { @@ -130,6 +144,7 @@ export interface BridgeApiMap { search: SearchAPIWrapper; thumbnail: ThumbnailAPIWrapper; export: ExportAPIWrapper; + bookmark: BookmarkAPIWrapper; } export type BridgeKey = keyof BridgeStateMap; @@ -148,6 +163,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({ search: null, thumbnail: null, export: null, + bookmark: null, }); export function registerBridge( diff --git a/frontend/src/core/data/toolsTaxonomy.ts b/frontend/src/core/data/toolsTaxonomy.ts index 0733d9fcc6..baebe28cdd 100644 --- a/frontend/src/core/data/toolsTaxonomy.ts +++ b/frontend/src/core/data/toolsTaxonomy.ts @@ -59,6 +59,10 @@ export type ToolRegistryEntry = { supportsAutomate?: boolean; // Synonyms for search (optional) synonyms?: string[]; + // Version status indicator (e.g., "alpha", "beta") + versionStatus?: "alpha" | "beta"; + // Whether this tool requires premium access + requiresPremium?: boolean; } export type RegularToolRegistry = Record; diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index e9ecf0c08f..cda99dc40e 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -46,6 +46,8 @@ import Rotate from "@app/tools/Rotate"; import ChangeMetadata from "@app/tools/ChangeMetadata"; import Crop from "@app/tools/Crop"; import Sign from "@app/tools/Sign"; +import AddText from "@app/tools/AddText"; +import AddImage from "@app/tools/AddImage"; import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation"; import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation"; @@ -82,6 +84,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation"; import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation"; import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation"; +import { ENDPOINTS as SPLIT_ENDPOINT_NAMES } from '@app/constants/splitConstants'; import CompressSettings from "@app/components/tools/compress/CompressSettings"; import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings"; import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings"; @@ -199,6 +202,30 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { synonyms: getSynonyms(t, "sign"), supportsAutomate: false, //TODO make support Sign }, + addText: { + icon: , + name: t('home.addText.title', 'Add Text'), + component: AddText, + description: t('home.addText.desc', 'Add custom text anywhere in your PDF'), + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + operationConfig: signOperationConfig, + automationSettings: null, + synonyms: getSynonyms(t, 'addText'), + supportsAutomate: false, + }, + addImage: { + icon: , + name: t('home.addImage.title', 'Add Image'), + component: AddImage, + description: t('home.addImage.desc', 'Add images anywhere in your PDF'), + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + operationConfig: signOperationConfig, + automationSettings: null, + synonyms: getSynonyms(t, 'addImage'), + supportsAutomate: false, + }, // Document Security @@ -300,6 +327,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.VERIFICATION, + endpoints: ["get-info-on-pdf"], synonyms: getSynonyms(t, "getPdfInfo"), supportsAutomate: false, automationSettings: null @@ -398,6 +426,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.split.desc", "Split PDFs into multiple documents"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))), operationConfig: splitOperationConfig, automationSettings: SplitAutomationSettings, synonyms: getSynonyms(t, "split") @@ -465,6 +494,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + endpoints: ["booklet-imposition"], }, pdfToSinglePage: { @@ -559,6 +589,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.REMOVAL, maxFiles: -1, + endpoints: ["remove-annotations"], operationConfig: removeAnnotationsOperationConfig, automationSettings: null, synonyms: getSynonyms(t, "removeAnnotations") @@ -597,7 +628,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.REMOVAL, maxFiles: -1, - endpoints: ["remove-certificate-sign"], + endpoints: ["remove-cert-sign"], operationConfig: removeCertificateSignOperationConfig, synonyms: getSynonyms(t, "removeCertSign"), automationSettings: null, @@ -626,7 +657,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { name: t("home.autoRename.title", "Auto Rename PDF File"), component: AutoRename, maxFiles: -1, - endpoints: ["remove-certificate-sign"], + endpoints: ["auto-rename"], operationConfig: autoRenameOperationConfig, description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"), categoryId: ToolCategoryId.ADVANCED_TOOLS, @@ -681,6 +712,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + endpoints: ["overlay-pdf"], operationConfig: overlayPdfsOperationConfig, synonyms: getSynonyms(t, "overlay-pdfs"), automationSettings: OverlayPdfsSettings @@ -698,16 +730,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { automationSettings: ReplaceColorSettings, synonyms: getSynonyms(t, "replaceColor"), }, - addImage: { - icon: , - name: t("home.addImage.title", "Add Image"), - component: null, - description: t("home.addImage.desc", "Add images to PDF documents"), - categoryId: ToolCategoryId.ADVANCED_TOOLS, - subcategoryId: SubcategoryId.ADVANCED_FORMATTING, - synonyms: getSynonyms(t, "addImage"), - automationSettings: null - }, scannerEffect: { icon: , name: t("home.scannerEffect.title", "Scanner Effect"), @@ -715,6 +737,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + endpoints: ["scanner-effect"], synonyms: getSynonyms(t, "scannerEffect"), automationSettings: null }, @@ -805,6 +828,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, maxFiles: -1, + endpoints: ["compress-pdf"], operationConfig: compressOperationConfig, automationSettings: CompressSettings, synonyms: getSynonyms(t, "compress") @@ -848,6 +872,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, maxFiles: -1, + endpoints: ["ocr-pdf"], operationConfig: ocrOperationConfig, automationSettings: OCRSettings, synonyms: getSynonyms(t, "ocr") diff --git a/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts b/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts index 3783800500..c13ab3211e 100644 --- a/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts +++ b/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts @@ -9,6 +9,7 @@ import { SPLIT_METHODS } from '@app/constants/splitConstants'; const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' }); const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' }); const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' }); +const PrivacyIcon = () => React.createElement(LocalIcon, { icon: 'shield-lock', width: '1.5rem', height: '1.5rem' }); export function useSuggestedAutomations(): SuggestedAutomation[] { const { t } = useTranslation(); @@ -67,6 +68,63 @@ export function useSuggestedAutomations(): SuggestedAutomation[] { updatedAt: now, icon: SecurityIcon, }, + { + id: "pre-publish-sanitization", + name: t("automation.suggested.prePublishSanitization", "Pre-publish Sanitization"), + description: t("automation.suggested.prePublishSanitizationDesc", "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online."), + operations: [ + { + operation: "sanitize", + parameters: { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: true, + removeMetadata: true, + removeLinks: true, + removeFonts: false, + } + }, + { + operation: "flatten", + parameters: { + flattenOnlyForms: true, + } + }, + { + operation: "removeAnnotations", + parameters: {} + }, + { + operation: "changeMetadata", + parameters: { + deleteAll: true, + author: '', + creationDate: '', + creator: '', + keywords: '', + modificationDate: '', + producer: '', + subject: '', + title: '', + trapped: '', + } + }, + { + operation: "compress", + parameters: { + compressionLevel: 3, + grayscale: false, + expectedSize: '', + compressionMethod: 'quality', + fileSizeValue: '', + fileSizeUnit: 'MB', + } + }, + ], + createdAt: now, + updatedAt: now, + icon: PrivacyIcon, + }, { id: "email-preparation", name: t("automation.suggested.emailPreparation", "Email Preparation"), diff --git a/frontend/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts b/frontend/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts index 828fa01ab5..b0692e5d6a 100644 --- a/frontend/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts +++ b/frontend/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts @@ -40,13 +40,15 @@ export const buildChangeMetadataFormData = (parameters: ChangeMetadataParameters // Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]' let keyNumber = 0; - parameters.customMetadata.forEach((entry) => { - if (entry.key.trim() && entry.value.trim()) { - keyNumber += 1; - formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim()); - formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim()); - } - }); + if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) { + parameters.customMetadata.forEach((entry) => { + if (entry.key.trim() && entry.value.trim()) { + keyNumber += 1; + formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim()); + formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim()); + } + }); + } return formData; }; diff --git a/frontend/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts b/frontend/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts index 67476734a8..838d672e5c 100644 --- a/frontend/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts +++ b/frontend/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts @@ -14,6 +14,6 @@ export type RemoveCertificateSignParametersHook = BaseParametersHook { return useBaseParameters({ defaultParameters, - endpointName: 'remove-certificate-sign', + endpointName: 'remove-cert-sign', }); }; \ No newline at end of file diff --git a/frontend/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts b/frontend/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts new file mode 100644 index 0000000000..cf3619afc3 --- /dev/null +++ b/frontend/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts @@ -0,0 +1,12 @@ +import { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters'; + +/** + * Builds FormData for remove password API request. + * Separated from operation config to avoid circular dependencies with FileContext. + */ +export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("password", parameters.password); + return formData; +}; diff --git a/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts b/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts index ce6140730e..4f31e8078f 100644 --- a/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts +++ b/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts @@ -2,14 +2,10 @@ import { useTranslation } from 'react-i18next'; import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation'; import { createStandardErrorHandler } from '@app/utils/toolErrorHandler'; import { RemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters'; +import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData'; -// Static function that can be used by both the hook and automation executor -export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("password", parameters.password); - return formData; -}; +// Re-export for backwards compatibility with any other imports +export { buildRemovePasswordFormData }; // Static configuration object export const removePasswordOperationConfig = { diff --git a/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts b/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts index 85ed04ad68..665f408145 100644 --- a/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts +++ b/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts @@ -97,7 +97,12 @@ const writeToStorage = (entries: SavedSignature[]) => { } }; -const generateId = () => crypto.randomUUID(); +const generateId = () => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `sig_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +}; export const useSavedSignatures = () => { const [savedSignatures, setSavedSignatures] = useState(() => readFromStorage()); diff --git a/frontend/src/core/hooks/useEndpointConfig.ts b/frontend/src/core/hooks/useEndpointConfig.ts index 83bb34b572..587fb064d9 100644 --- a/frontend/src/core/hooks/useEndpointConfig.ts +++ b/frontend/src/core/hooks/useEndpointConfig.ts @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; import apiClient from '@app/services/apiClient'; +import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability'; // Track globally fetched endpoint sets to prevent duplicate fetches across components const globalFetchedSets = new Set(); -const globalEndpointCache: Record = {}; +const globalEndpointCache: Record = {}; /** * Hook to check if a specific endpoint is enabled @@ -59,11 +60,13 @@ export function useEndpointEnabled(endpoint: string): { */ export function useMultipleEndpointsEnabled(endpoints: string[]): { endpointStatus: Record; + endpointDetails: Record; loading: boolean; error: string | null; refetch: () => Promise; } { const [endpointStatus, setEndpointStatus] = useState>({}); + const [endpointDetails, setEndpointDetails] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -73,31 +76,25 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { // Skip if we already fetched these exact endpoints globally if (!force && globalFetchedSets.has(endpointsKey)) { console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache'); - const cachedStatus = endpoints.reduce((acc, endpoint) => { - if (endpoint in globalEndpointCache) { - acc[endpoint] = globalEndpointCache[endpoint]; - } - return acc; - }, {} as Record); - setEndpointStatus(cachedStatus); + const cached = endpoints.reduce( + (acc, endpoint) => { + const cachedDetails = globalEndpointCache[endpoint]; + if (cachedDetails) { + acc.status[endpoint] = cachedDetails.enabled; + acc.details[endpoint] = cachedDetails; + } + return acc; + }, + { status: {} as Record, details: {} as Record } + ); + setEndpointStatus(cached.status); + setEndpointDetails(prev => ({ ...prev, ...cached.details })); setLoading(false); return; } if (!endpoints || endpoints.length === 0) { setEndpointStatus({}); - setLoading(false); - return; - } - - // Check if JWT exists - if not, optimistically enable all endpoints - const hasJwt = !!localStorage.getItem('stirling_jwt'); - if (!hasJwt) { - console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints'); - const optimisticStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = true; - return acc; - }, {} as Record); - setEndpointStatus(optimisticStatus); + setEndpointDetails({}); setLoading(false); return; } @@ -110,11 +107,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache)); if (newEndpoints.length === 0) { console.debug('[useEndpointConfig] All endpoints already in global cache'); - const cachedStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = globalEndpointCache[endpoint]; - return acc; - }, {} as Record); - setEndpointStatus(cachedStatus); + const cached = endpoints.reduce( + (acc, endpoint) => { + const cachedDetails = globalEndpointCache[endpoint]; + if (cachedDetails) { + acc.status[endpoint] = cachedDetails.enabled; + acc.details[endpoint] = cachedDetails; + } + return acc; + }, + { status: {} as Record, details: {} as Record } + ); + setEndpointStatus(cached.status); + setEndpointDetails(prev => ({ ...prev, ...cached.details })); globalFetchedSets.add(endpointsKey); setLoading(false); return; @@ -123,30 +128,51 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { // Use batch API for efficiency - only fetch new endpoints const endpointsParam = newEndpoints.join(','); - const response = await apiClient.get>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`); + const response = await apiClient.get>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`); const statusMap = response.data; // Update global cache with new results - Object.assign(globalEndpointCache, statusMap); + Object.entries(statusMap).forEach(([endpoint, details]) => { + globalEndpointCache[endpoint] = { + enabled: details?.enabled ?? true, + reason: details?.reason ?? null, + }; + }); // Get all requested endpoints from cache (including previously cached ones) - const fullStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache - return acc; - }, {} as Record); + const fullStatus = endpoints.reduce( + (acc, endpoint) => { + const cachedDetails = globalEndpointCache[endpoint]; + if (cachedDetails) { + acc.status[endpoint] = cachedDetails.enabled; + acc.details[endpoint] = cachedDetails; + } else { + acc.status[endpoint] = true; + } + return acc; + }, + { status: {} as Record, details: {} as Record } + ); - setEndpointStatus(fullStatus); + setEndpointStatus(fullStatus.status); + setEndpointDetails(prev => ({ ...prev, ...fullStatus.details })); globalFetchedSets.add(endpointsKey); } catch (err: any) { // On 401 (auth error), use optimistic fallback instead of disabling if (err.response?.status === 401) { console.warn('[useEndpointConfig] 401 error - using optimistic fallback'); - const optimisticStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = true; - globalEndpointCache[endpoint] = true; // Cache the optimistic value - return acc; - }, {} as Record); - setEndpointStatus(optimisticStatus); + const optimisticStatus = endpoints.reduce( + (acc, endpoint) => { + const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null }; + acc.status[endpoint] = true; + acc.details[endpoint] = optimisticDetails; + globalEndpointCache[endpoint] = optimisticDetails; + return acc; + }, + { status: {} as Record, details: {} as Record } + ); + setEndpointStatus(optimisticStatus.status); + setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details })); setLoading(false); return; } @@ -156,11 +182,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { console.error('[EndpointConfig] Failed to check multiple endpoints:', err); // Fallback: assume all endpoints are enabled on error (optimistic) - const optimisticStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = true; - return acc; - }, {} as Record); - setEndpointStatus(optimisticStatus); + const optimisticStatus = endpoints.reduce( + (acc, endpoint) => { + const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null }; + acc.status[endpoint] = true; + acc.details[endpoint] = optimisticDetails; + return acc; + }, + { status: {} as Record, details: {} as Record } + ); + setEndpointStatus(optimisticStatus.status); + setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details })); } finally { setLoading(false); } @@ -186,6 +218,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { return { endpointStatus, + endpointDetails, loading, error, refetch: () => fetchAllEndpointStatuses(true), diff --git a/frontend/src/core/hooks/useToolManagement.tsx b/frontend/src/core/hooks/useToolManagement.tsx index 3f5676824b..a53fe67356 100644 --- a/frontend/src/core/hooks/useToolManagement.tsx +++ b/frontend/src/core/hooks/useToolManagement.tsx @@ -1,9 +1,20 @@ import { useState, useCallback, useMemo } from 'react'; import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; +import { usePreferences } from '@app/contexts/PreferencesContext'; import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy"; import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig"; import { FileId } from '@app/types/file'; import { ToolId } from "@app/types/toolId"; +import type { EndpointDisableReason } from '@app/types/endpointAvailability'; + +export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown'; + +export interface ToolAvailabilityInfo { + available: boolean; + reason?: ToolDisableCause; +} + +export type ToolAvailabilityMap = Partial>; interface ToolManagementResult { selectedTool: ToolRegistryEntry | null; @@ -11,6 +22,7 @@ interface ToolManagementResult { toolRegistry: Partial; setToolSelectedFileIds: (fileIds: FileId[]) => void; getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null; + toolAvailability: ToolAvailabilityMap; } export const useToolManagement = (): ToolManagementResult => { @@ -19,9 +31,10 @@ export const useToolManagement = (): ToolManagementResult => { // Build endpoints list from registry entries with fallback to legacy mapping const { allTools } = useToolRegistry(); const baseRegistry = allTools; + const { preferences } = usePreferences(); const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]); - const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); + const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); const isToolAvailable = useCallback((toolKey: string): boolean => { // Keep tools enabled during loading (optimistic UX) @@ -38,22 +51,64 @@ export const useToolManagement = (): ToolManagementResult => { return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false); }, [endpointsLoading, endpointStatus, baseRegistry]); + const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => { + const tool = baseRegistry[toolKey]; + if (!tool) { + return 'unknown'; + } + const endpoints = tool.endpoints || []; + const disabledReasons: EndpointDisableReason[] = endpoints + .filter(endpoint => endpointStatus[endpoint] === false) + .map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG'); + + if (disabledReasons.some(reason => reason === 'DEPENDENCY')) { + return 'missingDependency'; + } + if (disabledReasons.some(reason => reason === 'CONFIG')) { + return 'disabledByAdmin'; + } + if (disabledReasons.length > 0) { + return 'unknown'; + } + return 'unknown'; + }, [baseRegistry, endpointDetails, endpointStatus]); + + const toolAvailability = useMemo(() => { + if (endpointsLoading) { + return {}; + } + const availability: ToolAvailabilityMap = {}; + (Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => { + const available = isToolAvailable(toolKey); + availability[toolKey] = available + ? { available: true } + : { available: false, reason: deriveToolDisableReason(toolKey) }; + }); + return availability; + }, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]); + const toolRegistry: Partial = useMemo(() => { const availableToolRegistry: Partial = {}; (Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => { - if (isToolAvailable(toolKey)) { - const baseTool = baseRegistry[toolKey]; - if (baseTool) { - availableToolRegistry[toolKey] = { - ...baseTool, - name: baseTool.name, - description: baseTool.description, - }; - } + const baseTool = baseRegistry[toolKey]; + if (!baseTool) return; + const availabilityInfo = toolAvailability[toolKey]; + const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true; + + // Check if tool is "coming soon" (has no component and no link) + const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool'; + + if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) { + return; } + availableToolRegistry[toolKey] = { + ...baseTool, + name: baseTool.name, + description: baseTool.description, + }; }); return availableToolRegistry; - }, [isToolAvailable, baseRegistry]); + }, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]); const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => { return toolKey ? toolRegistry[toolKey] || null : null; @@ -65,5 +120,6 @@ export const useToolManagement = (): ToolManagementResult => { toolRegistry, setToolSelectedFileIds, getSelectedTool, + toolAvailability, }; }; diff --git a/frontend/src/core/hooks/useUrlSync.ts b/frontend/src/core/hooks/useUrlSync.ts index 577dfa5f26..6c9ff903de 100644 --- a/frontend/src/core/hooks/useUrlSync.ts +++ b/frontend/src/core/hooks/useUrlSync.ts @@ -8,6 +8,7 @@ import { parseToolRoute, updateToolRoute, clearToolRoute } from '@app/utils/urlR import { ToolRegistry } from '@app/data/toolsTaxonomy'; import { firePixel } from '@app/utils/scarfTracking'; import { withBasePath } from '@app/constants/app'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; /** * Hook to sync workbench and tool with URL using registry @@ -19,11 +20,33 @@ export function useNavigationUrlSync( registry: ToolRegistry, enableSync: boolean = true ) { + const { config } = useAppConfig(); + const premiumEnabled = config?.premiumEnabled; const hasInitialized = useRef(false); const prevSelectedTool = useRef(null); + + // Check if tool requires premium and redirect if needed + const checkPremiumAndSelect = useCallback((toolId: ToolId) => { + const tool = registry[toolId]; + if (tool?.requiresPremium === true && premiumEnabled !== true) { + // Premium tool accessed without premium - redirect to home + const homePath = withBasePath('/'); + if (window.location.pathname !== homePath) { + clearToolRoute(true); // Use replaceState to avoid adding to history + window.location.href = homePath; + } + return; + } + handleToolSelect(toolId); + }, [registry, premiumEnabled, handleToolSelect]); + // Initialize workbench and tool from URL on mount useEffect(() => { if (!enableSync) return; + // Wait for config to load before checking premium status + if (config === null) return; + // Only run once on initial mount + if (hasInitialized.current) return; // Fire pixel for initial page load const currentPath = window.location.pathname; @@ -32,7 +55,7 @@ export function useNavigationUrlSync( const route = parseToolRoute(registry); if (route.toolId !== selectedTool) { if (route.toolId) { - handleToolSelect(route.toolId); + checkPremiumAndSelect(route.toolId); } else if (selectedTool !== null) { // Only clear selection if we actually had a tool selected // Don't clear on initial load when selectedTool starts as null @@ -41,7 +64,7 @@ export function useNavigationUrlSync( } hasInitialized.current = true; - }, []); // Only run on mount + }, [checkPremiumAndSelect, config, enableSync, registry, selectedTool]); // Include dependencies // Update URL when tool or workbench changes useEffect(() => { @@ -73,7 +96,7 @@ export function useNavigationUrlSync( firePixel(currentPath); if (route.toolId) { - handleToolSelect(route.toolId); + checkPremiumAndSelect(route.toolId); } else { clearToolSelection(); } @@ -82,7 +105,7 @@ export function useNavigationUrlSync( window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); - }, [selectedTool, handleToolSelect, clearToolSelection, registry, enableSync]); + }, [selectedTool, handleToolSelect, clearToolSelection, registry, enableSync, checkPremiumAndSelect]); } /** diff --git a/frontend/src/core/services/preferencesService.ts b/frontend/src/core/services/preferencesService.ts index 5894eae506..bc30f453c5 100644 --- a/frontend/src/core/services/preferencesService.ts +++ b/frontend/src/core/services/preferencesService.ts @@ -9,6 +9,8 @@ export interface UserPreferences { toolPanelModePromptSeen: boolean; showLegacyToolDescriptions: boolean; hasCompletedOnboarding: boolean; + hideUnavailableTools: boolean; + hideUnavailableConversions: boolean; } export const DEFAULT_PREFERENCES: UserPreferences = { @@ -19,6 +21,8 @@ export const DEFAULT_PREFERENCES: UserPreferences = { toolPanelModePromptSeen: false, showLegacyToolDescriptions: false, hasCompletedOnboarding: false, + hideUnavailableTools: false, + hideUnavailableConversions: false, }; const STORAGE_KEY = 'stirlingpdf_preferences'; diff --git a/frontend/src/core/services/updateService.ts b/frontend/src/core/services/updateService.ts index 1cba4838fa..f134e403e1 100644 --- a/frontend/src/core/services/updateService.ts +++ b/frontend/src/core/services/updateService.ts @@ -1,6 +1,6 @@ export interface UpdateSummary { - latest_version: string; - latest_stable_version?: string; + latest_version: string | null; + latest_stable_version?: string | null; max_priority: 'urgent' | 'normal' | 'minor' | 'low'; recommended_action?: string; any_breaking: boolean; diff --git a/frontend/src/core/tools/AddAttachments.tsx b/frontend/src/core/tools/AddAttachments.tsx index c248eab961..199ea1d0ee 100644 --- a/frontend/src/core/tools/AddAttachments.tsx +++ b/frontend/src/core/tools/AddAttachments.tsx @@ -8,10 +8,12 @@ import { useAddAttachmentsParameters } from "@app/hooks/tools/addAttachments/use import { useAddAttachmentsOperation } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation"; import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps"; import AddAttachmentsSettings from "@app/components/tools/addAttachments/AddAttachmentsSettings"; +import { useAddAttachmentsTips } from "@app/components/tooltips/useAddAttachmentsTips"; const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { selectedFiles } = useFileSelection(); + const addAttachmentsTips = useAddAttachmentsTips(); const params = useAddAttachmentsParameters(); const operation = useAddAttachmentsOperation(); @@ -64,6 +66,7 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = isCollapsed: accordion.getCollapsedState(AddAttachmentsStep.ATTACHMENTS), onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS), isVisible: true, + tooltip: addAttachmentsTips, content: ( { const { t } = useTranslation(); + const autoRenameTips = useAutoRenameTips(); const base = useBaseTool( - '"auto-rename-pdf-file', + 'auto-rename-pdf-file', useAutoRenameParameters, useAutoRenameOperation, props ); return createToolFlow({ - title: { title:t("auto-rename.title", "Auto Rename PDF"), description: t("auto-rename.description", "Auto Rename PDF"), tooltip: useAutoRenameTips()}, files: { selectedFiles: base.selectedFiles, isCollapsed: base.hasResults, }, - steps: [], + steps: [ + { + title: t("auto-rename.settings.title", "About"), + isCollapsed: false, + tooltip: autoRenameTips, + content: null, + }, + ], executeButton: { text: t("auto-rename.submit", "Auto Rename"), isVisible: !base.hasResults, diff --git a/frontend/src/core/tools/RemoveAnnotations.tsx b/frontend/src/core/tools/RemoveAnnotations.tsx index 82d2f47b8a..e2bf6c3f45 100644 --- a/frontend/src/core/tools/RemoveAnnotations.tsx +++ b/frontend/src/core/tools/RemoveAnnotations.tsx @@ -5,9 +5,11 @@ import { useRemoveAnnotationsParameters } from "@app/hooks/tools/removeAnnotatio import { useRemoveAnnotationsOperation } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation"; import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; +import { useRemoveAnnotationsTips } from "@app/components/tooltips/useRemoveAnnotationsTips"; const RemoveAnnotations = (props: BaseToolProps) => { const { t } = useTranslation(); + const removeAnnotationsTips = useRemoveAnnotationsTips(); const base = useBaseTool( 'removeAnnotations', @@ -26,6 +28,7 @@ const RemoveAnnotations = (props: BaseToolProps) => { title: t("removeAnnotations.settings.title", "Settings"), isCollapsed: base.settingsCollapsed, onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: removeAnnotationsTips, content: , }, ], diff --git a/frontend/src/core/tools/Sign.tsx b/frontend/src/core/tools/Sign.tsx index 480d4c3a88..0af1f6855c 100644 --- a/frontend/src/core/tools/Sign.tsx +++ b/frontend/src/core/tools/Sign.tsx @@ -1,208 +1,12 @@ -import { useEffect, useCallback, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; -import { useSignParameters } from "@app/hooks/tools/sign/useSignParameters"; -import { useSignOperation } from "@app/hooks/tools/sign/useSignOperation"; -import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool"; -import { BaseToolProps, ToolComponent } from "@app/types/tool"; -import SignSettings from "@app/components/tools/sign/SignSettings"; -import { useNavigation } from "@app/contexts/NavigationContext"; -import { useSignature } from "@app/contexts/SignatureContext"; -import { useFileContext } from "@app/contexts/FileContext"; -import { useViewer } from "@app/contexts/ViewerContext"; -import { flattenSignatures } from "@app/utils/signatureFlattening"; +import { createStampTool } from '@app/tools/stamp/createStampTool'; -const Sign = (props: BaseToolProps) => { - const { t } = useTranslation(); - const { setWorkbench } = useNavigation(); - const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, signatureApiRef, getImageData, setSignaturesApplied } = useSignature(); - const { consumeFiles, selectors } = useFileContext(); - const { exportActions, getScrollState, activeFileIndex, setActiveFileIndex } = useViewer(); - const { setHasUnsavedChanges, unregisterUnsavedChangesChecker } = useNavigation(); - - // Track which signature mode was active for reactivation after save - const activeModeRef = useRef<'draw' | 'placement' | null>(null); - - // Single handler that activates placement mode - const handleSignaturePlacement = useCallback(() => { - activateSignaturePlacementMode(); - }, [activateSignaturePlacementMode]); - - // Memoized callbacks for SignSettings to prevent infinite loops - const handleActivateDrawMode = useCallback(() => { - activeModeRef.current = 'draw'; - activateDrawMode(); - }, [activateDrawMode]); - - const handleActivateSignaturePlacement = useCallback(() => { - activeModeRef.current = 'placement'; - handleSignaturePlacement(); - }, [handleSignaturePlacement]); - - const handleDeactivateSignature = useCallback(() => { - activeModeRef.current = null; - deactivateDrawMode(); - }, [deactivateDrawMode]); - - const base = useBaseTool( - 'sign', - useSignParameters, - useSignOperation, - props - ); - - const hasOpenedViewer = useRef(false); - - // Open viewer when files are selected (only once) - useEffect(() => { - if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) { - setWorkbench('viewer'); - hasOpenedViewer.current = true; - } - }, [base.selectedFiles.length, setWorkbench]); - - - - // Sync signature configuration with context - useEffect(() => { - setSignatureConfig(base.params.parameters); - }, [base.params.parameters, setSignatureConfig]); - - // Save signed files to the system - apply signatures using EmbedPDF and replace original - const handleSaveToSystem = useCallback(async () => { - try { - // Unregister unsaved changes checker to prevent warning during apply - unregisterUnsavedChangesChecker(); - setHasUnsavedChanges(false); - - // Get the original file from FileContext using activeFileIndex - // The viewer displays files from FileContext, not from base.selectedFiles - const allFiles = selectors.getFiles(); - const fileIndex = activeFileIndex < allFiles.length ? activeFileIndex : 0; - const originalFile = allFiles[fileIndex]; - - if (!originalFile) { - console.error('No file available to replace'); - return; - } - - // Use the signature flattening utility - const flattenResult = await flattenSignatures({ - signatureApiRef, - getImageData, - exportActions, - selectors, - originalFile, - getScrollState, - activeFileIndex - }); - - if (flattenResult) { - // Now consume the files - this triggers the viewer reload - await consumeFiles( - flattenResult.inputFileIds, - [flattenResult.outputStirlingFile], - [flattenResult.outputStub] - ); - - // According to FileReducer.processFileSwap, new files are inserted at the beginning - // So the new file will be at index 0 - setActiveFileIndex(0); - - // Mark signatures as applied - setSignaturesApplied(true); - - // Deactivate signature placement mode after everything completes - handleDeactivateSignature(); - - const hasSignatureReady = (() => { - const params = base.params.parameters; - switch (params.signatureType) { - case 'canvas': - case 'image': - return Boolean(params.signatureData); - case 'text': - return Boolean(params.signerName && params.signerName.trim() !== ''); - default: - return false; - } - })(); - - if (hasSignatureReady) { - if (typeof window !== 'undefined') { - // TODO: Ideally, we should trigger handleActivateSignaturePlacement when the viewer is ready. - // However, due to current architectural constraints, we use a 150ms delay to allow the viewer to reload. - // This value was empirically determined to be sufficient for most environments, but should be revisited. - window.setTimeout(() => { - handleActivateSignaturePlacement(); - }, 150); - } else { - handleActivateSignaturePlacement(); - } - } - - // File has been consumed - viewer should reload automatically via key prop - } else { - console.error('Signature flattening failed'); - } - } catch (error) { - console.error('Error saving signed document:', error); - } - }, [exportActions, base.selectedFiles, base.params.parameters, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, handleActivateSignaturePlacement, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]); - - const getSteps = () => { - const steps = []; - - // Step 1: Signature Configuration - Only visible when file is loaded - if (base.selectedFiles.length > 0) { - steps.push({ - title: t('sign.steps.configure', 'Configure Signature'), - isCollapsed: false, - onCollapsedClick: undefined, - content: ( - - ), - }); - } - - return steps; - }; - - return createToolFlow({ - files: { - selectedFiles: base.selectedFiles, - isCollapsed: base.operation.files.length > 0, - }, - steps: getSteps(), - review: { - isVisible: false, // Hide review section - save moved to configure section - operation: base.operation, - title: t('sign.results.title', 'Signature Results'), - onFileClick: base.handleThumbnailClick, - onUndo: () => {}, - }, - forceStepNumbers: true, - }); -}; - -// Add the required static methods for automation -Sign.tool = () => useSignOperation; -Sign.getDefaultParameters = () => ({ - signatureType: 'canvas', - reason: 'Document signing', - location: 'Digital', - signerName: '', +const Sign = createStampTool({ + toolId: 'sign', + translationScope: 'sign', + allowedSignatureSources: ['canvas', 'image', 'text', 'saved'], + defaultSignatureSource: 'canvas', + defaultSignatureType: 'canvas', + enableApplyAction: true, }); -export default Sign as ToolComponent; +export default Sign; diff --git a/frontend/src/core/tools/stamp/createStampTool.tsx b/frontend/src/core/tools/stamp/createStampTool.tsx new file mode 100644 index 0000000000..907bd01597 --- /dev/null +++ b/frontend/src/core/tools/stamp/createStampTool.tsx @@ -0,0 +1,249 @@ +import { useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createToolFlow } from '@app/components/tools/shared/createToolFlow'; +import SignSettings, { SignatureSource } from '@app/components/tools/sign/SignSettings'; +import { DEFAULT_PARAMETERS, useSignParameters, SignParameters } from '@app/hooks/tools/sign/useSignParameters'; +import { useSignOperation } from '@app/hooks/tools/sign/useSignOperation'; +import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool'; +import { BaseToolProps, ToolComponent } from '@app/types/tool'; +import { ToolId } from '@app/types/toolId'; +import { useNavigation } from '@app/contexts/NavigationContext'; +import { useSignature } from '@app/contexts/SignatureContext'; +import { useFileContext } from '@app/contexts/FileContext'; +import { useViewer } from '@app/contexts/ViewerContext'; +import { flattenSignatures } from '@app/utils/signatureFlattening'; + +export type StampToolConfig = { + toolId: ToolId; + translationScope?: string; + allowedSignatureSources?: SignatureSource[]; + defaultSignatureSource?: SignatureSource; + defaultSignatureType?: SignParameters['signatureType']; + enableApplyAction?: boolean; +}; + +const STAMP_TOOL_DEFAULT_SOURCES: SignatureSource[] = ['canvas', 'image', 'text', 'saved']; + +export const createStampTool = (config: StampToolConfig) => { + const { + toolId, + translationScope = toolId, + allowedSignatureSources = STAMP_TOOL_DEFAULT_SOURCES, + defaultSignatureSource, + defaultSignatureType, + enableApplyAction = false, + } = config; + + const StampTool = (props: BaseToolProps) => { + const { t } = useTranslation(); + const translateTool = useCallback( + (key: string, defaultValue: string) => t(`${translationScope}.${key}`, defaultValue), + [t, translationScope] + ); + const { setWorkbench, setHasUnsavedChanges, unregisterUnsavedChangesChecker } = useNavigation(); + const { + setSignatureConfig, + activateDrawMode, + activateSignaturePlacementMode, + deactivateDrawMode, + updateDrawSettings, + undo, + redo, + signatureApiRef, + getImageData, + setSignaturesApplied, + } = useSignature(); + const { consumeFiles, selectors } = useFileContext(); + const { exportActions, getScrollState, activeFileIndex, setActiveFileIndex } = useViewer(); + const base = useBaseTool( + toolId, + useSignParameters, + useSignOperation, + props + ); + + const allowedSignatureTypes = allowedSignatureSources.filter( + (source): source is SignParameters['signatureType'] => source !== 'saved' + ); + const enforcedSignatureType = + defaultSignatureType ?? allowedSignatureTypes[0] ?? DEFAULT_PARAMETERS.signatureType; + + useEffect(() => { + if (!allowedSignatureTypes.includes(base.params.parameters.signatureType)) { + base.params.updateParameter('signatureType', enforcedSignatureType); + } + }, [allowedSignatureTypes, base.params.parameters.signatureType, base.params.updateParameter, enforcedSignatureType]); + + const hasOpenedViewer = useRef(false); + const activeModeRef = useRef<'draw' | 'placement' | null>(null); + + const handleSignaturePlacement = useCallback(() => { + activateSignaturePlacementMode(); + }, [activateSignaturePlacementMode]); + + const handleActivateDrawMode = useCallback(() => { + activeModeRef.current = 'draw'; + activateDrawMode(); + }, [activateDrawMode]); + + const handleActivateSignaturePlacement = useCallback(() => { + activeModeRef.current = 'placement'; + handleSignaturePlacement(); + }, [handleSignaturePlacement]); + + const handleDeactivateSignature = useCallback(() => { + activeModeRef.current = null; + deactivateDrawMode(); + }, [deactivateDrawMode]); + + useEffect(() => { + if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) { + setWorkbench('viewer'); + hasOpenedViewer.current = true; + } + }, [base.selectedFiles.length, setWorkbench]); + + useEffect(() => { + setSignatureConfig(base.params.parameters); + }, [base.params.parameters, setSignatureConfig]); + + const handleSaveToSystem = useCallback(async () => { + try { + unregisterUnsavedChangesChecker(); + setHasUnsavedChanges(false); + + const allFiles = selectors.getFiles(); + const fileIndex = activeFileIndex < allFiles.length ? activeFileIndex : 0; + const originalFile = allFiles[fileIndex]; + + if (!originalFile) { + console.error('No file available to replace'); + return; + } + + const flattenResult = await flattenSignatures({ + signatureApiRef, + getImageData, + exportActions, + selectors, + originalFile, + getScrollState, + activeFileIndex, + }); + + if (flattenResult) { + await consumeFiles( + flattenResult.inputFileIds, + [flattenResult.outputStirlingFile], + [flattenResult.outputStub] + ); + + setActiveFileIndex(0); + setSignaturesApplied(true); + handleDeactivateSignature(); + + const hasSignatureReady = (() => { + const params = base.params.parameters; + switch (params.signatureType) { + case 'canvas': + case 'image': + return Boolean(params.signatureData); + case 'text': + return Boolean(params.signerName && params.signerName.trim() !== ''); + default: + return false; + } + })(); + + if (hasSignatureReady) { + if (typeof window !== 'undefined') { + window.setTimeout(() => { + handleActivateSignaturePlacement(); + }, 150); + } else { + handleActivateSignaturePlacement(); + } + } + } else { + console.error('Signature flattening failed'); + } + } catch (error) { + console.error('Error saving signed document:', error); + } + }, [ + exportActions, + base.selectedFiles, + base.params.parameters, + selectors, + consumeFiles, + signatureApiRef, + getImageData, + setWorkbench, + activateDrawMode, + setSignaturesApplied, + getScrollState, + handleDeactivateSignature, + handleActivateSignaturePlacement, + setHasUnsavedChanges, + unregisterUnsavedChangesChecker, + activeFileIndex, + setActiveFileIndex, + ]); + + const getSteps = () => { + const steps = []; + + if (base.selectedFiles.length > 0) { + steps.push({ + title: translateTool('steps.configure', 'Configure Stamp'), + isCollapsed: false, + onCollapsedClick: undefined, + content: ( + + ), + }); + } + + return steps; + }; + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.operation.files.length > 0, + }, + steps: getSteps(), + review: { + isVisible: false, + operation: base.operation, + title: translateTool('results.title', 'Stamp Results'), + onFileClick: base.handleThumbnailClick, + onUndo: () => {}, + }, + forceStepNumbers: true, + }); + }; + + const StampToolComponent = StampTool as ToolComponent; + StampToolComponent.tool = () => useSignOperation; + StampToolComponent.getDefaultParameters = () => ({ + ...DEFAULT_PARAMETERS, + signatureType: config.defaultSignatureType ?? DEFAULT_PARAMETERS.signatureType, + }); + + return StampToolComponent; +}; diff --git a/frontend/src/core/types/endpointAvailability.ts b/frontend/src/core/types/endpointAvailability.ts new file mode 100644 index 0000000000..c0f37d8312 --- /dev/null +++ b/frontend/src/core/types/endpointAvailability.ts @@ -0,0 +1,6 @@ +export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null; + +export interface EndpointAvailabilityDetails { + enabled: boolean; + reason?: EndpointDisableReason; +} diff --git a/frontend/src/core/types/fileContext.ts b/frontend/src/core/types/fileContext.ts index ed2734039c..d28ea3a0de 100644 --- a/frontend/src/core/types/fileContext.ts +++ b/frontend/src/core/types/fileContext.ts @@ -23,6 +23,7 @@ export interface ProcessedFileMetadata { pages: ProcessedFilePage[]; totalPages?: number; lastProcessed?: number; + isEncrypted?: boolean; [key: string]: any; } @@ -301,6 +302,7 @@ export interface FileContextActions { trackBlobUrl: (url: string) => void; scheduleCleanup: (fileId: FileId, delay?: number) => void; cleanupFile: (fileId: FileId) => void; + openEncryptedUnlockPrompt: (fileId: FileId) => void; } // File selectors (separate from actions to avoid re-renders) diff --git a/frontend/src/core/types/toolId.ts b/frontend/src/core/types/toolId.ts index 123bc6f4b2..23b03a8716 100644 --- a/frontend/src/core/types/toolId.ts +++ b/frontend/src/core/types/toolId.ts @@ -9,6 +9,7 @@ export type ToolKind = 'regular' | 'super' | 'link'; export const CORE_REGULAR_TOOL_IDS = [ 'certSign', 'sign', + 'addText', 'addPassword', 'removePassword', 'removePages', @@ -113,4 +114,3 @@ type Disjoint = [A & B] extends [never] ? true : false; type _Check1 = Assert>; type _Check2 = Assert>; type _Check3 = Assert>; - diff --git a/frontend/src/core/utils/thumbnailUtils.ts b/frontend/src/core/utils/thumbnailUtils.ts index 88c4aeaefb..eda7825500 100644 --- a/frontend/src/core/utils/thumbnailUtils.ts +++ b/frontend/src/core/utils/thumbnailUtils.ts @@ -5,6 +5,7 @@ export interface ThumbnailWithMetadata { pageCount: number; pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270) pageDimensions?: Array<{ width: number; height: number }>; + isEncrypted?: boolean; } interface ColorScheme { @@ -451,7 +452,7 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b if (error instanceof Error && error.name === "PasswordException") { // Handle encrypted PDFs const thumbnail = generateEncryptedPDFThumbnail(file); - return { thumbnail, pageCount: 1 }; + return { thumbnail, pageCount: 1, isEncrypted: true }; } const thumbnail = generatePlaceholderThumbnail(file); diff --git a/frontend/src/core/utils/toolErrorHandler.ts b/frontend/src/core/utils/toolErrorHandler.ts index 637970adf0..836f1e00c2 100644 --- a/frontend/src/core/utils/toolErrorHandler.ts +++ b/frontend/src/core/utils/toolErrorHandler.ts @@ -2,6 +2,8 @@ * Standardized error handling utilities for tool operations */ +import { normalizeAxiosErrorData } from '@app/services/errorUtils'; + /** * Default error extractor that follows the standard pattern */ @@ -30,4 +32,36 @@ export const createStandardErrorHandler = (fallbackMessage: string) => { } return fallbackMessage; }; +}; + +/** + * Handles password-related errors with status code checking + * @param error - The error object from axios + * @param incorrectPasswordMessage - Message to show for incorrect password (typically 500 status) + * @param fallbackMessage - Message to show for other errors + * @returns Error message string + */ +export const handlePasswordError = async ( + error: any, + incorrectPasswordMessage: string, + fallbackMessage: string +): Promise => { + const status = error?.response?.status; + + // Handle specific error cases with user-friendly messages + if (status === 500) { + // 500 typically means incorrect password for encrypted PDFs + return incorrectPasswordMessage; + } + + // For other errors, try to extract the message + const normalizedData = await normalizeAxiosErrorData(error?.response?.data); + const errorWithNormalizedData = { + ...error, + response: { + ...error?.response, + data: normalizedData + } + }; + return extractErrorMessage(errorWithNormalizedData) || fallbackMessage; }; \ No newline at end of file diff --git a/frontend/src/core/utils/urlMapping.ts b/frontend/src/core/utils/urlMapping.ts index ba6fe1aaa7..aa2a3c57c0 100644 --- a/frontend/src/core/utils/urlMapping.ts +++ b/frontend/src/core/utils/urlMapping.ts @@ -96,6 +96,7 @@ export const URL_TO_TOOL_MAP: Record = { '/read': 'read', '/automate': 'automate', '/sign': 'sign', + '/add-text': 'addText', // Developer tools '/dev-api': 'devApi', diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index f6fc1ea1b7..84d455c518 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -17,23 +17,23 @@ import { tauriBackendService } from '@app/services/tauriBackendService'; */ export function AppProviders({ children }: { children: ReactNode }) { const { isFirstLaunch, setupComplete } = useFirstLaunchCheck(); - const [connectionMode, setConnectionMode] = useState<'offline' | 'server' | null>(null); + const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null); // Load connection mode on mount useEffect(() => { void connectionModeService.getCurrentMode().then(setConnectionMode); }, []); - // Initialize backend health monitoring for server mode + // Initialize backend health monitoring for self-hosted mode useEffect(() => { - if (setupComplete && !isFirstLaunch && connectionMode === 'server') { - console.log('[AppProviders] Initializing external backend monitoring for server mode'); + if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') { void tauriBackendService.initializeExternalBackend(); } }, [setupComplete, isFirstLaunch, connectionMode]); - // Only start bundled backend if in offline mode and setup is complete - const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'offline'; + // Only start bundled backend if in SaaS mode (local backend) and setup is complete + // Self-hosted mode connects to remote server so doesn't need local backend + const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas'; useBackendInitializer(shouldStartBackend); // Show setup wizard on first launch @@ -51,8 +51,23 @@ export function AppProviders({ children }: { children: ReactNode }) { }} > { - // Reload the page to reinitialize with new connection config + onComplete={async () => { + // Wait for backend to become healthy before reloading + // This prevents reloading mid-startup which would interrupt the backend + const maxWaitTime = 60000; // 60 seconds max + const checkInterval = 1000; // Check every second + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitTime) { + if (tauriBackendService.isBackendHealthy()) { + window.location.reload(); + return; + } + await new Promise(resolve => setTimeout(resolve, checkInterval)); + } + + // If we timeout, reload anyway + console.warn('[AppProviders] Backend health check timeout, reloading anyway...'); window.location.reload(); }} /> diff --git a/frontend/src/desktop/components/ConnectionSettings.tsx b/frontend/src/desktop/components/ConnectionSettings.tsx index da7e2cb61d..b0ba688c98 100644 --- a/frontend/src/desktop/components/ConnectionSettings.tsx +++ b/frontend/src/desktop/components/ConnectionSettings.tsx @@ -1,24 +1,15 @@ import React, { useState, useEffect } from 'react'; -import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core'; +import { Stack, Card, Badge, Button, Text, Group } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { - connectionModeService, - ConnectionConfig, - ServerConfig, -} from '@app/services/connectionModeService'; +import { connectionModeService, ConnectionConfig } from '@app/services/connectionModeService'; import { authService, UserInfo } from '@app/services/authService'; -import { LoginForm } from '@app/components/SetupWizard/LoginForm'; import { STIRLING_SAAS_URL } from '@app/constants/connection'; -import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; export const ConnectionSettings: React.FC = () => { const { t } = useTranslation(); const [config, setConfig] = useState(null); const [userInfo, setUserInfo] = useState(null); const [loading, setLoading] = useState(false); - const [showServerModal, setShowServerModal] = useState(false); - const [showLoginModal, setShowLoginModal] = useState(false); - const [newServerConfig, setNewServerConfig] = useState(null); // Load current config on mount useEffect(() => { @@ -26,7 +17,7 @@ export const ConnectionSettings: React.FC = () => { const currentConfig = await connectionModeService.getCurrentConfig(); setConfig(currentConfig); - if (currentConfig.mode === 'server') { + if (currentConfig.mode === 'saas' || currentConfig.mode === 'selfhosted') { const user = await authService.getUserInfo(); setUserInfo(user); } @@ -35,80 +26,26 @@ export const ConnectionSettings: React.FC = () => { loadConfig(); }, []); - const handleSwitchToOffline = async () => { - try { - setLoading(true); - await connectionModeService.switchToOffline(); - - // Reload config - const newConfig = await connectionModeService.getCurrentConfig(); - setConfig(newConfig); - setUserInfo(null); - - // Reload the page to start the local backend - window.location.reload(); - } catch (error) { - console.error('Failed to switch to offline:', error); - } finally { - setLoading(false); - } - }; - - const handleSwitchToServer = () => { - setShowServerModal(true); - }; - - const handleServerConfigSubmit = (serverConfig: ServerConfig) => { - setNewServerConfig(serverConfig); - setShowServerModal(false); - setShowLoginModal(true); - }; - - const handleLogin = async (username: string, password: string) => { - if (!newServerConfig) return; - - try { - setLoading(true); - - // Login - await authService.login(newServerConfig.url, username, password); - - // Switch to server mode - await connectionModeService.switchToServer(newServerConfig); - - // Reload config and user info - const newConfig = await connectionModeService.getCurrentConfig(); - setConfig(newConfig); - const user = await authService.getUserInfo(); - setUserInfo(user); - - setShowLoginModal(false); - setNewServerConfig(null); - - // Reload the page to stop local backend and initialize external backend monitoring - window.location.reload(); - } catch (error) { - console.error('Login failed:', error); - throw error; // Let LoginForm handle the error - } finally { - setLoading(false); - } - }; - const handleLogout = async () => { try { setLoading(true); await authService.logout(); - // Switch to offline mode - await connectionModeService.switchToOffline(); + // Switch to SaaS mode + await connectionModeService.switchToSaaS(STIRLING_SAAS_URL); + + // Reset setup completion to force login screen on reload + await connectionModeService.resetSetupCompletion(); // Reload config const newConfig = await connectionModeService.getCurrentConfig(); setConfig(newConfig); setUserInfo(null); - // Reload the page to clear all state and reconnect to local backend + // Clear URL to home page before reload so we don't return to settings after re-login + window.history.replaceState({}, '', '/'); + + // Reload the page to clear all state and show login screen window.location.reload(); } catch (error) { console.error('Logout failed:', error); @@ -127,21 +64,21 @@ export const ConnectionSettings: React.FC = () => { {t('settings.connection.title', 'Connection Mode')} - - {config.mode === 'offline' - ? t('settings.connection.mode.offline', 'Offline') - : t('settings.connection.mode.server', 'Server')} + + {config.mode === 'saas' + ? t('settings.connection.mode.saas', 'Stirling Cloud') + : t('settings.connection.mode.selfhosted', 'Self-Hosted')} - {config.mode === 'server' && config.server_config && ( + {(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && ( <>
{t('settings.connection.server', 'Server')} - {config.server_config.url} + {config.mode === 'saas' ? 'stirling.com' : config.server_config.url}
@@ -160,128 +97,12 @@ export const ConnectionSettings: React.FC = () => { )} - {config.mode === 'offline' ? ( - - ) : ( - <> - - - - )} +
- - {/* Server selection modal */} - setShowServerModal(false)} - title={t('settings.connection.selectServer', 'Select Server')} - zIndex={Z_INDEX_OVER_CONFIG_MODAL} - > - - - - {/* Login modal */} - { - setShowLoginModal(false); - setNewServerConfig(null); - }} - title={t('settings.connection.login', 'Login')} - zIndex={Z_INDEX_OVER_CONFIG_MODAL} - > - {newServerConfig && ( - - )} - ); }; - -// Mini server selection component for settings -const ServerSelectionInSettings: React.FC<{ onSubmit: (config: ServerConfig) => void }> = ({ - onSubmit, -}) => { - const { t } = useTranslation(); - const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas'); - const [customUrl, setCustomUrl] = useState(''); - const [testing, setTesting] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async () => { - const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim(); - - if (!url) { - setError(t('setup.server.error.emptyUrl', 'Please enter a server URL')); - return; - } - - setTesting(true); - setError(null); - - try { - const isReachable = await connectionModeService.testConnection(url); - - if (!isReachable) { - setError(t('setup.server.error.unreachable', 'Could not connect to server')); - setTesting(false); - return; - } - - onSubmit({ - url, - server_type: serverType, - }); - } catch (err) { - setError(err instanceof Error ? err.message : t('setup.server.error.testFailed', 'Connection test failed')); - setTesting(false); - } - }; - - return ( - - setServerType(value as 'saas' | 'selfhosted')}> - - - - - - - {serverType === 'selfhosted' && ( - { - setCustomUrl(e.target.value); - setError(null); - }} - disabled={testing} - error={error} - /> - )} - - {error && !customUrl && ( - - {error} - - )} - - - - ); -}; diff --git a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx index be99519952..5facdf96b8 100644 --- a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx +++ b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx @@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next'; interface LoginFormProps { serverUrl: string; + isSaaS?: boolean; onLogin: (username: string, password: string) => Promise; loading: boolean; } -export const LoginForm: React.FC = ({ serverUrl, onLogin, loading }) => { +export const LoginForm: React.FC = ({ serverUrl, isSaaS = false, onLogin, loading }) => { const { t } = useTranslation(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -36,7 +37,7 @@ export const LoginForm: React.FC = ({ serverUrl, onLogin, loadin
- {t('setup.login.connectingTo', 'Connecting to:')} {serverUrl} + {t('setup.login.connectingTo', 'Connecting to:')} {isSaaS ? 'stirling.com' : serverUrl} void; + onSelect: (mode: 'saas' | 'selfhosted') => void; loading: boolean; } @@ -17,31 +17,7 @@ export const ModeSelection: React.FC = ({ onSelect, loading - - + + diff --git a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx index 7d0f8de1e3..3ca5ea65b2 100644 --- a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx +++ b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx @@ -41,7 +41,6 @@ export const ServerSelection: React.FC = ({ onSelect, load // Connection successful onSelect({ url, - server_type: 'selfhosted', }); } catch (error) { console.error('Connection test failed:', error); diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index 3b47245e02..6e4b7b6e8e 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -7,13 +7,15 @@ import { LoginForm } from '@app/components/SetupWizard/LoginForm'; import { connectionModeService, ServerConfig } from '@app/services/connectionModeService'; import { authService } from '@app/services/authService'; import { tauriBackendService } from '@app/services/tauriBackendService'; -import { BASE_PATH } from '@app/constants/app'; +import { useLogoPath } from '@app/hooks/useLogoPath'; +import { STIRLING_SAAS_URL } from '@desktop/constants/connection'; import '@app/components/SetupWizard/SetupWizard.css'; enum SetupStep { ModeSelection, + SaaSLogin, ServerSelection, - Login, + SelfHostedLogin, } interface SetupWizardProps { @@ -22,34 +24,42 @@ interface SetupWizardProps { export const SetupWizard: React.FC = ({ onComplete }) => { const { t } = useTranslation(); + const logoPath = useLogoPath(); const [activeStep, setActiveStep] = useState(SetupStep.ModeSelection); - const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null); const [serverConfig, setServerConfig] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const handleModeSelection = (mode: 'offline' | 'server') => { - setSelectedMode(mode); + const handleModeSelection = (mode: 'saas' | 'selfhosted') => { setError(null); - if (mode === 'offline') { - handleOfflineSetup(); + if (mode === 'saas') { + // For SaaS, go directly to login screen with SaaS URL + setServerConfig({ url: STIRLING_SAAS_URL }); + setActiveStep(SetupStep.SaaSLogin); } else { + // For self-hosted, show server selection first setActiveStep(SetupStep.ServerSelection); } }; - const handleOfflineSetup = async () => { + const handleSaaSLogin = async (username: string, password: string) => { + if (!serverConfig) { + setError('No SaaS server configured'); + return; + } + try { setLoading(true); setError(null); - await connectionModeService.switchToOffline(); + await authService.login(serverConfig.url, username, password); + await connectionModeService.switchToSaaS(serverConfig.url); await tauriBackendService.startBackend(); onComplete(); } catch (err) { - console.error('Failed to set up offline mode:', err); - setError(err instanceof Error ? err.message : 'Failed to set up offline mode'); + console.error('SaaS login failed:', err); + setError(err instanceof Error ? err.message : 'SaaS login failed'); setLoading(false); } }; @@ -57,10 +67,10 @@ export const SetupWizard: React.FC = ({ onComplete }) => { const handleServerSelection = (config: ServerConfig) => { setServerConfig(config); setError(null); - setActiveStep(SetupStep.Login); + setActiveStep(SetupStep.SelfHostedLogin); }; - const handleLogin = async (username: string, password: string) => { + const handleSelfHostedLogin = async (username: string, password: string) => { if (!serverConfig) { setError('No server configured'); return; @@ -71,23 +81,25 @@ export const SetupWizard: React.FC = ({ onComplete }) => { setError(null); await authService.login(serverConfig.url, username, password); - await connectionModeService.switchToServer(serverConfig); + await connectionModeService.switchToSelfHosted(serverConfig); await tauriBackendService.initializeExternalBackend(); onComplete(); } catch (err) { - console.error('Login failed:', err); - setError(err instanceof Error ? err.message : 'Login failed'); + console.error('Self-hosted login failed:', err); + setError(err instanceof Error ? err.message : 'Self-hosted login failed'); setLoading(false); } }; const handleBack = () => { setError(null); - if (activeStep === SetupStep.Login) { + if (activeStep === SetupStep.SaaSLogin) { + setActiveStep(SetupStep.ModeSelection); + setServerConfig(null); + } else if (activeStep === SetupStep.SelfHostedLogin) { setActiveStep(SetupStep.ServerSelection); } else if (activeStep === SetupStep.ServerSelection) { setActiveStep(SetupStep.ModeSelection); - setSelectedMode(null); setServerConfig(null); } }; @@ -96,10 +108,12 @@ export const SetupWizard: React.FC = ({ onComplete }) => { switch (activeStep) { case SetupStep.ModeSelection: return t('setup.welcome', 'Welcome to Stirling PDF'); + case SetupStep.SaaSLogin: + return t('setup.saas.title', 'Sign in to Stirling Cloud'); case SetupStep.ServerSelection: return t('setup.server.title', 'Connect to Server'); - case SetupStep.Login: - return t('setup.login.title', 'Sign In'); + case SetupStep.SelfHostedLogin: + return t('setup.selfhosted.title', 'Sign in to Server'); default: return ''; } @@ -109,10 +123,12 @@ export const SetupWizard: React.FC = ({ onComplete }) => { switch (activeStep) { case SetupStep.ModeSelection: return t('setup.description', 'Get started by choosing how you want to use Stirling PDF'); + case SetupStep.SaaSLogin: + return t('setup.saas.subtitle', 'Sign in with your Stirling account'); case SetupStep.ServerSelection: return t('setup.server.subtitle', 'Enter your self-hosted server URL'); - case SetupStep.Login: - return t('setup.login.subtitle', 'Enter your credentials to continue'); + case SetupStep.SelfHostedLogin: + return t('setup.selfhosted.subtitle', 'Enter your server credentials'); default: return ''; } @@ -126,9 +142,9 @@ export const SetupWizard: React.FC = ({ onComplete }) => { {/* Logo Header */} Stirling PDF @@ -153,14 +169,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => { <ModeSelection onSelect={handleModeSelection} loading={loading} /> )} + {activeStep === SetupStep.SaaSLogin && ( + <LoginForm + serverUrl={serverConfig?.url || ''} + isSaaS={true} + onLogin={handleSaaSLogin} + loading={loading} + /> + )} + {activeStep === SetupStep.ServerSelection && ( <ServerSelection onSelect={handleServerSelection} loading={loading} /> )} - {activeStep === SetupStep.Login && ( + {activeStep === SetupStep.SelfHostedLogin && ( <LoginForm serverUrl={serverConfig?.url || ''} - onLogin={handleLogin} + isSaaS={false} + onLogin={handleSelfHostedLogin} loading={loading} /> )} diff --git a/frontend/src/desktop/constants/connection.ts b/frontend/src/desktop/constants/connection.ts index b9ba31f779..7f7d538362 100644 --- a/frontend/src/desktop/constants/connection.ts +++ b/frontend/src/desktop/constants/connection.ts @@ -2,4 +2,11 @@ * Connection-related constants for desktop app */ -export const STIRLING_SAAS_URL = 'https://stirling.com/app'; +// SaaS server URL from environment variable (required) +// The SaaS authentication server +// Will throw error if VITE_SAAS_SERVER_URL is not set +if (!import.meta.env.VITE_SAAS_SERVER_URL) { + throw new Error('VITE_SAAS_SERVER_URL environment variable is required'); +} + +export const STIRLING_SAAS_URL = import.meta.env.VITE_SAAS_SERVER_URL; diff --git a/frontend/src/desktop/hooks/useBackendInitializer.ts b/frontend/src/desktop/hooks/useBackendInitializer.ts index e14c7ea530..8be4e3c89d 100644 --- a/frontend/src/desktop/hooks/useBackendInitializer.ts +++ b/frontend/src/desktop/hooks/useBackendInitializer.ts @@ -25,9 +25,7 @@ export function useBackendInitializer(enabled = true) { const initializeBackend = async () => { try { - console.log('[BackendInitializer] Starting backend...'); await tauriBackendService.startBackend(backendUrl); - console.log('[BackendInitializer] Backend started successfully'); // Begin health checks after a short delay setTimeout(() => { diff --git a/frontend/src/desktop/hooks/useEndpointConfig.ts b/frontend/src/desktop/hooks/useEndpointConfig.ts index 8ccdc66e21..529d3f0f57 100644 --- a/frontend/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/src/desktop/hooks/useEndpointConfig.ts @@ -4,8 +4,10 @@ import { useTranslation } from 'react-i18next'; import apiClient from '@app/services/apiClient'; import { tauriBackendService } from '@app/services/tauriBackendService'; import { isBackendNotReadyError } from '@app/constants/backendErrors'; +import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability'; import { connectionModeService } from '@desktop/services/connectionModeService'; + interface EndpointConfig { backendUrl: string; } @@ -128,6 +130,7 @@ export function useEndpointEnabled(endpoint: string): { export function useMultipleEndpointsEnabled(endpoints: string[]): { endpointStatus: Record<string, boolean>; + endpointDetails: Record<string, EndpointAvailabilityDetails>; loading: boolean; error: string | null; refetch: () => Promise<void>; @@ -140,6 +143,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { return acc; }, {} as Record<string, boolean>); }); + const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const isMountedRef = useRef(true); @@ -174,13 +178,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { const endpointsParam = endpoints.join(','); - const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', { + const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>('/api/v1/config/endpoints-availability', { params: { endpoints: endpointsParam }, suppressErrorToast: true, }); if (!isMountedRef.current) return; - setEndpointStatus(response.data); + const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => { + acc[endpointName] = { + enabled: detail?.enabled ?? true, + reason: detail?.reason ?? null, + }; + return acc; + }, {} as Record<string, EndpointAvailabilityDetails>); + + const statusMap = Object.keys(details).reduce((acc, key) => { + acc[key] = details[key].enabled; + return acc; + }, {} as Record<string, boolean>); + + setEndpointDetails(prev => ({ ...prev, ...details })); + setEndpointStatus(statusMap); } catch (err: unknown) { const isBackendStarting = isBackendNotReadyError(err); const message = getErrorMessage(err); @@ -188,10 +206,13 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message); const fallbackStatus = endpoints.reduce((acc, endpointName) => { - acc[endpointName] = true; + const fallbackDetail: EndpointAvailabilityDetails = { enabled: true, reason: null }; + acc.status[endpointName] = true; + acc.details[endpointName] = fallbackDetail; return acc; - }, {} as Record<string, boolean>); - setEndpointStatus(fallbackStatus); + }, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }); + setEndpointStatus(fallbackStatus.status); + setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details })); if (!retryTimeoutRef.current) { retryTimeoutRef.current = setTimeout(() => { @@ -209,6 +230,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { useEffect(() => { if (!endpoints || endpoints.length === 0) { setEndpointStatus({}); + setEndpointDetails({}); setLoading(false); return; } @@ -230,6 +252,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { return { endpointStatus, + endpointDetails, loading, error, refetch: fetchAllEndpointStatuses, @@ -244,8 +267,8 @@ const DEFAULT_BACKEND_URL = /** * Desktop override exposing the backend URL based on connection mode. - * - Offline mode: Uses local bundled backend (from env vars) - * - Server mode: Uses configured server URL from connection config + * - SaaS mode: Uses local bundled backend (from env vars) + * - Self-hosted mode: Uses configured server URL from connection config */ export function useEndpointConfig(): EndpointConfig { const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL); @@ -253,10 +276,10 @@ export function useEndpointConfig(): EndpointConfig { useEffect(() => { connectionModeService.getCurrentConfig() .then((config) => { - if (config.mode === 'server' && config.server_config?.url) { + if (config.mode === 'selfhosted' && config.server_config?.url) { setBackendUrl(config.server_config.url); } else { - // Offline mode - use default from env vars + // SaaS mode - use default from env vars (local backend) setBackendUrl(DEFAULT_BACKEND_URL); } }) diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts index 2ba6c54977..d01c0d9973 100644 --- a/frontend/src/desktop/services/apiClientSetup.ts +++ b/frontend/src/desktop/services/apiClientSetup.ts @@ -49,7 +49,7 @@ export function setupApiInterceptors(client: AxiosInstance): void { console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`); // Add auth token for remote requests - const isRemote = await operationRouter.isRemoteMode(); + const isRemote = await operationRouter.isSelfHostedMode(); if (isRemote) { const token = await authService.getAuthToken(); if (token) { @@ -59,9 +59,9 @@ export function setupApiInterceptors(client: AxiosInstance): void { // Backend readiness check (for local backend) const skipCheck = extendedConfig.skipBackendReadyCheck === true; - const isOffline = await operationRouter.isOfflineMode(); + const isSaaS = await operationRouter.isSaaSMode(); - if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) { + if (isSaaS && !skipCheck && !tauriBackendService.isBackendHealthy()) { const method = (extendedConfig.method || 'get').toLowerCase(); if (method !== 'get') { const now = Date.now(); @@ -93,7 +93,7 @@ export function setupApiInterceptors(client: AxiosInstance): void { if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; - const isRemote = await operationRouter.isRemoteMode(); + const isRemote = await operationRouter.isSelfHostedMode(); if (isRemote) { const serverConfig = await connectionModeService.getServerConfig(); if (serverConfig) { diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts index f1a96c9c53..f01dbc40a6 100644 --- a/frontend/src/desktop/services/connectionModeService.ts +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -1,12 +1,10 @@ import { invoke } from '@tauri-apps/api/core'; import { fetch } from '@tauri-apps/plugin-http'; -export type ConnectionMode = 'offline' | 'server'; -export type ServerType = 'saas' | 'selfhosted'; +export type ConnectionMode = 'saas' | 'selfhosted'; export interface ServerConfig { url: string; - server_type: ServerType; } export interface ConnectionConfig { @@ -31,7 +29,7 @@ export class ConnectionModeService { if (!this.configLoadedOnce) { await this.loadConfig(); } - return this.currentConfig || { mode: 'offline', server_config: null }; + return this.currentConfig || { mode: 'saas', server_config: null }; } async getCurrentMode(): Promise<ConnectionMode> { @@ -64,38 +62,40 @@ export class ConnectionModeService { this.configLoadedOnce = true; } catch (error) { console.error('Failed to load connection config:', error); - // Default to offline mode on error - this.currentConfig = { mode: 'offline', server_config: null }; + // Default to SaaS mode on error + this.currentConfig = { mode: 'saas', server_config: null }; this.configLoadedOnce = true; } } - async switchToOffline(): Promise<void> { - console.log('Switching to offline mode'); + async switchToSaaS(saasServerUrl: string): Promise<void> { + console.log('Switching to SaaS mode'); + + const serverConfig: ServerConfig = { url: saasServerUrl }; await invoke('set_connection_mode', { - mode: 'offline', - serverConfig: null, - }); - - this.currentConfig = { mode: 'offline', server_config: null }; - this.notifyListeners(); - - console.log('Switched to offline mode successfully'); - } - - async switchToServer(serverConfig: ServerConfig): Promise<void> { - console.log('Switching to server mode:', serverConfig); - - await invoke('set_connection_mode', { - mode: 'server', + mode: 'saas', serverConfig, }); - this.currentConfig = { mode: 'server', server_config: serverConfig }; + this.currentConfig = { mode: 'saas', server_config: serverConfig }; this.notifyListeners(); - console.log('Switched to server mode successfully'); + console.log('Switched to SaaS mode successfully'); + } + + async switchToSelfHosted(serverConfig: ServerConfig): Promise<void> { + console.log('Switching to self-hosted mode:', serverConfig); + + await invoke('set_connection_mode', { + mode: 'selfhosted', + serverConfig, + }); + + this.currentConfig = { mode: 'selfhosted', server_config: serverConfig }; + this.notifyListeners(); + + console.log('Switched to self-hosted mode successfully'); } async testConnection(url: string): Promise<boolean> { @@ -126,6 +126,16 @@ export class ConnectionModeService { return false; } } + + async resetSetupCompletion(): Promise<void> { + try { + await invoke('reset_setup_completion'); + console.log('Setup completion flag reset successfully'); + } catch (error) { + console.error('Failed to reset setup completion:', error); + throw error; + } + } } export const connectionModeService = ConnectionModeService.getInstance(); diff --git a/frontend/src/desktop/services/operationRouter.ts b/frontend/src/desktop/services/operationRouter.ts index 723878b97d..8715959568 100644 --- a/frontend/src/desktop/services/operationRouter.ts +++ b/frontend/src/desktop/services/operationRouter.ts @@ -22,14 +22,16 @@ export class OperationRouter { const mode = await connectionModeService.getCurrentMode(); // Current implementation: simple mode-based routing - if (mode === 'offline') { + if (mode === 'saas') { + // SaaS mode: For now, all operations run locally + // Future enhancement: complex operations will be sent to SaaS server return 'local'; } - // In server mode, currently all operations go to remote + // In self-hosted mode, currently all operations go to remote // Future enhancement: check if operation is "simple" and route to local if so // Example future logic: - // if (mode === 'server' && operation && this.isSimpleOperation(operation)) { + // if (mode === 'selfhosted' && operation && this.isSimpleOperation(operation)) { // return 'local'; // } @@ -66,19 +68,19 @@ export class OperationRouter { } /** - * Checks if we're currently in remote mode + * Checks if we're currently in self-hosted mode */ - async isRemoteMode(): Promise<boolean> { + async isSelfHostedMode(): Promise<boolean> { const mode = await connectionModeService.getCurrentMode(); - return mode === 'server'; + return mode === 'selfhosted'; } /** - * Checks if we're currently in offline mode + * Checks if we're currently in SaaS mode */ - async isOfflineMode(): Promise<boolean> { + async isSaaSMode(): Promise<boolean> { const mode = await connectionModeService.getCurrentMode(); - return mode === 'offline'; + return mode === 'saas'; } // Future enhancement: operation classification diff --git a/frontend/src/desktop/services/tauriBackendService.ts b/frontend/src/desktop/services/tauriBackendService.ts index ce8bd93368..9840d93da4 100644 --- a/frontend/src/desktop/services/tauriBackendService.ts +++ b/frontend/src/desktop/services/tauriBackendService.ts @@ -64,7 +64,6 @@ export class TauriBackendService { return; } - console.log('[TauriBackendService] Initializing external backend monitoring'); this.backendStarted = true; // Mark as active for health checks this.setStatus('starting'); this.beginHealthMonitoring(); @@ -82,19 +81,17 @@ export class TauriBackendService { this.setStatus('starting'); this.startPromise = invoke('start_backend', { backendUrl }) - .then(async (result) => { - console.log('Backend started:', result); + .then(async () => { this.backendStarted = true; this.setStatus('starting'); // Poll for the dynamically assigned port await this.waitForPort(); - this.beginHealthMonitoring(); }) .catch((error) => { this.setStatus('unhealthy'); - console.error('Failed to start backend:', error); + console.error('[TauriBackendService] Failed to start backend:', error); throw error; }) .finally(() => { @@ -105,13 +102,11 @@ export class TauriBackendService { } private async waitForPort(maxAttempts = 30): Promise<void> { - console.log('[TauriBackendService] Waiting for backend port assignment...'); for (let i = 0; i < maxAttempts; i++) { try { const port = await invoke<number | null>('get_backend_port'); if (port) { this.backendPort = port; - console.log(`[TauriBackendService] Backend port detected: ${port}`); return; } } catch (error) { @@ -138,11 +133,11 @@ export class TauriBackendService { async checkBackendHealth(): Promise<boolean> { const mode = await connectionModeService.getCurrentMode(); - // For remote server mode, check the configured server - if (mode !== 'offline') { + // For self-hosted mode, check the configured remote server + if (mode === 'selfhosted') { const serverConfig = await connectionModeService.getServerConfig(); if (!serverConfig) { - console.error('[TauriBackendService] Server mode but no server URL configured'); + console.error('[TauriBackendService] Self-hosted mode but no server URL configured'); this.setStatus('unhealthy'); return false; } @@ -161,21 +156,20 @@ export class TauriBackendService { } catch (error) { const errorStr = String(error); if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) { - console.error('[TauriBackendService] Server health check failed:', error); + console.error('[TauriBackendService] Self-hosted server health check failed:', error); } this.setStatus('unhealthy'); return false; } } - // For offline mode, check the bundled backend via Rust + // For SaaS mode, check the bundled local backend via Rust if (!this.backendStarted) { this.setStatus('stopped'); return false; } if (!this.backendPort) { - console.debug('[TauriBackendService] Backend port not available yet'); return false; } @@ -197,7 +191,6 @@ export class TauriBackendService { for (let i = 0; i < maxAttempts; i++) { const isHealthy = await this.checkBackendHealth(); if (isHealthy) { - console.log('Backend is healthy'); return; } await new Promise(resolve => setTimeout(resolve, 1000)); @@ -210,7 +203,6 @@ export class TauriBackendService { * Reset backend state (used when switching from external to local backend) */ reset(): void { - console.log('[TauriBackendService] Resetting backend state'); this.backendStarted = false; this.backendPort = null; this.setStatus('stopped'); diff --git a/frontend/src/proprietary/auth/springAuthClient.test.ts b/frontend/src/proprietary/auth/springAuthClient.test.ts new file mode 100644 index 0000000000..5c6af7a75e --- /dev/null +++ b/frontend/src/proprietary/auth/springAuthClient.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { springAuth } from '@app/auth/springAuthClient'; +import apiClient from '@app/services/apiClient'; +import { AxiosError } from 'axios'; + +// Mock apiClient +vi.mock('@app/services/apiClient'); + +describe('SpringAuthClient', () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + // Clear all mocks + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getSession', () => { + it('should return null session when no JWT in localStorage', async () => { + const result = await springAuth.getSession(); + + expect(result.data.session).toBeNull(); + expect(result.error).toBeNull(); + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('should validate JWT and return session when JWT exists', async () => { + const mockToken = 'mock-jwt-token'; + const mockUser = { + id: '123', + email: 'test@example.com', + username: 'testuser', + role: 'USER', + }; + + localStorage.setItem('stirling_jwt', mockToken); + + vi.mocked(apiClient.get).mockResolvedValueOnce({ + status: 200, + data: { user: mockUser }, + } as any); + + const result = await springAuth.getSession(); + + expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', { + headers: { Authorization: `Bearer ${mockToken}` }, + suppressErrorToast: true, + }); + expect(result.data.session).toBeTruthy(); + expect(result.data.session?.user).toEqual(mockUser); + expect(result.data.session?.access_token).toBe(mockToken); + expect(result.error).toBeNull(); + }); + + it('should clear invalid JWT on 401 error', async () => { + const mockToken = 'invalid-jwt-token'; + localStorage.setItem('stirling_jwt', mockToken); + + const mockError = new AxiosError( + 'Unauthorized', + 'ERR_BAD_REQUEST', + undefined, + undefined, + { + status: 401, + statusText: 'Unauthorized', + data: {}, + headers: {}, + config: {} as any, + } + ); + + vi.mocked(apiClient.get).mockRejectedValueOnce(mockError); + + const result = await springAuth.getSession(); + + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + expect(result.data.session).toBeNull(); + // 401 is handled gracefully, so error should be null + expect(result.error).toBeNull(); + }); + + it('should clear invalid JWT on 403 error', async () => { + const mockToken = 'forbidden-jwt-token'; + localStorage.setItem('stirling_jwt', mockToken); + + const mockError = new AxiosError( + 'Forbidden', + 'ERR_BAD_REQUEST', + undefined, + undefined, + { + status: 403, + statusText: 'Forbidden', + data: {}, + headers: {}, + config: {} as any, + } + ); + + vi.mocked(apiClient.get).mockRejectedValueOnce(mockError); + + const result = await springAuth.getSession(); + + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + expect(result.data.session).toBeNull(); + // 403 is handled gracefully, so error should be null + expect(result.error).toBeNull(); + }); + }); + + describe('signInWithPassword', () => { + it('should successfully sign in with email and password', async () => { + const credentials = { + email: 'test@example.com', + password: 'password123', + }; + + const mockToken = 'new-jwt-token'; + const mockUser = { + id: '123', + email: credentials.email, + username: credentials.email, + role: 'USER', + }; + + vi.mocked(apiClient.post).mockResolvedValueOnce({ + status: 200, + data: { + user: mockUser, + session: { + access_token: mockToken, + expires_in: 3600, + }, + }, + } as any); + + // Spy on window.dispatchEvent + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + const result = await springAuth.signInWithPassword(credentials); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/auth/login', + { + username: credentials.email, + password: credentials.password, + }, + { withCredentials: true } + ); + expect(localStorage.getItem('stirling_jwt')).toBe(mockToken); + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'jwt-available' }) + ); + expect(result.user).toEqual(mockUser); + expect(result.session?.access_token).toBe(mockToken); + expect(result.error).toBeNull(); + }); + + it('should return error on failed login', async () => { + const credentials = { + email: 'wrong@example.com', + password: 'wrongpassword', + }; + + const errorMessage = 'Invalid credentials'; + const mockError = Object.assign(new Error(errorMessage), { + isAxiosError: true, + response: { + status: 401, + data: { message: errorMessage }, + }, + }); + + vi.mocked(apiClient.post).mockRejectedValueOnce(mockError); + + const result = await springAuth.signInWithPassword(credentials); + + expect(result.user).toBeNull(); + expect(result.session).toBeNull(); + expect(result.error).toBeTruthy(); + expect(result.error?.message).toBe(errorMessage); + }); + }); + + describe('signUp', () => { + it('should successfully register new user', async () => { + const credentials = { + email: 'newuser@example.com', + password: 'newpassword123', + }; + + const mockUser = { + id: '456', + email: credentials.email, + username: credentials.email, + role: 'USER', + }; + + vi.mocked(apiClient.post).mockResolvedValueOnce({ + status: 200, + data: { user: mockUser }, + } as any); + + const result = await springAuth.signUp(credentials); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/user/register', + { + username: credentials.email, + password: credentials.password, + }, + { withCredentials: true } + ); + expect(result.user).toEqual(mockUser); + expect(result.session).toBeNull(); // No auto-login on signup + expect(result.error).toBeNull(); + }); + + it('should return error on failed registration', async () => { + const credentials = { + email: 'existing@example.com', + password: 'password123', + }; + + const errorMessage = 'User already exists'; + const mockError = Object.assign(new Error(errorMessage), { + isAxiosError: true, + response: { + status: 409, + data: { message: errorMessage }, + }, + }); + + vi.mocked(apiClient.post).mockRejectedValueOnce(mockError); + + const result = await springAuth.signUp(credentials); + + expect(result.user).toBeNull(); + expect(result.session).toBeNull(); + expect(result.error).toBeTruthy(); + expect(result.error?.message).toBe(errorMessage); + }); + }); + + describe('signOut', () => { + it('should successfully sign out and clear JWT', async () => { + const mockToken = 'jwt-to-clear'; + localStorage.setItem('stirling_jwt', mockToken); + + vi.mocked(apiClient.post).mockResolvedValueOnce({ + status: 200, + data: {}, + } as any); + + const result = await springAuth.signOut(); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/auth/logout', + null, + expect.objectContaining({ withCredentials: true }) + ); + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + expect(result.error).toBeNull(); + }); + + it('should clear JWT even if logout request fails', async () => { + const mockToken = 'jwt-to-clear'; + localStorage.setItem('stirling_jwt', mockToken); + + vi.mocked(apiClient.post).mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 500 }, + message: 'Server error', + }); + + const result = await springAuth.signOut(); + + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + expect(result.error).toBeTruthy(); + }); + }); + + describe('refreshSession', () => { + it('should refresh JWT token successfully', async () => { + const newToken = 'refreshed-jwt-token'; + const mockUser = { + id: '123', + email: 'test@example.com', + username: 'testuser', + role: 'USER', + }; + + vi.mocked(apiClient.post).mockResolvedValueOnce({ + status: 200, + data: { + user: mockUser, + session: { + access_token: newToken, + expires_in: 3600, + }, + }, + } as any); + + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + const result = await springAuth.refreshSession(); + + expect(localStorage.getItem('stirling_jwt')).toBe(newToken); + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'jwt-available' }) + ); + expect(result.data.session?.access_token).toBe(newToken); + expect(result.error).toBeNull(); + }); + + it('should clear JWT and return error on 401', async () => { + localStorage.setItem('stirling_jwt', 'expired-token'); + + vi.mocked(apiClient.post).mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 401 }, + message: 'Token expired', + }); + + const result = await springAuth.refreshSession(); + + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + expect(result.data.session).toBeNull(); + expect(result.error).toBeTruthy(); + }); + }); + + describe('signInWithOAuth', () => { + it('should redirect to OAuth provider', async () => { + const mockAssign = vi.fn(); + Object.defineProperty(window, 'location', { + value: { assign: mockAssign }, + writable: true, + }); + + const result = await springAuth.signInWithOAuth({ + provider: 'github', + options: { redirectTo: '/auth/callback' }, + }); + + expect(mockAssign).toHaveBeenCalledWith('/oauth2/authorization/github'); + expect(result.error).toBeNull(); + }); + }); +}); diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 390ea28e51..51ba51df45 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -45,8 +45,8 @@ function normalizeRedirectPath(target?: string): string { function persistRedirectPath(path: string): void { try { document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`; - } catch (error) { - console.warn('[SpringAuth] Failed to persist OAuth redirect path', error); + } catch (_error) { + // console.warn('[SpringAuth] Failed to persist OAuth redirect path', _error); } } @@ -123,22 +123,23 @@ class SpringAuthClient { const token = localStorage.getItem('stirling_jwt'); if (!token) { - console.debug('[SpringAuth] getSession: No JWT in localStorage'); + // console.debug('[SpringAuth] getSession: No JWT in localStorage'); return { data: { session: null }, error: null }; } // Verify with backend // Note: We pass the token explicitly here, overriding the interceptor's default - console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me'); + // console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me'); const response = await apiClient.get('/api/v1/auth/me', { headers: { 'Authorization': `Bearer ${token}`, }, + suppressErrorToast: true, // Suppress global error handler (we handle errors locally) }); - console.debug('[SpringAuth] /me response status:', response.status); + // console.debug('[SpringAuth] /me response status:', response.status); const data = response.data; - console.debug('[SpringAuth] /me response data:', data); + // console.debug('[SpringAuth] /me response data:', data); // Create session object const session: Session = { @@ -148,7 +149,7 @@ class SpringAuthClient { expires_at: Date.now() + 3600 * 1000, }; - console.debug('[SpringAuth] getSession: Session retrieved successfully'); + // console.debug('[SpringAuth] getSession: Session retrieved successfully'); return { data: { session }, error: null }; } catch (error: unknown) { console.error('[SpringAuth] getSession error:', error); @@ -189,7 +190,7 @@ class SpringAuthClient { // Store JWT in localStorage localStorage.setItem('stirling_jwt', token); - console.log('[SpringAuth] JWT stored in localStorage'); + // console.log('[SpringAuth] JWT stored in localStorage'); // Dispatch custom event for other components to react to JWT availability window.dispatchEvent(new CustomEvent('jwt-available')); @@ -260,7 +261,7 @@ class SpringAuthClient { // Redirect to Spring OAuth2 endpoint (Vite will proxy to backend) const redirectUrl = `/oauth2/authorization/${params.provider}`; - console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl); + // console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl); // Use window.location.assign for full page navigation window.location.assign(redirectUrl); return { error: null }; @@ -284,7 +285,7 @@ class SpringAuthClient { }); if (response.status === 200) { - console.debug('[SpringAuth] signOut: Success'); + // console.debug('[SpringAuth] signOut: Success'); } // Clean up local storage @@ -314,6 +315,7 @@ class SpringAuthClient { 'X-XSRF-TOKEN': this.getCsrfToken() || '', }, withCredentials: true, + suppressErrorToast: true, // Suppress global error handler (we handle errors locally) }); const data = response.data; @@ -399,7 +401,7 @@ class SpringAuthClient { // Refresh if token expires soon if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) { - console.log('[SpringAuth] Proactively refreshing token'); + // console.log('[SpringAuth] Proactively refreshing token'); await this.refreshSession(); } } diff --git a/frontend/src/proprietary/components/tools/pdfTextEditor/FontStatusPanel.tsx b/frontend/src/proprietary/components/tools/pdfTextEditor/FontStatusPanel.tsx new file mode 100644 index 0000000000..0ba9a46654 --- /dev/null +++ b/frontend/src/proprietary/components/tools/pdfTextEditor/FontStatusPanel.tsx @@ -0,0 +1,286 @@ +import React, { useMemo, useState } from 'react'; +import { + Accordion, + Badge, + Box, + Code, + Collapse, + Group, + List, + Paper, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import WarningIcon from '@mui/icons-material/Warning'; +import ErrorIcon from '@mui/icons-material/Error'; +import InfoIcon from '@mui/icons-material/Info'; +import FontDownloadIcon from '@mui/icons-material/FontDownload'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; + +import { PdfJsonDocument } from '@app/tools/pdfTextEditor/pdfTextEditorTypes'; +import { + analyzeDocumentFonts, + DocumentFontAnalysis, + FontAnalysis, + getFontStatusColor, + getFontStatusDescription, +} from '@app/tools/pdfTextEditor/fontAnalysis'; + +interface FontStatusPanelProps { + document: PdfJsonDocument | null; + pageIndex?: number; +} + +const FontStatusBadge = ({ analysis }: { analysis: FontAnalysis }) => { + const color = getFontStatusColor(analysis.status); + const description = getFontStatusDescription(analysis.status); + + const icon = useMemo(() => { + switch (analysis.status) { + case 'perfect': + return <CheckCircleIcon sx={{ fontSize: 14 }} />; + case 'embedded-subset': + return <InfoIcon sx={{ fontSize: 14 }} />; + case 'system-fallback': + return <WarningIcon sx={{ fontSize: 14 }} />; + case 'missing': + return <ErrorIcon sx={{ fontSize: 14 }} />; + default: + return <InfoIcon sx={{ fontSize: 14 }} />; + } + }, [analysis.status]); + + return ( + <Tooltip label={description} position="top" withArrow> + <Badge + size="xs" + color={color} + variant="light" + leftSection={icon} + style={{ cursor: 'help' }} + > + {analysis.status.replace('-', ' ')} + </Badge> + </Tooltip> + ); +}; + +const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + return ( + <Paper withBorder p="xs" style={{ cursor: 'pointer' }} onClick={() => setExpanded(!expanded)}> + <Stack gap={4}> + <Group justify="space-between"> + <Group gap={4}> + <FontDownloadIcon sx={{ fontSize: 16 }} /> + <Text size="xs" fw={500} lineClamp={1}> + {analysis.baseName} + </Text> + {analysis.isSubset && ( + <Badge size="xs" color="gray" variant="outline"> + subset + </Badge> + )} + </Group> + <Group gap={4}> + <FontStatusBadge analysis={analysis} /> + {expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />} + </Group> + </Group> + + <Collapse in={expanded}> + <Stack gap={4} mt={4}> + {/* Font Details */} + <Box> + <Text size="xs" c="dimmed" mb={2}> + {t('pdfTextEditor.fontAnalysis.details', 'Font Details')}: + </Text> + <Stack gap={2}> + <Group gap={4}> + <Text size="xs" c="dimmed"> + {t('pdfTextEditor.fontAnalysis.embedded', 'Embedded')}: + </Text> + <Code style={{ fontSize: '0.65rem', padding: '0 4px' }}>{analysis.embedded ? 'Yes' : 'No'}</Code> + </Group> + {analysis.subtype && ( + <Group gap={4}> + <Text size="xs" c="dimmed"> + {t('pdfTextEditor.fontAnalysis.type', 'Type')}: + </Text> + <Code style={{ fontSize: '0.65rem', padding: '0 4px' }}>{analysis.subtype}</Code> + </Group> + )} + {analysis.webFormat && ( + <Group gap={4}> + <Text size="xs" c="dimmed"> + {t('pdfTextEditor.fontAnalysis.webFormat', 'Web Format')}: + </Text> + <Code style={{ fontSize: '0.65rem', padding: '0 4px' }}>{analysis.webFormat}</Code> + </Group> + )} + </Stack> + </Box> + + {/* Warnings */} + {analysis.warnings.length > 0 && ( + <Box> + <Text size="xs" c="orange" fw={500}> + {t('pdfTextEditor.fontAnalysis.warnings', 'Warnings')}: + </Text> + <List size="xs" spacing={2} withPadding> + {analysis.warnings.map((warning, index) => ( + <List.Item key={index}> + <Text size="xs">{warning}</Text> + </List.Item> + ))} + </List> + </Box> + )} + + {/* Suggestions */} + {analysis.suggestions.length > 0 && ( + <Box> + <Text size="xs" c="blue" fw={500}> + {t('pdfTextEditor.fontAnalysis.suggestions', 'Notes')}: + </Text> + <List size="xs" spacing={2} withPadding> + {analysis.suggestions.map((suggestion, index) => ( + <List.Item key={index}> + <Text size="xs">{suggestion}</Text> + </List.Item> + ))} + </List> + </Box> + )} + </Stack> + </Collapse> + </Stack> + </Paper> + ); +}; + +const FontStatusPanel: React.FC<FontStatusPanelProps> = ({ document, pageIndex }) => { + const { t } = useTranslation(); + + const fontAnalysis: DocumentFontAnalysis = useMemo( + () => analyzeDocumentFonts(document, pageIndex), + [document, pageIndex] + ); + + const { canReproducePerfectly, hasWarnings, summary, fonts } = fontAnalysis; + + const statusIcon = useMemo(() => { + if (canReproducePerfectly) { + return <CheckCircleIcon sx={{ fontSize: 16 }} />; + } + if (hasWarnings) { + return <WarningIcon sx={{ fontSize: 16 }} />; + } + return <InfoIcon sx={{ fontSize: 16 }} />; + }, [canReproducePerfectly, hasWarnings]); + + // Early return AFTER all hooks are declared + if (!document || fontAnalysis.fonts.length === 0) { + return null; + } + + const statusColor = canReproducePerfectly ? 'green' : hasWarnings ? 'yellow' : 'blue'; + + const pageLabel = pageIndex !== undefined + ? t('pdfTextEditor.fontAnalysis.currentPageFonts', 'Fonts on this page') + : t('pdfTextEditor.fontAnalysis.allFonts', 'All fonts'); + + return ( + <Accordion variant="contained" defaultValue={hasWarnings ? 'fonts' : undefined}> + <Accordion.Item value="fonts"> + <Accordion.Control> + <Group gap="xs" wrap="wrap" style={{ flex: 1 }}> + <Group gap="xs" wrap="nowrap"> + {statusIcon} + <Text size="sm" fw={500}> + {pageLabel} + </Text> + <Badge size="xs" color={statusColor} variant="dot"> + {fonts.length} + </Badge> + </Group> + + {/* Warning badges BEFORE expansion */} + <Group gap={4} wrap="wrap"> + {summary.systemFallback > 0 && ( + <Badge size="xs" color="yellow" variant="filled" leftSection={<WarningIcon sx={{ fontSize: 12 }} />}> + {summary.systemFallback} {t('pdfTextEditor.fontAnalysis.fallback', 'fallback')} + </Badge> + )} + {summary.missing > 0 && ( + <Badge size="xs" color="red" variant="filled" leftSection={<ErrorIcon sx={{ fontSize: 12 }} />}> + {summary.missing} {t('pdfTextEditor.fontAnalysis.missing', 'missing')} + </Badge> + )} + </Group> + </Group> + </Accordion.Control> + <Accordion.Panel> + <Stack gap="xs"> + {/* Overall Status Message */} + <Text size="xs" c="dimmed"> + {canReproducePerfectly + ? t( + 'pdfTextEditor.fontAnalysis.perfectMessage', + 'All fonts can be reproduced perfectly.' + ) + : hasWarnings + ? t( + 'pdfTextEditor.fontAnalysis.warningMessage', + 'Some fonts may not render correctly.' + ) + : t( + 'pdfTextEditor.fontAnalysis.infoMessage', + 'Font reproduction information available.' + )} + </Text> + + {/* Summary Statistics */} + <Group gap={4} wrap="wrap"> + {summary.perfect > 0 && ( + <Badge size="xs" color="green" variant="light" leftSection={<CheckCircleIcon sx={{ fontSize: 12 }} />}> + {summary.perfect} {t('pdfTextEditor.fontAnalysis.perfect', 'perfect')} + </Badge> + )} + {summary.embeddedSubset > 0 && ( + <Badge size="xs" color="blue" variant="light" leftSection={<InfoIcon sx={{ fontSize: 12 }} />}> + {summary.embeddedSubset} {t('pdfTextEditor.fontAnalysis.subset', 'subset')} + </Badge> + )} + {summary.systemFallback > 0 && ( + <Badge size="xs" color="yellow" variant="light" leftSection={<WarningIcon sx={{ fontSize: 12 }} />}> + {summary.systemFallback} {t('pdfTextEditor.fontAnalysis.fallback', 'fallback')} + </Badge> + )} + {summary.missing > 0 && ( + <Badge size="xs" color="red" variant="light" leftSection={<ErrorIcon sx={{ fontSize: 12 }} />}> + {summary.missing} {t('pdfTextEditor.fontAnalysis.missing', 'missing')} + </Badge> + )} + </Group> + + {/* Font List */} + <Stack gap={4} mt="xs"> + {fonts.map((font, index) => ( + <FontDetailItem key={`${font.fontId}-${index}`} analysis={font} /> + ))} + </Stack> + </Stack> + </Accordion.Panel> + </Accordion.Item> + </Accordion> + ); +}; + +export default FontStatusPanel; diff --git a/frontend/src/proprietary/components/tools/pdfTextEditor/PdfTextEditorView.tsx b/frontend/src/proprietary/components/tools/pdfTextEditor/PdfTextEditorView.tsx new file mode 100644 index 0000000000..3c9309e960 --- /dev/null +++ b/frontend/src/proprietary/components/tools/pdfTextEditor/PdfTextEditorView.tsx @@ -0,0 +1,2447 @@ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { + Accordion, + ActionIcon, + Alert, + Badge, + Box, + Button, + Card, + Divider, + Group, + Menu, + Modal, + Pagination, + Progress, + ScrollArea, + SegmentedControl, + Stack, + Switch, + Text, + Title, + Tooltip, +} from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import DescriptionIcon from '@mui/icons-material/DescriptionOutlined'; +import FileDownloadIcon from '@mui/icons-material/FileDownloadOutlined'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdfOutlined'; +import AutorenewIcon from '@mui/icons-material/Autorenew'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import CloseIcon from '@mui/icons-material/Close'; +import MergeTypeIcon from '@mui/icons-material/MergeType'; +import CallSplitIcon from '@mui/icons-material/CallSplit'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import { Rnd } from 'react-rnd'; +import NavigationWarningModal from '@core/components/shared/NavigationWarningModal'; + +import { + PdfTextEditorViewData, + PdfJsonFont, + PdfJsonPage, + TextGroup, +} from '@app/tools/pdfTextEditor/pdfTextEditorTypes'; +import { getImageBounds, pageDimensions } from '@app/tools/pdfTextEditor/pdfTextEditorUtils'; +import FontStatusPanel from '@app/components/tools/pdfTextEditor/FontStatusPanel'; + +const MAX_RENDER_WIDTH = 820; +const MIN_BOX_SIZE = 18; + +const normalizeFontFormat = (format?: string | null): string => { + if (!format) { + return 'ttf'; + } + const lower = format.toLowerCase(); + if (lower.includes('woff2')) { + return 'woff2'; + } + if (lower.includes('woff')) { + return 'woff'; + } + if (lower.includes('otf')) { + return 'otf'; + } + if (lower.includes('cff')) { + return 'otf'; + } + return 'ttf'; +}; + +const getFontMimeType = (format: string): string => { + switch (format) { + case 'woff2': + return 'font/woff2'; + case 'woff': + return 'font/woff'; + case 'otf': + return 'font/otf'; + default: + return 'font/ttf'; + } +}; + +const getFontFormatHint = (format: string): string | null => { + switch (format) { + case 'woff2': + return 'woff2'; + case 'woff': + return 'woff'; + case 'otf': + return 'opentype'; + case 'ttf': + return 'truetype'; + default: + return null; + } +}; + +const decodeBase64ToUint8Array = (value: string): Uint8Array => { + const binary = window.atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +}; + +const buildFontFamilyName = (font: PdfJsonFont): string => { + const preferred = (font.baseName ?? '').trim(); + const identifier = preferred.length > 0 ? preferred : (font.uid ?? font.id ?? 'font').toString(); + return `pdf-font-${identifier.replace(/[^a-zA-Z0-9_-]/g, '')}`; +}; + +const getCaretOffset = (element: HTMLElement): number => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !element.contains(selection.focusNode)) { + return element.innerText.length; + } + const range = selection.getRangeAt(0).cloneRange(); + range.selectNodeContents(element); + range.setEnd(selection.focusNode as Node, selection.focusOffset); + return range.toString().length; +}; + +const setCaretOffset = (element: HTMLElement, offset: number): void => { + const selection = window.getSelection(); + if (!selection) { + return; + } + const targetOffset = Math.max(0, Math.min(offset, element.innerText.length)); + const range = document.createRange(); + let remaining = targetOffset; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + + let node = walker.nextNode(); + while (node) { + const textNode = node as Text; + const length = textNode.length; + if (remaining <= length) { + range.setStart(textNode, remaining); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return; + } + remaining -= length; + node = walker.nextNode(); + } + + range.selectNodeContents(element); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); +}; + +const extractTextWithSoftBreaks = (element: HTMLElement): { text: string; insertedBreaks: boolean } => { + const normalized = element.innerText.replace(/\u00A0/g, ' '); + if (!element.isConnected) { + return { text: normalized, insertedBreaks: false }; + } + + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); + const range = document.createRange(); + let result = ''; + let previousTop: number | null = null; + let insertedBreaks = false; + + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const nodeText = node.textContent ?? ''; + for (let index = 0; index < nodeText.length; index += 1) { + const char = nodeText[index]; + range.setStart(node, index); + range.setEnd(node, index + 1); + const rect = range.getClientRects()[0]; + + if (previousTop !== null && rect && Math.abs(rect.top - previousTop) > 0.5 && result[result.length - 1] !== '\n') { + result += '\n'; + insertedBreaks = true; + } + + result += char; + if (rect) { + previousTop = rect.top; + } + if (char === '\n') { + previousTop = null; + } + } + } + + return { + text: result.replace(/\u00A0/g, ' '), + insertedBreaks, + }; +}; + +interface PdfTextEditorViewProps { + data: PdfTextEditorViewData; +} + +const toCssBounds = ( + _page: PdfJsonPage | null | undefined, + pageHeight: number, + scale: number, + bounds: { left: number; right: number; top: number; bottom: number }, +) => { + const width = Math.max(bounds.right - bounds.left, 1); + // Note: This codebase uses inverted naming where bounds.bottom > bounds.top + // bounds.bottom = visually upper edge (larger Y in PDF coords) + // bounds.top = visually lower edge (smaller Y in PDF coords) + const height = Math.max(bounds.bottom - bounds.top, 1); + const scaledWidth = Math.max(width * scale, MIN_BOX_SIZE); + const scaledHeight = Math.max(height * scale, MIN_BOX_SIZE / 2); + // Convert PDF's visually upper edge (bounds.bottom) to CSS top + const top = Math.max(pageHeight - bounds.bottom, 0) * scale; + + return { + left: bounds.left * scale, + top, + width: scaledWidth, + height: scaledHeight, + }; +}; + +const normalizePageNumber = (pageIndex: number | null | undefined): number | null => { + if (pageIndex === null || pageIndex === undefined || Number.isNaN(pageIndex)) { + return null; + } + return pageIndex + 1; +}; + +const buildFontLookupKeys = ( + fontId: string, + font: PdfJsonFont | null | undefined, + pageIndex: number | null | undefined, +): string[] => { + const keys: string[] = []; + const pageNumber = normalizePageNumber(pageIndex); + if (pageNumber !== null) { + keys.push(`${pageNumber}:${fontId}`); + } + if (font?.uid) { + keys.push(font.uid); + } + if (font?.pageNumber !== null && font?.pageNumber !== undefined && font?.id) { + keys.push(`${font.pageNumber}:${font.id}`); + } + keys.push(fontId); + return Array.from(new Set(keys.filter((value) => value && value.length > 0))); +}; + +/** + * Analyzes text groups on a page to determine if it's paragraph-heavy or sparse. + * Returns true if the page appears to be document-like with substantial text content. + */ +const analyzePageContentType = (groups: TextGroup[], pageWidth: number): boolean => { + if (groups.length === 0) return false; + + let totalWords = 0; + let longTextGroups = 0; + let totalGroups = 0; + let fullWidthLines = 0; + const wordCounts: number[] = []; + const fullWidthThreshold = pageWidth * 0.7; + + groups.forEach((group) => { + const text = (group.text || '').trim(); + if (text.length === 0) return; + + totalGroups++; + const wordCount = text.split(/\s+/).filter((w) => w.length > 0).length; + + totalWords += wordCount; + wordCounts.push(wordCount); + + // Count text groups with substantial content (≥10 words or ≥50 chars) + if (wordCount >= 10 || text.length >= 50) { + longTextGroups++; + } + + // Check if this line extends close to the right margin + const rightEdge = group.bounds.right; + if (rightEdge >= fullWidthThreshold) { + fullWidthLines++; + } + }); + + if (totalGroups === 0) return false; + + const avgWordsPerGroup = totalWords / totalGroups; + const longTextRatio = longTextGroups / totalGroups; + const fullWidthRatio = fullWidthLines / totalGroups; + + // Calculate variance in line lengths + const variance = wordCounts.reduce((sum, count) => { + const diff = count - avgWordsPerGroup; + return sum + diff * diff; + }, 0) / totalGroups; + const stdDev = Math.sqrt(variance); + const coefficientOfVariation = avgWordsPerGroup > 0 ? stdDev / avgWordsPerGroup : 0; + + // All 3 criteria must pass for paragraph mode + const criterion1 = avgWordsPerGroup > 5; + const criterion2 = longTextRatio > 0.4; + const criterion3 = coefficientOfVariation > 0.5 || fullWidthRatio > 0.6; + + const isParagraphPage = criterion1 && criterion2 && criterion3; + + return isParagraphPage; +}; + +type GroupingMode = 'auto' | 'paragraph' | 'singleLine'; + +const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { + const { t } = useTranslation(); + const [activeGroupId, setActiveGroupId] = useState<string | null>(null); + const [editingGroupId, setEditingGroupId] = useState<string | null>(null); + const [activeImageId, setActiveImageId] = useState<string | null>(null); + const [selectedGroupIds, setSelectedGroupIds] = useState<Set<string>>(new Set()); + const [widthOverrides, setWidthOverrides] = useState<Map<string, number>>(new Map()); + const draggingImageRef = useRef<string | null>(null); + const rndRefs = useRef<Map<string, any>>(new Map()); + const pendingDragUpdateRef = useRef<number | null>(null); + const [fontFamilies, setFontFamilies] = useState<Map<string, string>>(new Map()); + const [autoScaleText, setAutoScaleText] = useState(true); + const [textScales, setTextScales] = useState<Map<string, number>>(new Map()); + const [pendingModeChange, setPendingModeChange] = useState<GroupingMode | null>(null); + const measurementKeyRef = useRef<string>(''); + const containerRef = useRef<HTMLDivElement | null>(null); + const editorRefs = useRef<Map<string, HTMLDivElement>>(new Map()); + const caretOffsetsRef = useRef<Map<string, number>>(new Map()); + const lastSelectedGroupIdRef = useRef<string | null>(null); + const widthOverridesRef = useRef<Map<string, number>>(widthOverrides); + const resizingRef = useRef<{ + groupId: string; + startX: number; + startWidth: number; + baseWidth: number; + maxWidth: number; + } | null>(null); + + // First-time banner state + const [showWelcomeBanner, setShowWelcomeBanner] = useState(() => { + try { + return localStorage.getItem('pdfTextEditor.welcomeBannerDismissed') !== 'true'; + } catch { + return true; + } + }); + + const handleDismissWelcomeBanner = useCallback(() => { + // Just dismiss for this session, don't save to localStorage + setShowWelcomeBanner(false); + }, []); + + const handleDontShowAgain = useCallback(() => { + // Save to localStorage to never show again + try { + localStorage.setItem('pdfTextEditor.welcomeBannerDismissed', 'true'); + } catch { + // Ignore localStorage errors + } + setShowWelcomeBanner(false); + }, []); + + const { + document: pdfDocument, + groupsByPage, + imagesByPage, + pagePreviews, + selectedPage, + dirtyPages, + hasDocument, + hasVectorPreview, + fileName, + errorMessage, + isGeneratingPdf, + isConverting, + conversionProgress, + hasChanges, + forceSingleTextElement, + groupingMode: externalGroupingMode, + requestPagePreview, + onSelectPage, + onGroupEdit, + onGroupDelete, + onImageTransform, + onImageReset, + onReset, + onDownloadJson, + onGeneratePdf, + onGeneratePdfForNavigation, + onForceSingleTextElementChange, + onGroupingModeChange, + onMergeGroups, + onUngroupGroup, + } = data; + + // Define derived variables immediately after props destructuring, before any hooks + const pages = pdfDocument?.pages ?? []; + const currentPage = pages[selectedPage] ?? null; + const pageGroups = groupsByPage[selectedPage] ?? []; + const pageImages = imagesByPage[selectedPage] ?? []; + const pagePreview = pagePreviews.get(selectedPage); + const { width: pageWidth, height: pageHeight } = pageDimensions(currentPage); + + // Debug logging for page dimensions + console.log(`📐 [PdfTextEditor] Page ${selectedPage + 1} Dimensions:`, { + pageWidth, + pageHeight, + aspectRatio: pageHeight > 0 ? (pageWidth / pageHeight).toFixed(3) : 'N/A', + currentPage: currentPage ? { + mediaBox: currentPage.mediaBox, + cropBox: currentPage.cropBox, + rotation: currentPage.rotation, + } : null, + documentMetadata: pdfDocument?.metadata ? { + title: pdfDocument.metadata.title, + pageCount: pages.length, + } : null, + }); + + const handleModeChangeRequest = useCallback((newMode: GroupingMode) => { + if (hasChanges && newMode !== externalGroupingMode) { + // Show confirmation dialog + setPendingModeChange(newMode); + } else { + // No changes, switch immediately + onGroupingModeChange(newMode); + } + }, [hasChanges, externalGroupingMode, onGroupingModeChange]); + + const handleConfirmModeChange = useCallback(() => { + if (pendingModeChange) { + onGroupingModeChange(pendingModeChange); + setPendingModeChange(null); + } + }, [pendingModeChange, onGroupingModeChange]); + + const handleCancelModeChange = useCallback(() => { + setPendingModeChange(null); + }, []); + + const clearSelection = useCallback(() => { + setSelectedGroupIds(new Set()); + lastSelectedGroupIdRef.current = null; + }, []); + + useEffect(() => { + widthOverridesRef.current = widthOverrides; + }, [widthOverrides]); + + const resolveFont = useCallback((fontId: string | null | undefined, pageIndex: number | null | undefined): PdfJsonFont | null => { + if (!fontId || !pdfDocument?.fonts) { + return null; + } + const fonts = pdfDocument.fonts; + const pageNumber = normalizePageNumber(pageIndex); + if (pageNumber !== null) { + const pageMatch = fonts.find((font) => font?.id === fontId && font?.pageNumber === pageNumber); + if (pageMatch) { + return pageMatch; + } + const uidKey = `${pageNumber}:${fontId}`; + const uidMatch = fonts.find((font) => font?.uid === uidKey); + if (uidMatch) { + return uidMatch; + } + } + const directUid = fonts.find((font) => font?.uid === fontId); + if (directUid) { + return directUid; + } + return fonts.find((font) => font?.id === fontId) ?? null; + }, [pdfDocument?.fonts]); + + const getFontFamily = useCallback((fontId: string | null | undefined, pageIndex: number | null | undefined): string => { + if (!fontId) { + return 'sans-serif'; + } + + const font = resolveFont(fontId, pageIndex); + const lookupKeys = buildFontLookupKeys(fontId, font ?? undefined, pageIndex); + for (const key of lookupKeys) { + const loadedFamily = fontFamilies.get(key); + if (loadedFamily) { + return `'${loadedFamily}', sans-serif`; + } + } + + const fontName = font?.standard14Name || font?.baseName || ''; + const lowerName = fontName.toLowerCase(); + + if (lowerName.includes('times')) { + return '"Times New Roman", Times, serif'; + } + if (lowerName.includes('helvetica') || lowerName.includes('arial')) { + return 'Arial, Helvetica, sans-serif'; + } + if (lowerName.includes('courier')) { + return '"Courier New", Courier, monospace'; + } + if (lowerName.includes('symbol')) { + return 'Symbol, serif'; + } + + return 'Arial, Helvetica, sans-serif'; + }, [resolveFont, fontFamilies]); + + useEffect(() => { + clearSelection(); + }, [clearSelection, selectedPage]); + + useEffect(() => { + clearSelection(); + }, [clearSelection, externalGroupingMode]); + + useEffect(() => { + setWidthOverrides(new Map()); + }, [pdfDocument]); + + useEffect(() => { + setSelectedGroupIds((prev) => { + const filtered = Array.from(prev).filter((id) => pageGroups.some((group) => group.id === id)); + if (filtered.length === prev.size) { + return prev; + } + return new Set(filtered); + }); + setWidthOverrides((prev) => { + const filtered = new Map<string, number>(); + pageGroups.forEach((group) => { + if (prev.has(group.id)) { + filtered.set(group.id, prev.get(group.id) ?? 0); + } + }); + if (filtered.size === prev.size) { + return prev; + } + return filtered; + }); + }, [pageGroups]); + + // Detect if current page contains paragraph-heavy content + const isParagraphPage = useMemo(() => { + const result = analyzePageContentType(pageGroups, pageWidth); + console.log(`🏷️ Page ${selectedPage} badge: ${result ? 'PARAGRAPH' : 'SPARSE'} (${pageGroups.length} groups)`); + return result; + }, [pageGroups, pageWidth, selectedPage]); + const isParagraphLayout = + externalGroupingMode === 'paragraph' || (externalGroupingMode === 'auto' && isParagraphPage); + + const resolveGroupWidth = useCallback( + (group: TextGroup): { width: number; base: number; max: number } => { + const baseWidth = Math.max(group.bounds.right - group.bounds.left, 1); + const maxWidth = Math.max(pageWidth - group.bounds.left, baseWidth); + const override = widthOverrides.get(group.id); + const resolved = override ? Math.min(Math.max(override, baseWidth), maxWidth) : baseWidth; + return { width: resolved, base: baseWidth, max: maxWidth }; + }, + [pageWidth, widthOverrides], + ); + + const selectedGroupIdsArray = useMemo(() => Array.from(selectedGroupIds), [selectedGroupIds]); + const selectionIndices = useMemo(() => { + return selectedGroupIdsArray + .map((id) => pageGroups.findIndex((group) => group.id === id)) + .filter((index) => index >= 0) + .sort((a, b) => a - b); + }, [pageGroups, selectedGroupIdsArray]); + const canMergeSelection = selectionIndices.length >= 2 && selectionIndices.every((value, idx, array) => idx === 0 || value === array[idx - 1] + 1); + const paragraphSelectionIds = useMemo(() => + selectedGroupIdsArray.filter((id) => { + const target = pageGroups.find((group) => group.id === id); + return target ? (target.childLineGroups?.length ?? 0) > 1 : false; + }), + [pageGroups, selectedGroupIdsArray]); + const canUngroupSelection = paragraphSelectionIds.length > 0; + const hasWidthOverrides = selectedGroupIdsArray.some((id) => widthOverrides.has(id)); + const hasSelection = selectedGroupIdsArray.length > 0; + + const syncEditorValue = useCallback( + ( + element: HTMLElement, + pageIndex: number, + groupId: string, + options?: { skipCaretRestore?: boolean }, + ) => { + const { text: value } = extractTextWithSoftBreaks(element); + const offset = getCaretOffset(element); + caretOffsetsRef.current.set(groupId, offset); + onGroupEdit(pageIndex, groupId, value); + if (options?.skipCaretRestore) { + return; + } + requestAnimationFrame(() => { + if (editingGroupId !== groupId) { + return; + } + const editor = editorRefs.current.get(groupId); + if (editor) { + const savedOffset = caretOffsetsRef.current.get(groupId) ?? editor.innerText.length; + setCaretOffset(editor, savedOffset); + } + }); + }, + [editingGroupId, onGroupEdit], + ); + + const handleMergeSelection = useCallback(() => { + if (!canMergeSelection) { + return; + } + const orderedIds = selectionIndices + .map((index) => pageGroups[index]?.id) + .filter((value): value is string => Boolean(value)); + if (orderedIds.length < 2) { + return; + } + const merged = onMergeGroups(selectedPage, orderedIds); + if (merged) { + clearSelection(); + } + }, [canMergeSelection, selectionIndices, pageGroups, onMergeGroups, selectedPage, clearSelection]); + + const handleUngroupSelection = useCallback(() => { + if (!canUngroupSelection) { + return; + } + let changed = false; + paragraphSelectionIds.forEach((id) => { + const result = onUngroupGroup(selectedPage, id); + if (result) { + changed = true; + } + }); + if (changed) { + clearSelection(); + } + }, [canUngroupSelection, paragraphSelectionIds, onUngroupGroup, selectedPage, clearSelection]); + + const handleWidthAdjustment = useCallback( + (mode: 'expand' | 'reset') => { + if (mode === 'expand' && !hasSelection) { + return; + } + if (mode === 'reset' && !hasWidthOverrides) { + return; + } + const selectedGroups = selectedGroupIdsArray + .map((id) => pageGroups.find((group) => group.id === id)) + .filter((group): group is TextGroup => Boolean(group)); + if (selectedGroups.length === 0) { + return; + } + setWidthOverrides((prev) => { + const next = new Map(prev); + selectedGroups.forEach((group) => { + const baseWidth = Math.max(group.bounds.right - group.bounds.left, 1); + const maxWidth = Math.max(pageWidth - group.bounds.left, baseWidth); + if (mode === 'expand') { + next.set(group.id, maxWidth); + } else { + next.delete(group.id); + } + }); + return next; + }); + }, + [hasSelection, hasWidthOverrides, selectedGroupIdsArray, pageGroups, pageWidth], + ); + + const extractPreferredFontId = useCallback((target?: TextGroup | null) => { + if (!target) { + return undefined; + } + if (target.fontId) { + return target.fontId; + } + for (const element of target.originalElements ?? []) { + if (element.fontId) { + return element.fontId; + } + } + for (const element of target.elements ?? []) { + if (element.fontId) { + return element.fontId; + } + } + return undefined; + }, []); + + const resolveFontIdForIndex = useCallback( + (index: number): string | null | undefined => { + if (index < 0 || index >= pageGroups.length) { + return undefined; + } + const direct = extractPreferredFontId(pageGroups[index]); + if (direct) { + return direct; + } + for (let offset = 1; offset < pageGroups.length; offset += 1) { + const prevIndex = index - offset; + if (prevIndex >= 0) { + const candidate = extractPreferredFontId(pageGroups[prevIndex]); + if (candidate) { + return candidate; + } + } + const nextIndex = index + offset; + if (nextIndex < pageGroups.length) { + const candidate = extractPreferredFontId(pageGroups[nextIndex]); + if (candidate) { + return candidate; + } + } + } + return undefined; + }, + [extractPreferredFontId, pageGroups], + ); + + const fontMetrics = useMemo(() => { + const metrics = new Map<string, { unitsPerEm: number; ascent: number; descent: number }>(); + pdfDocument?.fonts?.forEach((font) => { + if (!font?.id) { + return; + } + const unitsPerEm = font.unitsPerEm && font.unitsPerEm > 0 ? font.unitsPerEm : 1000; + const ascent = font.ascent ?? unitsPerEm; + const descent = font.descent ?? -(unitsPerEm * 0.2); + const metric = { unitsPerEm, ascent, descent }; + metrics.set(font.id, metric); + if (font.uid) { + metrics.set(font.uid, metric); + } + if (font.pageNumber !== null && font.pageNumber !== undefined) { + metrics.set(`${font.pageNumber}:${font.id}`, metric); + } + }); + return metrics; + }, [pdfDocument?.fonts]); + + useEffect(() => { + if (typeof FontFace === 'undefined') { + setFontFamilies(new Map()); + return undefined; + } + + let disposed = false; + const active: { fontFace: FontFace; url?: string }[] = []; + + const registerFonts = async () => { + const fonts = pdfDocument?.fonts ?? []; + if (fonts.length === 0) { + setFontFamilies(new Map()); + return; + } + + const next = new Map<string, string>(); + const pickFontSource = ( + font: PdfJsonFont + ): { data: string; format?: string | null; source: 'pdfProgram' | 'webProgram' | 'program' } | null => { + if (font.pdfProgram && font.pdfProgram.length > 0) { + return { data: font.pdfProgram, format: font.pdfProgramFormat, source: 'pdfProgram' }; + } + if (font.webProgram && font.webProgram.length > 0) { + return { data: font.webProgram, format: font.webProgramFormat, source: 'webProgram' }; + } + if (font.program && font.program.length > 0) { + return { data: font.program, format: font.programFormat, source: 'program' }; + } + return null; + }; + + const registerLoadedFontKeys = (font: PdfJsonFont, familyName: string) => { + if (font.id) { + next.set(font.id, familyName); + } + if (font.uid) { + next.set(font.uid, familyName); + } + if (font.pageNumber !== null && font.pageNumber !== undefined && font.id) { + next.set(`${font.pageNumber}:${font.id}`, familyName); + } + }; + + for (const font of fonts) { + if (!font || !font.id) { + continue; + } + const selection = pickFontSource(font); + if (!selection) { + continue; + } + try { + const formatSource = selection.format; + const format = normalizeFontFormat(formatSource); + const data = decodeBase64ToUint8Array(selection.data); + const blob = new Blob([data as BlobPart], { type: getFontMimeType(format) }); + const url = URL.createObjectURL(blob); + const formatHint = getFontFormatHint(format); + const familyName = buildFontFamilyName(font); + const source = formatHint ? `url(${url}) format('${formatHint}')` : `url(${url})`; + const fontFace = new FontFace(familyName, source); + + console.debug(`[FontLoader] Loading font ${font.id} (${font.baseName}) using ${selection.source}:`, { + formatSource, + format, + formatHint, + familyName, + dataLength: data.length, + hasPdfProgram: !!font.pdfProgram, + hasWebProgram: !!font.webProgram, + hasProgram: !!font.program + }); + + await fontFace.load(); + if (disposed) { + document.fonts.delete(fontFace); + URL.revokeObjectURL(url); + continue; + } + document.fonts.add(fontFace); + active.push({ fontFace, url }); + registerLoadedFontKeys(font, familyName); + console.debug(`[FontLoader] Successfully loaded font ${font.id}`); + } catch (error) { + console.warn(`[FontLoader] Failed to load font ${font.id} (${font.baseName}) using ${selection.source}:`, { + error: error instanceof Error ? error.message : String(error), + formatSource: selection.format, + hasPdfProgram: !!font.pdfProgram, + hasWebProgram: !!font.webProgram, + hasProgram: !!font.program + }); + // Fallback to web-safe fonts is already implemented via getFontFamily() + } + } + + if (!disposed) { + setFontFamilies(next); + } else { + active.forEach(({ fontFace, url }) => { + document.fonts.delete(fontFace); + if (url) { + URL.revokeObjectURL(url); + } + }); + } + }; + + registerFonts(); + + return () => { + disposed = true; + active.forEach(({ fontFace, url }) => { + document.fonts.delete(fontFace); + if (url) { + URL.revokeObjectURL(url); + } + }); + }; + }, [pdfDocument?.fonts]); + + // Define helper functions that depend on hooks AFTER all hook calls + const getFontMetricsFor = useCallback(( + fontId: string | null | undefined, + pageIndex: number | null | undefined, + ): { unitsPerEm: number; ascent: number; descent: number } | undefined => { + if (!fontId) { + return undefined; + } + const font = resolveFont(fontId, pageIndex); + const lookupKeys = buildFontLookupKeys(fontId, font ?? undefined, pageIndex); + for (const key of lookupKeys) { + const metrics = fontMetrics.get(key); + if (metrics) { + return metrics; + } + } + return undefined; + }, [resolveFont, fontMetrics]); + + const getLineHeightPx = useCallback(( + fontId: string | null | undefined, + pageIndex: number | null | undefined, + fontSizePx: number, + ): number => { + if (fontSizePx <= 0) { + return fontSizePx; + } + const metrics = getFontMetricsFor(fontId, pageIndex); + if (!metrics || metrics.unitsPerEm <= 0) { + return fontSizePx * 1.2; + } + const unitsPerEm = metrics.unitsPerEm > 0 ? metrics.unitsPerEm : 1000; + const ascentUnits = metrics.ascent ?? unitsPerEm; + const descentUnits = Math.abs(metrics.descent ?? -(unitsPerEm * 0.2)); + const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits); + if (totalUnits <= 0) { + return fontSizePx * 1.2; + } + const lineHeight = (totalUnits / unitsPerEm) * fontSizePx; + return Math.max(lineHeight, fontSizePx * 1.05); + }, [getFontMetricsFor]); + + const getFontGeometry = useCallback(( + fontId: string | null | undefined, + pageIndex: number | null | undefined, + ): { + unitsPerEm: number; + ascentUnits: number; + descentUnits: number; + totalUnits: number; + ascentRatio: number; + descentRatio: number; + } | undefined => { + const metrics = getFontMetricsFor(fontId, pageIndex); + if (!metrics) { + return undefined; + } + const unitsPerEm = metrics.unitsPerEm > 0 ? metrics.unitsPerEm : 1000; + const rawAscent = metrics.ascent ?? unitsPerEm; + const rawDescent = metrics.descent ?? -(unitsPerEm * 0.2); + const ascentUnits = Number.isFinite(rawAscent) ? rawAscent : unitsPerEm; + const descentUnits = Number.isFinite(rawDescent) ? Math.abs(rawDescent) : unitsPerEm * 0.2; + const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits); + if (totalUnits <= 0 || !Number.isFinite(totalUnits)) { + return undefined; + } + return { + unitsPerEm, + ascentUnits, + descentUnits, + totalUnits, + ascentRatio: ascentUnits / totalUnits, + descentRatio: descentUnits / totalUnits, + }; + }, [getFontMetricsFor]); + + const getFontWeight = useCallback(( + fontId: string | null | undefined, + pageIndex: number | null | undefined, + ): number | 'normal' | 'bold' => { + if (!fontId) { + return 'normal'; + } + const font = resolveFont(fontId, pageIndex); + if (!font || !font.fontDescriptorFlags) { + return 'normal'; + } + + // PDF font descriptor flag bit 18 (value 262144 = 0x40000) indicates ForceBold + const FORCE_BOLD_FLAG = 262144; + if ((font.fontDescriptorFlags & FORCE_BOLD_FLAG) !== 0) { + return 'bold'; + } + + // Also check if font name contains "Bold" + const fontName = font.standard14Name || font.baseName || ''; + if (fontName.toLowerCase().includes('bold')) { + return 'bold'; + } + + return 'normal'; + }, [resolveFont]); + + const visibleGroups = useMemo( + () => + pageGroups + .map((group, index) => ({ group, pageGroupIndex: index })) + .filter(({ group }) => { + const hasContent = + ((group.text ?? '').trim().length > 0) || + ((group.originalText ?? '').trim().length > 0); + return hasContent || editingGroupId === group.id; + }), + [editingGroupId, pageGroups], + ); + +const orderedImages = useMemo( + () => + [...pageImages].sort( + (first, second) => (first?.zOrder ?? -1_000_000) - (second?.zOrder ?? -1_000_000), + ), + [pageImages], +); +const scale = useMemo(() => { + const calculatedScale = Math.min(MAX_RENDER_WIDTH / pageWidth, 2.5); + console.log(`🔍 [PdfTextEditor] Scale Calculation:`, { + MAX_RENDER_WIDTH, + pageWidth, + pageHeight, + calculatedScale: calculatedScale.toFixed(3), + scaledWidth: (pageWidth * calculatedScale).toFixed(2), + scaledHeight: (pageHeight * calculatedScale).toFixed(2), + }); + return calculatedScale; +}, [pageWidth, pageHeight]); +const scaledWidth = pageWidth * scale; +const scaledHeight = pageHeight * scale; +const selectionToolbarPosition = useMemo(() => { + if (!hasSelection) { + return null; + } + const firstSelected = pageGroups.find((group) => selectedGroupIds.has(group.id)); + if (!firstSelected) { + return null; + } + const bounds = toCssBounds(currentPage, pageHeight, scale, firstSelected.bounds); + const top = Math.max(bounds.top - 40, 8); + const left = Math.min(Math.max(bounds.left, 8), Math.max(scaledWidth - 220, 8)); + return { left, top }; +}, [hasSelection, pageGroups, selectedGroupIds, currentPage, pageHeight, scale, scaledWidth]); + + useEffect(() => { + if (!hasDocument || !hasVectorPreview) { + return; + } + requestPagePreview(selectedPage, scale); + if (selectedPage + 1 < pages.length) { + requestPagePreview(selectedPage + 1, scale); + } + }, [hasDocument, hasVectorPreview, selectedPage, scale, pages.length, requestPagePreview]); + + useEffect(() => { + setActiveGroupId(null); + setEditingGroupId(null); + setActiveImageId(null); + setTextScales(new Map()); + measurementKeyRef.current = ''; + }, [selectedPage]); + + // Measure text widths once per page/configuration and apply static scaling + useLayoutEffect(() => { + if (!autoScaleText) { + // Clear all scales when auto-scale is disabled + setTextScales(new Map()); + measurementKeyRef.current = ''; + return; + } + + if (visibleGroups.length === 0) { + return; + } + + // Create a stable key for this measurement configuration + const currentKey = `${selectedPage}-${fontFamilies.size}-${autoScaleText}`; + + // Skip if we've already measured for this configuration + if (measurementKeyRef.current === currentKey) { + return; + } + + const measureTextScales = () => { + const newScales = new Map<string, number>(); + + visibleGroups.forEach(({ group }) => { + // Skip groups that are being edited + if (editingGroupId === group.id) { + return; + } + + // Only apply auto-scaling to unchanged text + const hasChanges = group.text !== group.originalText; + if (hasChanges) { + newScales.set(group.id, 1); + return; + } + + const lineCount = (group.text || '').split('\n').length; + + // Skip multi-line paragraphs - auto-scaling doesn't work well with wrapped text + if (lineCount > 1) { + newScales.set(group.id, 1); + return; + } + + const element = document.querySelector<HTMLElement>(`[data-text-group="${group.id}"]`); + if (!element) { + return; + } + + const textSpan = element.querySelector<HTMLSpanElement>('span[data-text-content]'); + if (!textSpan) { + return; + } + + // Temporarily remove any existing transform to get natural width + const originalTransform = textSpan.style.transform; + textSpan.style.transform = 'none'; + + const _bounds = toCssBounds(currentPage, pageHeight, scale, group.bounds); + const { width: resolvedWidth } = resolveGroupWidth(group); + const containerWidth = resolvedWidth * scale; + const textWidth = textSpan.getBoundingClientRect().width; + + // Restore original transform + textSpan.style.transform = originalTransform; + + // Only scale if text overflows by more than 2% + if (textWidth > 0 && textWidth > containerWidth * 1.02) { + const scaleX = Math.max(containerWidth / textWidth, 0.5); // Min 50% scale + newScales.set(group.id, scaleX); + } else { + newScales.set(group.id, 1); + } + }); + + // Mark this configuration as measured + measurementKeyRef.current = currentKey; + setTextScales(newScales); + }; + + // Delay measurement to ensure fonts and layout are ready + const timer = setTimeout(measureTextScales, 150); + return () => clearTimeout(timer); + }, [ + autoScaleText, + visibleGroups, + editingGroupId, + currentPage, + pageHeight, + scale, + fontFamilies.size, + selectedPage, + isParagraphLayout, + resolveGroupWidth, + ]); + + useLayoutEffect(() => { + // Only restore caret position during re-renders while already editing + // Don't interfere with initial click-to-position behavior + if (!editingGroupId) { + return; + } + const editor = editorRefs.current.get(editingGroupId); + if (!editor) { + return; + } + const offset = caretOffsetsRef.current.get(editingGroupId); + // Only restore if we have a saved offset (meaning user was already typing) + if (offset === undefined || offset === 0) { + return; + } + setCaretOffset(editor, offset); + }, [editingGroupId, groupsByPage, imagesByPage]); + + useEffect(() => { + if (!editingGroupId) { + return; + } + const editor = document.querySelector<HTMLElement>(`[data-editor-group="${editingGroupId}"]`); + if (editor) { + if (document.activeElement !== editor) { + editor.focus(); + } + } + }, [editingGroupId]); + + // Sync image positions when not dragging (handles stutters/re-renders) + useLayoutEffect(() => { + const isDragging = draggingImageRef.current !== null; + if (isDragging) { + return; // Don't sync during drag + } + + pageImages.forEach((image) => { + if (!image?.id) return; + + const imageId = image.id; + const rndRef = rndRefs.current.get(imageId); + if (!rndRef || !rndRef.updatePosition) return; + + const bounds = getImageBounds(image); + const _width = Math.max(bounds.right - bounds.left, 1); + const _height = Math.max(bounds.top - bounds.bottom, 1); + const cssLeft = bounds.left * scale; + const cssTop = (pageHeight - bounds.top) * scale; + + // Get current position from Rnd component + const currentState = rndRef.state || {}; + const currentX = currentState.x ?? 0; + const currentY = currentState.y ?? 0; + + // Calculate drift + const drift = Math.abs(currentX - cssLeft) + Math.abs(currentY - cssTop); + + // Only sync if drift is significant (more than 3px) + if (drift > 3) { + rndRef.updatePosition({ x: cssLeft, y: cssTop }); + } + }); + }, [pageImages, scale, pageHeight]); + + const handlePageChange = (pageNumber: number) => { + setActiveGroupId(null); + setEditingGroupId(null); + clearSelection(); + onSelectPage(pageNumber - 1); + }; + + const handleBackgroundClick = () => { + setEditingGroupId(null); + setActiveGroupId(null); + setActiveImageId(null); + clearSelection(); + }; + + const handleSelectionInteraction = useCallback( + (groupId: string, groupIndex: number, event: React.MouseEvent): boolean => { + const multiSelect = event.metaKey || event.ctrlKey; + const rangeSelect = event.shiftKey && lastSelectedGroupIdRef.current !== null; + setSelectedGroupIds((previous) => { + if (multiSelect) { + const next = new Set(previous); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + } + if (rangeSelect) { + const anchorId = lastSelectedGroupIdRef.current; + const anchorIndex = anchorId ? pageGroups.findIndex((group) => group.id === anchorId) : -1; + if (anchorIndex === -1) { + return new Set([groupId]); + } + const start = Math.min(anchorIndex, groupIndex); + const end = Math.max(anchorIndex, groupIndex); + const next = new Set<string>(); + for (let idx = start; idx <= end; idx += 1) { + const candidate = pageGroups[idx]; + if (candidate) { + next.add(candidate.id); + } + } + return next; + } + return new Set([groupId]); + }); + if (!rangeSelect) { + lastSelectedGroupIdRef.current = groupId; + } + return !(multiSelect || rangeSelect); + }, + [pageGroups], + ); + + const handleResizeStart = useCallback( + (event: React.MouseEvent, group: TextGroup, currentWidth: number) => { + const baseWidth = Math.max(group.bounds.right - group.bounds.left, 1); + const maxWidth = Math.max(pageWidth - group.bounds.left, baseWidth); + event.stopPropagation(); + event.preventDefault(); + const startX = event.clientX; + const handleMouseMove = (moveEvent: MouseEvent) => { + const context = resizingRef.current; + if (!context) { + return; + } + moveEvent.preventDefault(); + const deltaPx = moveEvent.clientX - context.startX; + const deltaWidth = deltaPx / scale; + const nextWidth = Math.min( + Math.max(context.startWidth + deltaWidth, context.baseWidth), + context.maxWidth, + ); + setWidthOverrides((prev) => { + const next = new Map(prev); + if (Math.abs(nextWidth - context.baseWidth) <= 0.5) { + next.delete(context.groupId); + } else { + next.set(context.groupId, nextWidth); + } + return next; + }); + }; + const handleMouseUp = () => { + resizingRef.current = null; + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + resizingRef.current = { + groupId: group.id, + startX, + startWidth: currentWidth, + baseWidth, + maxWidth, + }; + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + }, + [pageWidth, scale], + ); + + const renderGroupContainer = ( + groupId: string, + pageIndex: number, + isActive: boolean, + isChanged: boolean, + content: React.ReactNode, + onActivate?: (event: React.MouseEvent) => void, + onClick?: (event: React.MouseEvent) => void, + isSelected = false, + resizeHandle?: React.ReactNode, + ) => ( + <Box + component="div" + style={{ + width: 'calc(100% + 4px)', + height: 'calc(100% + 6px)', + marginLeft: '-2px', + marginTop: '-3px', + outline: isActive + ? '2px solid var(--mantine-color-blue-5)' + : isSelected + ? '1px solid var(--mantine-color-violet-5)' + : isChanged + ? '1px solid var(--mantine-color-yellow-5)' + : 'none', + outlineOffset: '-1px', + borderRadius: 6, + backgroundColor: isActive + ? 'rgba(184,212,255,0.35)' + : isSelected + ? 'rgba(206,190,255,0.32)' + : isChanged + ? 'rgba(250,255,189,0.28)' + : 'transparent', + transition: 'outline 120ms ease, background-color 120ms ease', + pointerEvents: 'auto', + overflow: 'visible', + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'flex-start', + padding: '3px 2px 3px 2px', + position: 'relative', + }} + onClick={(event) => { + event.stopPropagation(); + if (onClick) { + onClick(event); + } else { + onActivate?.(event); + } + }} + > + {content} + {resizeHandle} + {activeGroupId === groupId && ( + <ActionIcon + size="xs" + variant="filled" + color="red" + radius="xl" + style={{ + position: 'absolute', + top: -8, + right: -8, + zIndex: 9999, + cursor: 'pointer', + pointerEvents: 'auto', + }} + onMouseDown={(event) => { + console.log(`❌ MOUSEDOWN on X button for group ${groupId}`); + event.stopPropagation(); + event.preventDefault(); + + // Find the current group to check if it's already empty + const currentGroups = groupsByPage[pageIndex] ?? []; + const currentGroup = currentGroups.find(g => g.id === groupId); + const currentText = (currentGroup?.text ?? '').trim(); + + if (currentText.length === 0) { + // Already empty - remove the textbox entirely + console.log(` Text already empty, removing textbox`); + onGroupDelete(pageIndex, groupId); + setActiveGroupId(null); + setEditingGroupId(null); + } else { + // Has text - clear it but keep the textbox + console.log(` Clearing text (textbox remains)`); + onGroupEdit(pageIndex, groupId, ''); + } + console.log(` Operation completed`); + }} + onClick={(event) => { + console.log(`❌ X button ONCLICK fired for group ${groupId} on page ${pageIndex}`); + event.stopPropagation(); + event.preventDefault(); + }} + > + <CloseIcon style={{ fontSize: 12 }} /> + </ActionIcon> + )} + </Box> + ); + + const emitImageTransform = useCallback( + ( + imageId: string, + leftPx: number, + topPx: number, + widthPx: number, + heightPx: number, + ) => { + const rawLeft = leftPx / scale; + const rawTop = pageHeight - topPx / scale; + const width = Math.max(widthPx / scale, 0.01); + const height = Math.max(heightPx / scale, 0.01); + const maxLeft = Math.max(pageWidth - width, 0); + const left = Math.min(Math.max(rawLeft, 0), maxLeft); + const minTop = Math.min(height, pageHeight); + const top = Math.min(Math.max(rawTop, minTop), pageHeight); + const bottom = Math.max(top - height, 0); + onImageTransform(selectedPage, imageId, { left, bottom, width, height, transform: [] }); + }, + [onImageTransform, pageHeight, pageWidth, scale, selectedPage], + ); + + return ( + <Stack + gap="xl" + className="h-full" + style={{ + padding: '1.5rem', + overflow: 'hidden', + height: '100%', + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) 320px', + alignItems: 'start', + gap: '1.5rem', + }} + > + <Card + withBorder + radius="md" + shadow="xs" + padding="md" + style={{ + gridColumn: '2 / 3', + gridRow: 1, + maxHeight: 'calc(100vh - 3rem)', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden' + }} + > + <ScrollArea style={{ flex: 1 }} offsetScrollbars> + <Stack gap="sm" pb="md"> + <Group justify="space-between" align="center"> + <Group gap="xs" align="center"> + <DescriptionIcon fontSize="small" /> + <Title order={3}>{t('pdfTextEditor.title', 'PDF JSON Editor')} + {hasChanges && {t('pdfTextEditor.badges.unsaved', 'Edited')}} + + + + + + + + + + {fileName && ( + + {t('pdfTextEditor.currentFile', 'Current file: {{name}}', { name: fileName })} + + )} + + + + +
+ + {t('pdfTextEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')} + + + {t( + 'pdfTextEditor.options.autoScaleText.description', + 'Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF.' + )} + +
+ setAutoScaleText(event.currentTarget.checked)} + /> +
+ + + + + {t('pdfTextEditor.options.groupingMode.title', 'Text Grouping Mode')} + + {externalGroupingMode === 'auto' && isParagraphPage && ( + + {t('pdfTextEditor.pageType.paragraph', 'Paragraph page')} + + )} + {externalGroupingMode === 'auto' && !isParagraphPage && hasDocument && ( + + {t('pdfTextEditor.pageType.sparse', 'Sparse text')} + + )} + + + {externalGroupingMode === 'auto' + ? t( + 'pdfTextEditor.options.groupingMode.autoDescription', + 'Automatically detects page type and groups text appropriately.' + ) + : externalGroupingMode === 'paragraph' + ? t( + 'pdfTextEditor.options.groupingMode.paragraphDescription', + 'Groups aligned lines into multi-line paragraph text boxes.' + ) + : t( + 'pdfTextEditor.options.groupingMode.singleLineDescription', + 'Keeps each PDF text line as a separate text box.' + )} + + handleModeChangeRequest(value as GroupingMode)} + data={[ + { label: t('pdfTextEditor.groupingMode.auto', 'Auto'), value: 'auto' }, + { label: t('pdfTextEditor.groupingMode.paragraph', 'Paragraph'), value: 'paragraph' }, + { label: t('pdfTextEditor.groupingMode.singleLine', 'Single Line'), value: 'singleLine' }, + ]} + fullWidth + /> + + + + {t( + 'pdfTextEditor.options.manualGrouping.descriptionInline', + 'Tip: Hold Ctrl (Cmd) or Shift to multi-select text boxes. A floating toolbar will appear above the selection so you can merge, ungroup, or adjust widths.', + )} + + + +
+ + {t('pdfTextEditor.options.forceSingleElement.title', 'Lock edited text to a single PDF element')} + + + {t( + 'pdfTextEditor.options.forceSingleElement.description', + 'When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts.' + )} + +
+ onForceSingleTextElementChange(event.currentTarget.checked)} + /> +
+ + + + + + + + + + {t('pdfTextEditor.disclaimer.heading', 'Preview Limitations')} + + + + + + + {t( + 'pdfTextEditor.disclaimer.textFocus', + 'This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here.' + )} + + + {t( + 'pdfTextEditor.disclaimer.previewVariance', + 'Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible.' + )} + + + {t( + 'pdfTextEditor.disclaimer.alpha', + 'This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing.' + )} + + + + + + + {hasDocument && } +
+ + + + {errorMessage && ( + } + color="red" + radius="md" + style={{ gridColumn: '2 / 3' }} + > + {errorMessage} + + )} + + {!hasDocument && !isConverting && ( + + + + + {t('pdfTextEditor.empty.title', 'No document loaded')} + + + {t('pdfTextEditor.empty.subtitle', 'Load a PDF or JSON file to begin editing text content.')} + + + + )} + + {isConverting && ( + + + +
+ + {conversionProgress + ? conversionProgress.message + : t('pdfTextEditor.converting', 'Converting PDF to editable format...')} + + {conversionProgress && ( + + + {t(`pdfTextEditor.stages.${conversionProgress.stage}`, conversionProgress.stage)} + + {conversionProgress.current !== undefined && + conversionProgress.total !== undefined && ( + + • Page {conversionProgress.current} of {conversionProgress.total} + + )} + + )} +
+ +
+ +
+
+ )} + + {hasDocument && ( + + + + + {t('pdfTextEditor.pageSummary', 'Page {{number}} of {{total}}', { + number: selectedPage + 1, + total: pages.length, + })} + + {dirtyPages[selectedPage] && ( + + {t('pdfTextEditor.badges.modified', 'Edited')} + + )} + + {t('pdfTextEditor.badges.earlyAccess', 'Early Access')} + + + {pages.length > 1 && ( + + )} + + + + + {t('pdfTextEditor.welcomeBanner.title', 'Welcome to PDF Text Editor (Early Access)')} + + } + centered + size="lg" + > + + + + + {t('pdfTextEditor.welcomeBanner.experimental', 'This is an experimental feature in active development. Expect some instability and issues during use.')} + + + + {t('pdfTextEditor.welcomeBanner.howItWorks', 'This tool converts your PDF to an editable format where you can modify text content and reposition images. Changes are saved back as a new PDF.')} + + + + {t('pdfTextEditor.welcomeBanner.bestFor', 'Works Best With:')} + + +
  • {t('pdfTextEditor.welcomeBanner.bestFor1', 'Simple PDFs containing primarily text and images')}
  • +
  • {t('pdfTextEditor.welcomeBanner.bestFor2', 'Documents with standard paragraph formatting')}
  • +
  • {t('pdfTextEditor.welcomeBanner.bestFor3', 'Letters, essays, reports, and basic documents')}
  • +
    + + + {t('pdfTextEditor.welcomeBanner.notIdealFor', 'Not Ideal For:')} + + +
  • {t('pdfTextEditor.welcomeBanner.notIdealFor1', 'PDFs with special formatting like bullet points, tables, or multi-column layouts')}
  • +
  • {t('pdfTextEditor.welcomeBanner.notIdealFor2', 'Magazines, brochures, or heavily designed documents')}
  • +
  • {t('pdfTextEditor.welcomeBanner.notIdealFor3', 'Instruction manuals with complex layouts')}
  • +
    + + + {t('pdfTextEditor.welcomeBanner.limitations', 'Current Limitations:')} + + +
  • {t('pdfTextEditor.welcomeBanner.limitation1', 'Font rendering may differ slightly from the original PDF')}
  • +
  • {t('pdfTextEditor.welcomeBanner.limitation2', 'Complex graphics, form fields, and annotations are preserved but not editable')}
  • +
  • {t('pdfTextEditor.welcomeBanner.limitation3', 'Large files may take time to convert and process')}
  • +
    + + + {t('pdfTextEditor.welcomeBanner.knownIssues', 'Known Issues (Being Fixed):')} + + +
  • {t('pdfTextEditor.welcomeBanner.issue1', 'Text colour is not currently preserved (will be added soon)')}
  • +
  • {t('pdfTextEditor.welcomeBanner.issue2', 'Paragraph mode has more alignment and spacing issues - Single Line mode recommended')}
  • +
  • {t('pdfTextEditor.welcomeBanner.issue3', 'The preview display differs from the exported PDF - exported PDFs are closer to the original')}
  • +
  • {t('pdfTextEditor.welcomeBanner.issue4', 'Rotated text alignment may need manual adjustment')}
  • +
  • {t('pdfTextEditor.welcomeBanner.issue5', 'Transparency and layering effects may vary from original')}
  • +
    + + + {t('pdfTextEditor.welcomeBanner.feedback', 'This is an early access feature. Please report any issues you encounter to help us improve!')} + + + + + +
    +
    +
    + + + + + + { + containerRef.current = node; + if (node) { + console.log(`🖼️ [PdfTextEditor] Canvas Rendered:`, { + renderedWidth: node.offsetWidth, + renderedHeight: node.offsetHeight, + styleWidth: scaledWidth, + styleHeight: scaledHeight, + pageNumber: selectedPage + 1, + }); + } + }} + > + {pagePreview && ( + {t('pdfTextEditor.pagePreviewAlt', + )} + {selectionToolbarPosition && ( + { + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + }} + > + {canMergeSelection && ( + + + + + + )} + {canUngroupSelection && ( + + + + + + )} + + + event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + + + + + handleWidthAdjustment('expand')} + > + {t('pdfTextEditor.manual.expandWidth', 'Expand to page edge')} + + handleWidthAdjustment('reset')} + > + {t('pdfTextEditor.manual.resetWidth', 'Reset width')} + + + + + )} + {orderedImages.map((image, imageIndex) => { + if (!image?.imageData) { + return null; + } + const bounds = getImageBounds(image); + const width = Math.max(bounds.right - bounds.left, 1); + const height = Math.max(bounds.top - bounds.bottom, 1); + const cssWidth = Math.max(width * scale, 2); + const cssHeight = Math.max(height * scale, 2); + const cssLeft = bounds.left * scale; + const cssTop = (pageHeight - bounds.top) * scale; + const imageId = image.id ?? `page-${selectedPage}-image-${imageIndex}`; + const isActive = activeImageId === imageId; + const src = `data:image/${image.imageFormat ?? 'png'};base64,${image.imageData}`; + const baseZIndex = (image.zOrder ?? -1_000_000) + 1_050_000; + const zIndex = isActive ? baseZIndex + 1_000_000 : baseZIndex; + + return ( + { + if (ref) { + rndRefs.current.set(imageId, ref); + } else { + rndRefs.current.delete(imageId); + } + }} + key={`image-${imageId}`} + bounds="parent" + size={{ width: cssWidth, height: cssHeight }} + position={{ x: cssLeft, y: cssTop }} + onDragStart={(_event, _data) => { + setActiveGroupId(null); + setEditingGroupId(null); + setActiveImageId(imageId); + draggingImageRef.current = imageId; + }} + onDrag={(_event, data) => { + // Cancel any pending update + if (pendingDragUpdateRef.current) { + cancelAnimationFrame(pendingDragUpdateRef.current); + } + + // Schedule update on next frame to batch rapid drag events + pendingDragUpdateRef.current = requestAnimationFrame(() => { + const rndRef = rndRefs.current.get(imageId); + if (rndRef && rndRef.updatePosition) { + rndRef.updatePosition({ x: data.x, y: data.y }); + } + }); + }} + onDragStop={(_event, data) => { + if (pendingDragUpdateRef.current) { + cancelAnimationFrame(pendingDragUpdateRef.current); + pendingDragUpdateRef.current = null; + } + draggingImageRef.current = null; + emitImageTransform( + imageId, + data.x, + data.y, + cssWidth, + cssHeight, + ); + }} + onResizeStart={() => { + setActiveImageId(imageId); + setActiveGroupId(null); + setEditingGroupId(null); + draggingImageRef.current = imageId; + }} + onResizeStop={(_event, _direction, ref, _delta, position) => { + draggingImageRef.current = null; + const nextWidth = parseFloat(ref.style.width); + const nextHeight = parseFloat(ref.style.height); + emitImageTransform( + imageId, + position.x, + position.y, + nextWidth, + nextHeight, + ); + }} + style={{ zIndex }} + > + setActiveImageId(imageId)} + onMouseLeave={() => { + setActiveImageId((current) => (current === imageId ? null : current)); + }} + onDoubleClick={(event) => { + event.stopPropagation(); + onImageReset(selectedPage, imageId); + }} + style={{ + width: '100%', + height: '100%', + cursor: isActive ? 'grabbing' : 'grab', + outline: isActive + ? '2px solid rgba(59, 130, 246, 0.9)' + : '1px solid rgba(148, 163, 184, 0.4)', + outlineOffset: '-1px', + borderRadius: 4, + backgroundColor: 'rgba(255,255,255,0.04)', + transition: 'outline 120ms ease', + }} + > + {t('pdfTextEditor.imageLabel', + + + ); + })} + {visibleGroups.length === 0 && orderedImages.length === 0 ? ( + + + + {t('pdfTextEditor.noTextOnPage', 'No editable text was detected on this page.')} + + + + ) : ( + visibleGroups.map(({ group, pageGroupIndex }) => { + const bounds = toCssBounds(currentPage, pageHeight, scale, group.bounds); + const changed = group.text !== group.originalText; + const isActive = activeGroupId === group.id || editingGroupId === group.id; + const isEditing = editingGroupId === group.id; + const baseFontSize = group.fontMatrixSize ?? group.fontSize ?? 12; + const fontSizePx = Math.max(baseFontSize * scale, 6); + const effectiveFontId = resolveFontIdForIndex(pageGroupIndex) ?? group.fontId; + const fontFamily = getFontFamily(effectiveFontId, group.pageIndex); + let lineHeightPx = getLineHeightPx(effectiveFontId, group.pageIndex, fontSizePx); + let lineHeightRatio = fontSizePx > 0 ? Math.max(lineHeightPx / fontSizePx, 1.05) : 1.2; + const rotation = group.rotation ?? 0; + const hasRotation = Math.abs(rotation) > 0.5; + const baselineLength = group.baselineLength ?? Math.max(group.bounds.right - group.bounds.left, 0); + const geometry = getFontGeometry(effectiveFontId, group.pageIndex); + const ascentPx = geometry ? Math.max(fontSizePx * geometry.ascentRatio, fontSizePx * 0.7) : fontSizePx * 0.82; + const descentPx = geometry ? Math.max(fontSizePx * geometry.descentRatio, fontSizePx * 0.2) : fontSizePx * 0.22; + lineHeightPx = Math.max(lineHeightPx, ascentPx + descentPx); + if (fontSizePx > 0) { + lineHeightRatio = Math.max(lineHeightRatio, lineHeightPx / fontSizePx); + } + const detectedSpacingPx = + group.lineSpacing && group.lineSpacing > 0 ? group.lineSpacing * scale : undefined; + if (detectedSpacingPx && detectedSpacingPx > 0) { + lineHeightPx = Math.max(lineHeightPx, detectedSpacingPx); + if (fontSizePx > 0) { + lineHeightRatio = Math.max(lineHeightRatio, detectedSpacingPx / fontSizePx); + } + } + const lineCount = Math.max(group.text.split('\n').length, 1); + const paragraphHeightPx = + lineCount > 1 + ? lineHeightPx + (lineCount - 1) * (detectedSpacingPx ?? lineHeightPx) + : lineHeightPx; + + let containerLeft = bounds.left; + let containerTop = bounds.top; + const { width: resolvedWidth, base: baseWidth, max: _maxWidth } = resolveGroupWidth(group); + let containerWidth = Math.max(resolvedWidth * scale, fontSizePx); + let containerHeight = Math.max(bounds.height, paragraphHeightPx); + let transform: string | undefined; + let transformOrigin: React.CSSProperties['transformOrigin']; + + if (hasRotation) { + const anchorX = group.anchor?.x ?? group.bounds.left; + const anchorY = group.anchor?.y ?? group.bounds.bottom; + containerLeft = anchorX * scale; + const anchorTop = Math.max(pageHeight - anchorY, 0) * scale; + containerWidth = Math.max(baselineLength * scale, MIN_BOX_SIZE); + containerHeight = Math.max(lineHeightPx, fontSizePx * lineHeightRatio); + transformOrigin = 'left bottom'; + // Negate rotation because Y-axis is flipped from PDF to web coordinates + transform = `rotate(${-rotation}deg)`; + // Align the baseline (PDF anchor) with the bottom edge used as the + // transform origin. Without this adjustment rotated text appears shifted + // downward by roughly one line height. + containerTop = anchorTop - containerHeight; + } + + if ( + lineCount === 1 && + !hasRotation && + group.baseline !== null && + group.baseline !== undefined && + geometry + ) { + const cssBaselineTop = (pageHeight - group.baseline) * scale; + containerTop = Math.max(cssBaselineTop - ascentPx, 0); + containerHeight = Math.max(containerHeight, ascentPx + descentPx); + } + + // Extract styling from group + const textColor = group.color || '#111827'; + const fontWeight = group.fontWeight || getFontWeight(effectiveFontId, group.pageIndex); + + // Determine text wrapping behavior based on whether text has been changed + const hasChanges = changed; + const widthExtended = resolvedWidth - baseWidth > 0.5; + const enableWrap = isParagraphLayout || widthExtended || isEditing || hasChanges; + const whiteSpace = enableWrap ? 'pre-wrap' : 'pre'; + const wordBreak = enableWrap ? 'break-word' : 'normal'; + const overflowWrap = enableWrap ? 'break-word' : 'normal'; + + // For paragraph mode, allow height to grow to accommodate lines without wrapping + // For single-line mode, maintain fixed height based on PDF bounds + const useFlexibleHeight = isEditing || enableWrap || (isParagraphLayout && lineCount > 1); + + // The renderGroupContainer wrapper adds 4px horizontal padding (2px left + 2px right) + // We need to add this to the container width to compensate, so the inner content + // has the full PDF-defined width available for text + const WRAPPER_HORIZONTAL_PADDING = 4; + + const containerStyle: React.CSSProperties = { + position: 'absolute', + left: `${containerLeft}px`, + top: `${containerTop}px`, + width: `${containerWidth + WRAPPER_HORIZONTAL_PADDING}px`, + height: useFlexibleHeight ? 'auto' : `${containerHeight}px`, + minHeight: useFlexibleHeight ? 'auto' : `${containerHeight}px`, + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'flex-start', + pointerEvents: 'auto', + cursor: 'text', + zIndex: 2_000_000, + transform, + transformOrigin, + }; + + const showResizeHandle = !hasRotation && (selectedGroupIds.has(group.id) || activeGroupId === group.id); + const resizeHandle = showResizeHandle ? ( + handleResizeStart(event, group, resolvedWidth)} + style={{ + position: 'absolute', + top: '50%', + right: -6, + width: 12, + height: 32, + marginTop: -16, + cursor: 'ew-resize', + borderRadius: 6, + backgroundColor: 'rgba(76, 110, 245, 0.35)', + border: '1px solid rgba(76, 110, 245, 0.8)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: 'white', + fontSize: 9, + userSelect: 'none', + }} + > + || + + ) : null; + + if (isEditing) { + return ( + + {renderGroupContainer( + group.id, + group.pageIndex, + true, + changed, +
    { + if (node) { + editorRefs.current.set(group.id, node); + } else { + editorRefs.current.delete(group.id); + } + }} + contentEditable + suppressContentEditableWarning + data-editor-group={group.id} + onFocus={(event) => { + const primaryFont = fontFamily.split(',')[0]?.replace(/['"]/g, '').trim(); + if (primaryFont && typeof document !== 'undefined') { + try { + if (document.queryCommandSupported?.('styleWithCSS')) { + document.execCommand('styleWithCSS', false, 'true'); + } + if (document.queryCommandSupported?.('fontName')) { + document.execCommand('fontName', false, primaryFont); + } + } catch { + // ignore execCommand failures; inline style already enforces font + } + } + event.currentTarget.style.fontFamily = fontFamily; + }} + onClick={(event) => { + // Allow click position to determine cursor placement + event.stopPropagation(); + }} + onBlur={(event) => { + syncEditorValue(event.currentTarget, group.pageIndex, group.id, { + skipCaretRestore: true, + }); + caretOffsetsRef.current.delete(group.id); + editorRefs.current.delete(group.id); + setActiveGroupId(null); + setEditingGroupId(null); + }} + onInput={(event) => { + syncEditorValue(event.currentTarget, group.pageIndex, group.id); + }} + style={{ + width: '100%', + minHeight: '100%', + height: 'auto', + padding: '2px', + backgroundColor: 'rgba(255,255,255,0.95)', + color: textColor, + fontSize: `${fontSizePx}px`, + fontFamily, + fontWeight, + lineHeight: lineHeightRatio, + outline: 'none', + border: 'none', + display: 'block', + whiteSpace, + wordBreak, + overflowWrap, + cursor: 'text', + overflow: 'visible', + }} + > + {group.text || '\u00A0'} +
    , + undefined, + undefined, + selectedGroupIds.has(group.id), + resizeHandle, + )} +
    + ); + } + + const textScale = textScales.get(group.id) ?? 1; + const shouldScale = autoScaleText && textScale < 0.98; + + return ( + + {renderGroupContainer( + group.id, + group.pageIndex, + isActive, + changed, +
    + + {group.text || '\u00A0'} + +
    , + undefined, + (event: React.MouseEvent) => { + const shouldActivate = handleSelectionInteraction(group.id, pageGroupIndex, event); + if (!shouldActivate) { + setActiveGroupId(null); + setEditingGroupId(null); + return; + } + + const clickX = event.clientX; + const clickY = event.clientY; + + setActiveGroupId(group.id); + setEditingGroupId(group.id); + caretOffsetsRef.current.delete(group.id); + + // Log group stats when selected + const lines = (group.text ?? '').split('\n'); + const words = (group.text ?? '').split(/\s+/).filter(w => w.length > 0).length; + const chars = (group.text ?? '').length; + const width = group.bounds.right - group.bounds.left; + const height = group.bounds.bottom - group.bounds.top; + const isMultiLine = lines.length > 1; + console.log(`📝 Selected Text Group "${group.id}":`); + console.log(` Lines: ${lines.length}, Words: ${words}, Chars: ${chars}`); + console.log(` Dimensions: ${width.toFixed(1)}pt × ${height.toFixed(1)}pt`); + console.log(` Type: ${isMultiLine ? 'MULTI-LINE (paragraph)' : 'SINGLE-LINE'}`); + console.log(` Text preview: "${(group.text ?? '').substring(0, 80)}${(group.text ?? '').length > 80 ? '...' : ''}"`); + if (isMultiLine) { + console.log(` Line spacing: ${group.lineSpacing?.toFixed(1) ?? 'unknown'}pt`); + } + + requestAnimationFrame(() => { + const editor = document.querySelector(`[data-editor-group="${group.id}"]`); + if (!editor) return; + editor.focus(); + + setTimeout(() => { + if (document.caretRangeFromPoint) { + const range = document.caretRangeFromPoint(clickX, clickY); + if (range) { + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + } + } else if ((document as any).caretPositionFromPoint) { + const pos = (document as any).caretPositionFromPoint(clickX, clickY); + if (pos) { + const range = document.createRange(); + range.setStart(pos.offsetNode, pos.offset); + range.collapse(true); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + } + } + }, 10); + }); + }, + selectedGroupIds.has(group.id), + resizeHandle, + )} +
    + ); + }) + )} +
    +
    +
    +
    +
    + +
    + )} + + {/* Mode Change Confirmation Modal */} + + + + {t( + 'pdfTextEditor.modeChange.warning', + 'Changing the text grouping mode will reset all unsaved changes. Are you sure you want to continue?' + )} + + + + + + + + + {/* Navigation Warning Modal */} + +
    + ); +}; + +export default PdfTextEditorView; diff --git a/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx b/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx index e6d62693b1..1544334385 100644 --- a/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx +++ b/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx @@ -1,5 +1,13 @@ import { useMemo } from "react"; -import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { useTranslation } from "react-i18next"; +import { getSynonyms } from "@app/utils/toolSynonyms"; +import PdfTextEditor from "@app/tools/pdfTextEditor/PdfTextEditor"; +import { + SubcategoryId, + ToolCategoryId, + type ProprietaryToolRegistry, +} from "@app/data/toolsTaxonomy"; /** * Hook that provides the proprietary tool registry. @@ -8,6 +16,26 @@ import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy"; * and will be included in the main tool registry. */ export function useProprietaryToolRegistry(): ProprietaryToolRegistry { - return useMemo(() => ({ - }), []); + const { t } = useTranslation(); + + return useMemo(() => ({ + pdfTextEditor: { + icon: , + name: t("home.pdfTextEditor.title", "PDF Text Editor"), + component: PdfTextEditor, + description: t( + "home.pdfTextEditor.desc", + "Review and edit text and images in PDFs with grouped text editing and PDF regeneration" + ), + categoryId: ToolCategoryId.RECOMMENDED_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + maxFiles: 1, + endpoints: ["text-editor-pdf"], + synonyms: getSynonyms(t, "pdfTextEditor"), + supportsAutomate: false, + automationSettings: null, + versionStatus: "alpha", + requiresPremium: true, + }, + }), [t]); } diff --git a/frontend/src/proprietary/routes/AuthCallback.test.tsx b/frontend/src/proprietary/routes/AuthCallback.test.tsx new file mode 100644 index 0000000000..25bca74f63 --- /dev/null +++ b/frontend/src/proprietary/routes/AuthCallback.test.tsx @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import AuthCallback from '@app/routes/AuthCallback'; +import { springAuth } from '@app/auth/springAuthClient'; + +// Mock springAuth +vi.mock('@app/auth/springAuthClient', () => ({ + springAuth: { + getSession: vi.fn(), + }, +})); + +// Mock useNavigate +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +describe('AuthCallback', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + // Reset window.location.hash + window.location.hash = ''; + }); + + it('should extract JWT from URL hash and validate it', async () => { + const mockToken = 'oauth-jwt-token'; + const mockUser = { + id: '123', + email: 'oauth@example.com', + username: 'oauthuser', + role: 'USER', + }; + + // Set URL hash with access token + window.location.hash = `#access_token=${mockToken}`; + + // Mock successful session validation + vi.mocked(springAuth.getSession).mockResolvedValueOnce({ + data: { + session: { + user: mockUser, + access_token: mockToken, + expires_in: 3600, + expires_at: Date.now() + 3600000, + }, + }, + error: null, + }); + + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + render( + + + + ); + + await waitFor(() => { + // Verify JWT was stored + expect(localStorage.getItem('stirling_jwt')).toBe(mockToken); + + // Verify jwt-available event was dispatched + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'jwt-available' }) + ); + + // Verify getSession was called to validate token + expect(springAuth.getSession).toHaveBeenCalled(); + + // Verify navigation to home + expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true }); + }); + }); + + it('should redirect to login when no access token in hash', async () => { + // No hash or empty hash + window.location.hash = ''; + + render( + + + + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/login', { + replace: true, + state: { error: 'OAuth login failed - no token received.' }, + }); + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + }); + }); + + it('should redirect to login when token validation fails', async () => { + const invalidToken = 'invalid-oauth-token'; + window.location.hash = `#access_token=${invalidToken}`; + + // Mock failed session validation + vi.mocked(springAuth.getSession).mockResolvedValueOnce({ + data: { session: null }, + error: { message: 'Invalid token' }, + }); + + render( + + + + ); + + await waitFor(() => { + // JWT should be stored initially + expect(localStorage.getItem('stirling_jwt')).toBeNull(); // Cleared after validation failure + + // Verify redirect to login + expect(mockNavigate).toHaveBeenCalledWith('/login', { + replace: true, + state: { error: 'OAuth login failed - invalid token.' }, + }); + }); + }); + + it('should handle errors gracefully', async () => { + const mockToken = 'error-token'; + window.location.hash = `#access_token=${mockToken}`; + + // Mock getSession throwing error + vi.mocked(springAuth.getSession).mockRejectedValueOnce( + new Error('Network error') + ); + + render( + + + + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/login', { + replace: true, + state: { error: 'OAuth login failed. Please try again.' }, + }); + }); + }); + + it('should display loading state while processing', () => { + window.location.hash = '#access_token=processing-token'; + + vi.mocked(springAuth.getSession).mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + data: { session: null }, + error: { message: 'Token expired' }, + }), + 100 + ) + ) + ); + + const { getByText } = render( + + + + ); + + expect(getByText('Completing authentication...')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/proprietary/routes/AuthCallback.tsx b/frontend/src/proprietary/routes/AuthCallback.tsx index 0c7128368d..488a541464 100644 --- a/frontend/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/src/proprietary/routes/AuthCallback.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '@app/auth/UseSession'; +import { springAuth } from '@app/auth/springAuthClient'; /** * OAuth Callback Handler @@ -11,7 +11,6 @@ import { useAuth } from '@app/auth/UseSession'; */ export default function AuthCallback() { const navigate = useNavigate(); - const { refreshSession } = useAuth(); useEffect(() => { const handleCallback = async () => { @@ -37,12 +36,23 @@ export default function AuthCallback() { console.log('[AuthCallback] JWT stored in localStorage'); // Dispatch custom event for other components to react to JWT availability - window.dispatchEvent(new CustomEvent('jwt-available')) + window.dispatchEvent(new CustomEvent('jwt-available')); - // Refresh session to load user info into state - await refreshSession(); + // Validate the token and load user info + // This calls /api/v1/auth/me with the JWT to get user details + const { data, error } = await springAuth.getSession(); - console.log('[AuthCallback] Session refreshed, redirecting to home'); + if (error || !data.session) { + console.error('[AuthCallback] Failed to validate token:', error); + localStorage.removeItem('stirling_jwt'); + navigate('/login', { + replace: true, + state: { error: 'OAuth login failed - invalid token.' } + }); + return; + } + + console.log('[AuthCallback] Token validated, redirecting to home'); // Clear the hash from URL and redirect to home page navigate('/', { replace: true }); @@ -56,7 +66,7 @@ export default function AuthCallback() { }; handleCallback(); - }, [navigate, refreshSession]); + }, [navigate]); return (
    ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | Record) => { + if (typeof fallback === 'string') return fallback; + return key; + }, + }), +})); + +// Mock useAuth hook +vi.mock('@app/auth/UseSession', () => ({ + useAuth: vi.fn(), +})); + +// Mock springAuth +vi.mock('@app/auth/springAuthClient', () => ({ + springAuth: { + signInWithPassword: vi.fn(), + signInWithOAuth: vi.fn(), + }, +})); + +// Mock useDocumentMeta +vi.mock('@app/hooks/useDocumentMeta', () => ({ + useDocumentMeta: vi.fn(), +})); + +// Mock fetch for provider list +global.fetch = vi.fn(); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +// Test wrapper with MantineProvider +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('Login', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default auth state - not logged in + vi.mocked(useAuth).mockReturnValue({ + session: null, + user: null, + loading: false, + error: null, + signOut: vi.fn(), + refreshSession: vi.fn(), + }); + + // Mock fetch for login UI data + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + enableLogin: true, + providerList: {}, + }), + } as Response); + }); + + it('should render login form', async () => { + render( + + + + + + ); + + await waitFor(() => { + // Check for login form elements - use id since it's more reliable + const emailInput = document.getElementById('email'); + expect(emailInput).toBeTruthy(); + }); + }); + + it('should redirect authenticated user to home', async () => { + const mockSession = { + user: { + id: '123', + email: 'test@example.com', + username: 'testuser', + role: 'USER', + }, + access_token: 'mock-token', + expires_in: 3600, + }; + + vi.mocked(useAuth).mockReturnValue({ + session: mockSession, + user: mockSession.user, + loading: false, + error: null, + signOut: vi.fn(), + refreshSession: vi.fn(), + }); + + render( + + + + + + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true }); + }); + }); + + it('should show loading state while auth is loading', () => { + vi.mocked(useAuth).mockReturnValue({ + session: null, + user: null, + loading: true, + error: null, + signOut: vi.fn(), + refreshSession: vi.fn(), + }); + + render( + + + + + + ); + + // Component shouldn't redirect or show form while loading + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('should handle email/password login', async () => { + const user = userEvent.setup(); + const mockUser = { + id: '123', + email: 'test@example.com', + username: 'test@example.com', + role: 'USER', + }; + + const mockSession = { + user: mockUser, + access_token: 'new-token', + expires_in: 3600, + }; + + vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({ + user: mockUser, + session: mockSession, + error: null, + }); + + render( + + + + + + ); + + // Wait for form to load + await waitFor(() => { + const emailInput = document.getElementById('email'); + expect(emailInput).toBeTruthy(); + const passwordInput = document.getElementById('password'); + expect(passwordInput).toBeTruthy(); + }, { timeout: 3000 }); + + // Fill in form using getElementById + const emailInput = document.getElementById('email') as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement; + + if (!emailInput || !passwordInput) { + throw new Error('Form inputs not found'); + } + + await user.type(emailInput, 'test@example.com'); + await user.type(passwordInput, 'password123'); + + // Submit form - use a more flexible query + // Look for button with type="submit" in the form + const submitButton = await waitFor(() => { + const buttons = screen.queryAllByRole('button'); + const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit'); + if (!submitBtn) { + throw new Error('Submit button not found'); + } + return submitBtn; + }, { timeout: 5000 }); + await user.click(submitButton); + + await waitFor(() => { + expect(springAuth.signInWithPassword).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123', + }); + }); + }); + + it('should show error on failed login', async () => { + const user = userEvent.setup(); + const errorMessage = 'Invalid credentials'; + + vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({ + user: null, + session: null, + error: { message: errorMessage }, + }); + + render( + + + + + + ); + + await waitFor(() => { + const emailInput = document.getElementById('email'); + const passwordInput = document.getElementById('password'); + expect(emailInput).toBeTruthy(); + expect(passwordInput).toBeTruthy(); + }, { timeout: 3000 }); + + const emailInput = document.getElementById('email') as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement; + + await user.type(emailInput, 'wrong@example.com'); + await user.type(passwordInput, 'wrongpassword'); + + const submitButton = await waitFor(() => { + const buttons = screen.queryAllByRole('button'); + const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit'); + if (!submitBtn) { + throw new Error('Submit button not found'); + } + return submitBtn; + }, { timeout: 5000 }); + await user.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + it('should validate empty email and password', async () => { + render( + + + + + + ); + + await waitFor(() => { + expect(document.getElementById('email')).toBeTruthy(); + }, { timeout: 3000 }); + + // Find the submit button + const submitButton = await waitFor(() => { + const buttons = screen.queryAllByRole('button'); + const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit'); + if (!submitBtn) { + throw new Error('Submit button not found'); + } + return submitBtn; + }, { timeout: 5000 }); + + // Button should be disabled when email/password are empty + expect(submitButton).toBeDisabled(); + + // Verify sign in was not called + expect(springAuth.signInWithPassword).not.toHaveBeenCalled(); + }); + + it('should display session expired message from URL param', () => { + render( + + + + + + ); + + expect(screen.getByText(/session.*expired/i)).toBeInTheDocument(); + }); + + it('should display account created success message', () => { + render( + + + + + + ); + + expect(screen.getByText(/account created/i)).toBeInTheDocument(); + }); + + it('should prefill email from query param', () => { + const email = 'prefilled@example.com'; + + render( + + + + + + ); + + waitFor(() => { + const emailInput = document.getElementById('email') as HTMLInputElement; + expect(emailInput.value).toBe(email); + }); + }); + + it('should redirect to home when login disabled', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + enableLogin: false, + providerList: {}, + }), + } as Response); + + render( + + + + + + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); + + it('should handle OAuth provider click', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + enableLogin: true, + providerList: { + '/oauth2/authorization/github': 'GitHub', + }, + }), + } as Response); + + vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({ + error: null, + }); + + render( + + + + + + ); + + await waitFor(() => { + const githubButton = screen.queryByText(/github/i); + if (githubButton) { + expect(githubButton).toBeInTheDocument(); + } + }); + + // Since OAuth buttons might be dynamically rendered based on config, + // we just verify the mock is set up correctly + expect(springAuth.signInWithOAuth).toBeDefined(); + }); + + it('should show email form by default when no SSO providers', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + enableLogin: true, + providerList: {}, // No providers + }), + } as Response); + + render( + + + + + + ); + + await waitFor(() => { + expect(document.getElementById('email')).toBeInTheDocument(); + expect(document.getElementById('password')).toBeInTheDocument(); + }); + }); + + it('should disable submit button while signing in', async () => { + const user = userEvent.setup(); + + vi.mocked(springAuth.signInWithPassword).mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + user: null, + session: null, + error: { message: 'Error' }, + }), + 100 + ) + ) + ); + + render( + + + + + + ); + + await waitFor(() => { + const emailInput = document.getElementById('email'); + const passwordInput = document.getElementById('password'); + expect(emailInput).toBeTruthy(); + expect(passwordInput).toBeTruthy(); + }, { timeout: 3000 }); + + const emailInput = document.getElementById('email') as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement; + + await user.type(emailInput, 'test@example.com'); + await user.type(passwordInput, 'password123'); + + const submitButton = await waitFor(() => { + const buttons = screen.queryAllByRole('button'); + const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit'); + if (!submitBtn) { + throw new Error('Submit button not found'); + } + return submitBtn; + }, { timeout: 5000 }); + await user.click(submitButton); + + // Button should be disabled while signing in + expect(submitButton).toBeDisabled(); + + // Wait for completion + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + }); +}); diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index b50de410f0..a64fac5001 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -30,6 +30,14 @@ export default function Login() { const [hasSSOProviders, setHasSSOProviders] = useState(false); const [_enableLogin, setEnableLogin] = useState(null); + // Redirect immediately if user has valid session (JWT already validated by AuthProvider) + useEffect(() => { + if (!loading && session) { + console.debug('[Login] User already authenticated, redirecting to home'); + navigate('/', { replace: true }); + } + }, [session, loading, navigate]); + // Fetch enabled SSO providers and login config from backend useEffect(() => { const fetchProviders = async () => { diff --git a/frontend/src/proprietary/routes/Signup.tsx b/frontend/src/proprietary/routes/Signup.tsx index 192a3a95a5..ad1b7dc009 100644 --- a/frontend/src/proprietary/routes/Signup.tsx +++ b/frontend/src/proprietary/routes/Signup.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useDocumentMeta } from '@app/hooks/useDocumentMeta'; +import { useAuth } from '@app/auth/UseSession'; import AuthLayout from '@app/routes/authShared/AuthLayout'; import '@app/routes/authShared/auth.css'; import { BASE_PATH } from '@app/constants/app'; @@ -17,6 +18,7 @@ import { useAuthService } from '@app/routes/signup/AuthService'; export default function Signup() { const navigate = useNavigate(); const { t } = useTranslation(); + const { session, loading } = useAuth(); const [isSigningUp, setIsSigningUp] = useState(false); const [error, setError] = useState(null); const [email, setEmail] = useState(''); @@ -24,6 +26,14 @@ export default function Signup() { const [confirmPassword, setConfirmPassword] = useState(''); const [fieldErrors, setFieldErrors] = useState({}); + // Redirect immediately if user has valid session (JWT already validated by AuthProvider) + useEffect(() => { + if (!loading && session) { + console.debug('[Signup] User already authenticated, redirecting to home'); + navigate('/', { replace: true }); + } + }, [session, loading, navigate]); + const baseUrl = window.location.origin + BASE_PATH; // Set document meta diff --git a/frontend/src/proprietary/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/src/proprietary/tools/pdfTextEditor/PdfTextEditor.tsx new file mode 100644 index 0000000000..6337c23fd0 --- /dev/null +++ b/frontend/src/proprietary/tools/pdfTextEditor/PdfTextEditor.tsx @@ -0,0 +1,1444 @@ +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import DescriptionIcon from '@mui/icons-material/DescriptionOutlined'; + +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useFileSelection } from '@app/contexts/FileContext'; +import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; +import { BaseToolProps, ToolComponent } from '@app/types/tool'; +import { CONVERSION_ENDPOINTS } from '@app/constants/convertConstants'; +import apiClient from '@app/services/apiClient'; +import { downloadBlob, downloadTextAsFile } from '@app/utils/downloadUtils'; +import { getFilenameFromHeaders } from '@app/utils/fileResponseUtils'; +import { pdfWorkerManager } from '@core/services/pdfWorkerManager'; +import { Util } from 'pdfjs-dist/legacy/build/pdf.mjs'; +import { + PdfJsonDocument, + PdfJsonImageElement, + PdfJsonPage, + TextGroup, + PdfTextEditorViewData, + BoundingBox, + ConversionProgress, +} from '@app/tools/pdfTextEditor/pdfTextEditorTypes'; +import { + deepCloneDocument, + getDirtyPages, + groupDocumentText, + restoreGlyphElements, + extractDocumentImages, + cloneImageElement, + cloneTextElement, + valueOr, +} from '@app/tools/pdfTextEditor/pdfTextEditorUtils'; +import PdfTextEditorView from '@app/components/tools/pdfTextEditor/PdfTextEditorView'; +import type { PDFDocumentProxy } from 'pdfjs-dist'; + +const WORKBENCH_VIEW_ID = 'pdfTextEditorWorkbench'; +const WORKBENCH_ID = 'custom:pdfTextEditor' as const; + +const sanitizeBaseName = (name?: string | null): string => { + if (!name || name.trim().length === 0) { + return 'document'; + } + return name.replace(/\.[^.]+$/u, ''); +}; + +const getAutoLoadKey = (file: File): string => { + const withId = file as File & { fileId?: string; quickKey?: string }; + if (withId.fileId && typeof withId.fileId === 'string') { + return withId.fileId; + } + if (withId.quickKey && typeof withId.quickKey === 'string') { + return withId.quickKey; + } + return `${file.name}|${file.size}|${file.lastModified}`; +}; + +const normalizeLineArray = (value: string | undefined | null, expected: number): string[] => { + const normalized = (value ?? '').replace(/\r/g, ''); + if (expected <= 0) { + return [normalized]; + } + const parts = normalized.split('\n'); + if (parts.length === expected) { + return parts; + } + if (parts.length < expected) { + return parts.concat(Array(expected - parts.length).fill('')); + } + const head = parts.slice(0, Math.max(expected - 1, 0)); + const tail = parts.slice(Math.max(expected - 1, 0)).join('\n'); + return [...head, tail]; +}; + +const cloneLineTemplate = (line: TextGroup, text?: string, originalText?: string): TextGroup => ({ + ...line, + text: text ?? line.text, + originalText: originalText ?? line.originalText, + childLineGroups: null, + lineElementCounts: null, + lineSpacing: null, + elements: line.elements.map(cloneTextElement), + originalElements: line.originalElements.map(cloneTextElement), +}); + +const expandGroupToLines = (group: TextGroup): TextGroup[] => { + if (group.childLineGroups && group.childLineGroups.length > 0) { + const textLines = normalizeLineArray(group.text, group.childLineGroups.length); + const originalLines = normalizeLineArray(group.originalText, group.childLineGroups.length); + return group.childLineGroups.map((child, index) => + cloneLineTemplate(child, textLines[index], originalLines[index]), + ); + } + return [cloneLineTemplate(group)]; +}; + +const mergeBoundingBoxes = (boxes: BoundingBox[]): BoundingBox => { + if (boxes.length === 0) { + return { left: 0, right: 0, top: 0, bottom: 0 }; + } + return boxes.reduce( + (acc, box) => ({ + left: Math.min(acc.left, box.left), + right: Math.max(acc.right, box.right), + top: Math.min(acc.top, box.top), + bottom: Math.max(acc.bottom, box.bottom), + }), + { ...boxes[0] }, + ); +}; + +const buildMergedGroupFromSelection = (groups: TextGroup[]): TextGroup | null => { + if (groups.length === 0) { + return null; + } + + const lineTemplates = groups.flatMap(expandGroupToLines); + if (lineTemplates.length <= 1) { + return null; + } + + const lineTexts = lineTemplates.map((line) => line.text ?? ''); + const lineOriginalTexts = lineTemplates.map((line) => line.originalText ?? ''); + const combinedOriginals = lineTemplates.flatMap((line) => line.originalElements.map(cloneTextElement)); + const combinedElements = combinedOriginals.map(cloneTextElement); + const mergedBounds = mergeBoundingBoxes(lineTemplates.map((line) => line.bounds)); + + const spacingValues: number[] = []; + for (let index = 1; index < lineTemplates.length; index += 1) { + const prevBaseline = lineTemplates[index - 1].baseline ?? lineTemplates[index - 1].bounds.bottom; + const currentBaseline = lineTemplates[index].baseline ?? lineTemplates[index].bounds.bottom; + const spacing = Math.abs(prevBaseline - currentBaseline); + if (spacing > 0) { + spacingValues.push(spacing); + } + } + const averageSpacing = + spacingValues.length > 0 + ? spacingValues.reduce((sum, value) => sum + value, 0) / spacingValues.length + : null; + + const first = groups[0]; + const lineElementCounts = lineTemplates.map((line) => Math.max(line.originalElements.length, 1)); + const paragraph: TextGroup = { + ...first, + text: lineTexts.join('\n'), + originalText: lineOriginalTexts.join('\n'), + elements: combinedElements, + originalElements: combinedOriginals, + bounds: mergedBounds, + lineSpacing: averageSpacing, + lineElementCounts: lineElementCounts.length > 1 ? lineElementCounts : null, + childLineGroups: lineTemplates.map((line, index) => + cloneLineTemplate(line, lineTexts[index], lineOriginalTexts[index]), + ), + }; + + return paragraph; +}; + +const splitParagraphGroup = (group: TextGroup): TextGroup[] => { + if (!group.childLineGroups || group.childLineGroups.length <= 1) { + return []; + } + + const templateLines = group.childLineGroups.map((child) => cloneLineTemplate(child)); + const lineCount = templateLines.length; + const textLines = normalizeLineArray(group.text, lineCount); + const originalLines = normalizeLineArray(group.originalText, lineCount); + const baseCounts = + group.lineElementCounts && group.lineElementCounts.length === lineCount + ? [...group.lineElementCounts] + : templateLines.map((line) => Math.max(line.originalElements.length, 1)); + + const totalOriginals = group.originalElements.length; + const counted = baseCounts.reduce((sum, count) => sum + count, 0); + if (counted < totalOriginals && baseCounts.length > 0) { + baseCounts[baseCounts.length - 1] += totalOriginals - counted; + } + + let offset = 0; + return templateLines.map((template, index) => { + const take = Math.max(1, baseCounts[index] ?? 1); + const slice = group.originalElements.slice(offset, offset + take).map(cloneTextElement); + offset += take; + return { + ...template, + id: `${group.id}-line-${index + 1}-${Date.now()}-${index}`, + text: textLines[index] ?? '', + originalText: originalLines[index] ?? '', + elements: slice.map(cloneTextElement), + originalElements: slice, + lineElementCounts: null, + lineSpacing: null, + childLineGroups: null, + }; + }); +}; + +const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + setLeftPanelView, + } = useToolWorkflow(); + const { actions: navigationActions } = useNavigationActions(); + const navigationState = useNavigationState(); + const { registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = navigationActions; + + const [loadedDocument, setLoadedDocument] = useState(null); + const [groupsByPage, setGroupsByPage] = useState([]); + const [imagesByPage, setImagesByPage] = useState([]); + const [selectedPage, setSelectedPage] = useState(0); + const [fileName, setFileName] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); + const [isConverting, setIsConverting] = useState(false); + const [conversionProgress, setConversionProgress] = useState(null); + const [forceSingleTextElement, setForceSingleTextElement] = useState(true); + const [groupingMode, setGroupingMode] = useState<'auto' | 'paragraph' | 'singleLine'>('auto'); + const [hasVectorPreview, setHasVectorPreview] = useState(false); + const [pagePreviews, setPagePreviews] = useState>(new Map()); + + // Lazy loading state + const [isLazyMode, setIsLazyMode] = useState(false); + const [cachedJobId, setCachedJobId] = useState(null); + const [loadedImagePages, setLoadedImagePages] = useState>(new Set()); + const [loadingImagePages, setLoadingImagePages] = useState>(new Set()); + + const originalImagesRef = useRef([]); + const originalGroupsRef = useRef([]); + const imagesByPageRef = useRef([]); + const autoLoadKeyRef = useRef(null); + const loadRequestIdRef = useRef(0); + const latestPdfRequestIdRef = useRef(null); + const loadedDocumentRef = useRef(null); + const loadedImagePagesRef = useRef>(new Set()); + const loadingImagePagesRef = useRef>(new Set()); + const pdfDocumentRef = useRef(null); + const previewRequestIdRef = useRef(0); + const previewRenderingRef = useRef>(new Set()); + const pagePreviewsRef = useRef>(pagePreviews); + const previewScaleRef = useRef>(new Map()); + const cachedJobIdRef = useRef(null); + + // Keep ref in sync with state for access in async callbacks + useEffect(() => { + loadedDocumentRef.current = loadedDocument; + }, [loadedDocument]); + + useEffect(() => { + loadedImagePagesRef.current = new Set(loadedImagePages); + }, [loadedImagePages]); + + useEffect(() => { + loadingImagePagesRef.current = new Set(loadingImagePages); + }, [loadingImagePages]); + + useEffect(() => { + pagePreviewsRef.current = pagePreviews; + }, [pagePreviews]); + + + useEffect(() => { + return () => { + if (pdfDocumentRef.current) { + pdfWorkerManager.destroyDocument(pdfDocumentRef.current); + pdfDocumentRef.current = null; + } + }; + }, []); + + const dirtyPages = useMemo( + () => getDirtyPages(groupsByPage, imagesByPage, originalGroupsRef.current, originalImagesRef.current), + [groupsByPage, imagesByPage], + ); + const hasChanges = useMemo(() => dirtyPages.some(Boolean), [dirtyPages]); + const hasDocument = loadedDocument !== null; + const viewLabel = useMemo(() => t('pdfTextEditor.viewLabel', 'PDF Editor'), [t]); + const { selectedFiles } = useFileSelection(); + + const resetToDocument = useCallback((document: PdfJsonDocument | null, mode: 'auto' | 'paragraph' | 'singleLine') => { + if (!document) { + setGroupsByPage([]); + setImagesByPage([]); + originalImagesRef.current = []; + imagesByPageRef.current = []; + setLoadedImagePages(new Set()); + setLoadingImagePages(new Set()); + loadedImagePagesRef.current = new Set(); + loadingImagePagesRef.current = new Set(); + setSelectedPage(0); + return; + } + const cloned = deepCloneDocument(document); + const groups = groupDocumentText(cloned, mode); + const images = extractDocumentImages(cloned); + const originalImages = images.map((page) => page.map(cloneImageElement)); + originalImagesRef.current = originalImages; + originalGroupsRef.current = groups.map((page) => page.map((group) => ({ ...group }))); + imagesByPageRef.current = images.map((page) => page.map(cloneImageElement)); + const initialLoaded = new Set(); + originalImages.forEach((pageImages, index) => { + if (pageImages.length > 0) { + initialLoaded.add(index); + } + }); + setGroupsByPage(groups); + setImagesByPage(images); + setLoadedImagePages(initialLoaded); + setLoadingImagePages(new Set()); + loadedImagePagesRef.current = new Set(initialLoaded); + loadingImagePagesRef.current = new Set(); + setSelectedPage(0); + }, []); + + const clearPdfPreview = useCallback(() => { + previewRequestIdRef.current += 1; + previewRenderingRef.current.clear(); + previewScaleRef.current.clear(); + const empty = new Map(); + pagePreviewsRef.current = empty; + setPagePreviews(empty); + if (pdfDocumentRef.current) { + pdfWorkerManager.destroyDocument(pdfDocumentRef.current); + pdfDocumentRef.current = null; + } + setHasVectorPreview(false); + }, []); + + const clearCachedJob = useCallback((jobId: string | null) => { + if (!jobId) { + return; + } + console.log(`[PdfTextEditor] Cleaning up cached document for jobId: ${jobId}`); + apiClient.post(`/api/v1/convert/pdf/text-editor/clear-cache/${jobId}`).catch((error) => { + console.warn('[PdfTextEditor] Failed to clear cache:', error); + }); + }, []); + + useEffect(() => { + const previousJobId = cachedJobIdRef.current; + if (previousJobId && previousJobId !== cachedJobId) { + clearCachedJob(previousJobId); + } + cachedJobIdRef.current = cachedJobId; + }, [cachedJobId, clearCachedJob]); + + const initializePdfPreview = useCallback( + async (file: File) => { + const requestId = ++previewRequestIdRef.current; + try { + const buffer = await file.arrayBuffer(); + const pdfDocument = await pdfWorkerManager.createDocument(buffer); + if (previewRequestIdRef.current !== requestId) { + pdfWorkerManager.destroyDocument(pdfDocument); + return; + } + if (pdfDocumentRef.current) { + pdfWorkerManager.destroyDocument(pdfDocumentRef.current); + } + pdfDocumentRef.current = pdfDocument; + previewRenderingRef.current.clear(); + previewScaleRef.current.clear(); + const empty = new Map(); + pagePreviewsRef.current = empty; + setPagePreviews(empty); + setHasVectorPreview(true); + } catch (error) { + if (previewRequestIdRef.current === requestId) { + console.warn('[PdfTextEditor] Failed to initialise PDF preview:', error); + clearPdfPreview(); + } + } + }, + [clearPdfPreview], + ); + + // Load images for a page in lazy mode + const loadImagesForPage = useCallback( + async (pageIndex: number) => { + if (!isLazyMode) { + return; + } + if (!cachedJobId) { + console.log('[loadImagesForPage] No cached jobId, skipping'); + return; + } + if ( + loadedImagePagesRef.current.has(pageIndex) || + loadingImagePagesRef.current.has(pageIndex) + ) { + return; + } + + loadingImagePagesRef.current.add(pageIndex); + setLoadingImagePages((prev) => { + const next = new Set(prev); + next.add(pageIndex); + return next; + }); + + const pageNumber = pageIndex + 1; + const start = performance.now(); + + try { + const response = await apiClient.get( + `/api/v1/convert/pdf/text-editor/page/${cachedJobId}/${pageNumber}`, + { + responseType: 'json', + }, + ); + + const pageData = response.data as PdfJsonPage; + const normalizedImages = (pageData.imageElements ?? []).map(cloneImageElement); + + if (imagesByPageRef.current.length <= pageIndex) { + imagesByPageRef.current.length = pageIndex + 1; + } + imagesByPageRef.current[pageIndex] = normalizedImages.map(cloneImageElement); + + setLoadedDocument((prevDoc) => { + if (!prevDoc || !prevDoc.pages) { + return prevDoc; + } + const nextPages = [...prevDoc.pages]; + const existingPage = nextPages[pageIndex] ?? {}; + nextPages[pageIndex] = { + ...existingPage, + imageElements: normalizedImages.map(cloneImageElement), + }; + return { + ...prevDoc, + pages: nextPages, + }; + }); + + setImagesByPage((prev) => { + const next = [...prev]; + while (next.length <= pageIndex) { + next.push([]); + } + next[pageIndex] = normalizedImages.map(cloneImageElement); + return next; + }); + + if (originalImagesRef.current.length <= pageIndex) { + originalImagesRef.current.length = pageIndex + 1; + } + originalImagesRef.current[pageIndex] = normalizedImages.map(cloneImageElement); + + setLoadedImagePages((prev) => { + const next = new Set(prev); + next.add(pageIndex); + return next; + }); + loadedImagePagesRef.current.add(pageIndex); + + console.log( + `[loadImagesForPage] Loaded ${normalizedImages.length} images for page ${pageNumber} in ${( + performance.now() - start + ).toFixed(2)}ms`, + ); + } catch (error) { + console.error(`[loadImagesForPage] Failed to load images for page ${pageNumber}:`, error); + } finally { + loadingImagePagesRef.current.delete(pageIndex); + setLoadingImagePages((prev) => { + const next = new Set(prev); + next.delete(pageIndex); + return next; + }); + } + }, + [isLazyMode, cachedJobId], + ); + + const handleLoadFile = useCallback( + async (file: File | null) => { + if (!file) { + return; + } + + const requestId = loadRequestIdRef.current + 1; + loadRequestIdRef.current = requestId; + + const _fileKey = getAutoLoadKey(file); + const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'); + + try { + let parsed: PdfJsonDocument | null = null; + let shouldUseLazyMode = false; + let pendingJobId: string | null = null; + + if (isPdf) { + latestPdfRequestIdRef.current = requestId; + setIsConverting(true); + setConversionProgress({ + percent: 0, + stage: 'uploading', + message: 'Uploading PDF file to server...', + }); + + const formData = new FormData(); + formData.append('fileInput', file); + + console.log('Sending conversion request with async=true'); + const response = await apiClient.post( + `${CONVERSION_ENDPOINTS['pdf-text-editor']}?async=true&lightweight=true`, + formData, + { + responseType: 'json', + }, + ); + + console.log('Conversion response:', response.data); + const jobId = response.data.jobId; + + if (!jobId) { + console.error('No job ID in response:', response.data); + throw new Error('No job ID received from server'); + } + + pendingJobId = jobId; + console.log('Got job ID:', jobId); + setConversionProgress({ + percent: 3, + stage: 'processing', + message: 'Starting conversion...', + }); + + let jobComplete = false; + let attempts = 0; + const maxAttempts = 600; + + while (!jobComplete && attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + attempts += 1; + + try { + const statusResponse = await apiClient.get(`/api/v1/general/job/${jobId}`); + const jobStatus = statusResponse.data; + console.log(`Job status (attempt ${attempts}):`, jobStatus); + + if (jobStatus.notes && jobStatus.notes.length > 0) { + const lastNote = jobStatus.notes[jobStatus.notes.length - 1]; + console.log('Latest note:', lastNote); + const matchWithCount = lastNote.match( + /\[(\d+)%\]\s+(\w+):\s+(.+?)\s+\((\d+)\/(\d+)\)/, + ); + if (matchWithCount) { + const percent = parseInt(matchWithCount[1], 10); + const stage = matchWithCount[2]; + const message = matchWithCount[3]; + const current = parseInt(matchWithCount[4], 10); + const total = parseInt(matchWithCount[5], 10); + setConversionProgress({ + percent, + stage, + message, + current, + total, + }); + } else { + const match = lastNote.match(/\[(\d+)%\]\s+(\w+):\s+(.+)/); + if (match) { + const percent = parseInt(match[1], 10); + const stage = match[2]; + const message = match[3]; + setConversionProgress({ + percent, + stage, + message, + }); + } + } + } else if (jobStatus.progress !== undefined) { + const percent = Math.min(Math.max(jobStatus.progress, 0), 100); + setConversionProgress({ + percent, + stage: jobStatus.stage || 'processing', + message: jobStatus.note || 'Converting PDF to JSON...', + }); + } + + if (jobStatus.complete) { + if (jobStatus.error) { + console.error('Job failed:', jobStatus.error); + throw new Error(jobStatus.error); + } + + console.log('Job completed, retrieving JSON result...'); + jobComplete = true; + + const resultResponse = await apiClient.get( + `/api/v1/general/job/${jobId}/result`, + { + responseType: 'blob', + }, + ); + + const jsonText = await resultResponse.data.text(); + const result = JSON.parse(jsonText); + + if (!Array.isArray(result.pages)) { + console.error('Conversion result missing page array:', result); + throw new Error( + 'PDF conversion result did not include page data. Please update the server.', + ); + } + + const docResult = result as PdfJsonDocument; + parsed = { + ...docResult, + pages: docResult.pages ?? [], + }; + shouldUseLazyMode = Boolean(docResult.lazyImages); + pendingJobId = shouldUseLazyMode ? jobId : null; + setConversionProgress(null); + } else { + console.log('Job not complete yet, continuing to poll...'); + } + } catch (pollError: any) { + console.error('Error polling job status:', pollError); + console.error('Poll error details:', { + status: pollError?.response?.status, + data: pollError?.response?.data, + message: pollError?.message, + }); + if (pollError?.response?.status === 404) { + throw new Error('Job not found on server'); + } + } + } + + if (!jobComplete) { + throw new Error('Conversion timed out'); + } + if (!parsed) { + throw new Error('Conversion did not return JSON content'); + } + } else { + const content = await file.text(); + const docResult = JSON.parse(content) as PdfJsonDocument; + parsed = { + ...docResult, + pages: docResult.pages ?? [], + }; + shouldUseLazyMode = false; + pendingJobId = null; + } + + setConversionProgress(null); + + if (loadRequestIdRef.current !== requestId) { + return; + } + + if (!parsed) { + throw new Error('Failed to parse PDF JSON document'); + } + + console.log( + `[PdfTextEditor] Document loaded. Lazy image mode: ${shouldUseLazyMode}, Pages: ${ + parsed.pages?.length || 0 + }`, + ); + + if (isPdf) { + initializePdfPreview(file); + } else { + clearPdfPreview(); + } + + setLoadedDocument(parsed); + resetToDocument(parsed, groupingMode); + setIsLazyMode(shouldUseLazyMode); + setCachedJobId(shouldUseLazyMode ? pendingJobId : null); + setFileName(file.name); + setErrorMessage(null); + } catch (error: any) { + console.error('Failed to load file', error); + console.error('Error details:', { + message: error?.message, + response: error?.response?.data, + stack: error?.stack, + }); + + if (loadRequestIdRef.current !== requestId) { + return; + } + + setLoadedDocument(null); + resetToDocument(null, groupingMode); + clearPdfPreview(); + + if (isPdf) { + const errorMsg = + error?.message || + t('pdfTextEditor.conversionFailed', 'Failed to convert PDF. Please try again.'); + setErrorMessage(errorMsg); + console.error('Setting error message:', errorMsg); + } else { + setErrorMessage( + t( + 'pdfTextEditor.errors.invalidJson', + 'Unable to read the JSON file. Ensure it was generated by the PDF to JSON tool.', + ), + ); + } + } finally { + if (isPdf && latestPdfRequestIdRef.current === requestId) { + setIsConverting(false); + } + } + }, + [groupingMode, resetToDocument, t], + ); + + const handleSelectPage = useCallback((pageIndex: number) => { + setSelectedPage(pageIndex); + // Trigger lazy loading for images on the selected page + if (isLazyMode) { + void loadImagesForPage(pageIndex); + } + }, [isLazyMode, loadImagesForPage]); + + const handleGroupTextChange = useCallback((pageIndex: number, groupId: string, value: string) => { + setGroupsByPage((previous) => + previous.map((groups, idx) => + idx !== pageIndex + ? groups + : groups.map((group) => (group.id === groupId ? { ...group, text: value } : group)) + ) + ); + }, []); + + const handleGroupDelete = useCallback((pageIndex: number, groupId: string) => { + console.log(`🗑️ Deleting group ${groupId} from page ${pageIndex}`); + setGroupsByPage((previous) => { + const updated = previous.map((groups, idx) => { + if (idx !== pageIndex) return groups; + const filtered = groups.filter((group) => group.id !== groupId); + console.log(` Before: ${groups.length} groups, After: ${filtered.length} groups`); + return filtered; + }); + return updated; + }); + }, []); + + const handleMergeGroups = useCallback((pageIndex: number, groupIds: string[]): boolean => { + if (groupIds.length < 2) { + return false; + } + let updated = false; + setGroupsByPage((previous) => + previous.map((groups, idx) => { + if (idx !== pageIndex) { + return groups; + } + const indices = groupIds + .map((id) => groups.findIndex((group) => group.id === id)) + .filter((index) => index >= 0); + if (indices.length !== groupIds.length) { + return groups; + } + const sorted = [...indices].sort((a, b) => a - b); + for (let i = 1; i < sorted.length; i += 1) { + if (sorted[i] !== sorted[i - 1] + 1) { + return groups; + } + } + const selection = sorted.map((position) => groups[position]); + const merged = buildMergedGroupFromSelection(selection); + if (!merged) { + return groups; + } + const next = [ + ...groups.slice(0, sorted[0]), + merged, + ...groups.slice(sorted[sorted.length - 1] + 1), + ]; + updated = true; + return next; + }), + ); + return updated; + }, []); + + const handleUngroupGroup = useCallback((pageIndex: number, groupId: string): boolean => { + let updated = false; + setGroupsByPage((previous) => + previous.map((groups, idx) => { + if (idx !== pageIndex) { + return groups; + } + const targetIndex = groups.findIndex((group) => group.id === groupId); + if (targetIndex < 0) { + return groups; + } + const targetGroup = groups[targetIndex]; + const splits = splitParagraphGroup(targetGroup); + if (splits.length <= 1) { + return groups; + } + const next = [ + ...groups.slice(0, targetIndex), + ...splits, + ...groups.slice(targetIndex + 1), + ]; + updated = true; + return next; + }), + ); + return updated; + }, []); + + const handleImageTransform = useCallback( + ( + pageIndex: number, + imageId: string, + next: { left: number; bottom: number; width: number; height: number; transform: number[] }, + ) => { + setImagesByPage((previous) => { + const current = previous[pageIndex] ?? []; + let changed = false; + const updatedPage = current.map((image) => { + if ((image.id ?? '') !== imageId) { + return image; + } + const originalTransform = image.transform ?? originalImagesRef.current[pageIndex]?.find((base) => (base.id ?? '') === imageId)?.transform; + const scaleXSign = originalTransform && originalTransform.length >= 6 ? Math.sign(originalTransform[0]) || 1 : 1; + const scaleYSign = originalTransform && originalTransform.length >= 6 ? Math.sign(originalTransform[3]) || 1 : 1; + const right = next.left + next.width; + const top = next.bottom + next.height; + const updatedImage: PdfJsonImageElement = { + ...image, + x: next.left, + y: next.bottom, + left: next.left, + bottom: next.bottom, + right, + top, + width: next.width, + height: next.height, + transform: scaleXSign < 0 || scaleYSign < 0 + ? [ + next.width * scaleXSign, + 0, + 0, + next.height * scaleYSign, + next.left, + scaleYSign >= 0 ? next.bottom : next.bottom + next.height, + ] + : null, + }; + + const isSame = + Math.abs(valueOr(image.left, 0) - next.left) < 1e-4 && + Math.abs(valueOr(image.bottom, 0) - next.bottom) < 1e-4 && + Math.abs(valueOr(image.width, 0) - next.width) < 1e-4 && + Math.abs(valueOr(image.height, 0) - next.height) < 1e-4; + + if (!isSame) { + changed = true; + } + return updatedImage; + }); + + if (!changed) { + return previous; + } + + const nextImages = previous.map((images, idx) => (idx === pageIndex ? updatedPage : images)); + if (imagesByPageRef.current.length <= pageIndex) { + imagesByPageRef.current.length = pageIndex + 1; + } + imagesByPageRef.current[pageIndex] = updatedPage.map(cloneImageElement); + return nextImages; + }); + }, + [], + ); + + const handleImageReset = useCallback((pageIndex: number, imageId: string) => { + const baseline = originalImagesRef.current[pageIndex]?.find((image) => (image.id ?? '') === imageId); + if (!baseline) { + return; + } + setImagesByPage((previous) => { + const current = previous[pageIndex] ?? []; + let changed = false; + const updatedPage = current.map((image) => { + if ((image.id ?? '') !== imageId) { + return image; + } + changed = true; + return cloneImageElement(baseline); + }); + + if (!changed) { + return previous; + } + + const nextImages = previous.map((images, idx) => (idx === pageIndex ? updatedPage : images)); + if (imagesByPageRef.current.length <= pageIndex) { + imagesByPageRef.current.length = pageIndex + 1; + } + imagesByPageRef.current[pageIndex] = updatedPage.map(cloneImageElement); + return nextImages; + }); + }, []); + + const handleResetEdits = useCallback(() => { + if (!loadedDocument) { + return; + } + resetToDocument(loadedDocument, groupingMode); + setErrorMessage(null); + }, [groupingMode, loadedDocument, resetToDocument]); + + const buildPayload = useCallback(() => { + if (!loadedDocument) { + return null; + } + + const updatedDocument = restoreGlyphElements( + loadedDocument, + groupsByPage, + imagesByPageRef.current, + originalImagesRef.current, + forceSingleTextElement, + ); + const baseName = sanitizeBaseName(fileName || loadedDocument.metadata?.title || undefined); + return { + document: updatedDocument, + filename: `${baseName}.json`, + }; + }, [fileName, forceSingleTextElement, groupsByPage, loadedDocument]); + + const handleDownloadJson = useCallback(() => { + const payload = buildPayload(); + if (!payload) { + return; + } + + const { document, filename } = payload; + const serialized = JSON.stringify(document); + downloadTextAsFile(serialized, filename, 'application/json'); + + if (onComplete) { + const exportedFile = new File([serialized], filename, { type: 'application/json' }); + onComplete([exportedFile]); + } + }, [buildPayload, onComplete]); + + const handleGeneratePdf = useCallback(async (skipComplete = false) => { + try { + setIsGeneratingPdf(true); + + const ensureImagesForPages = async (pageIndices: number[]) => { + const uniqueIndices = Array.from(new Set(pageIndices)).filter((index) => index >= 0); + if (uniqueIndices.length === 0) { + return; + } + + for (const index of uniqueIndices) { + if (!loadedImagePagesRef.current.has(index)) { + await loadImagesForPage(index); + } + } + + const maxWaitTime = 15000; + const pollInterval = 150; + const startWait = Date.now(); + while (Date.now() - startWait < maxWaitTime) { + const allLoaded = uniqueIndices.every( + (index) => + loadedImagePagesRef.current.has(index) && + imagesByPageRef.current[index] !== undefined, + ); + const anyLoading = uniqueIndices.some((index) => + loadingImagePagesRef.current.has(index), + ); + if (allLoaded && !anyLoading) { + return; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + const missing = uniqueIndices.filter( + (index) => !loadedImagePagesRef.current.has(index), + ); + if (missing.length > 0) { + throw new Error( + `Failed to load images for pages ${missing.map((i) => i + 1).join(', ')}`, + ); + } + }; + + const currentDoc = loadedDocumentRef.current; + const totalPages = currentDoc?.pages?.length ?? 0; + const dirtyPageIndices = dirtyPages + .map((isDirty, index) => (isDirty ? index : -1)) + .filter((index) => index >= 0); + + const canUseIncremental = + isLazyMode && + cachedJobId && + dirtyPageIndices.length > 0 && + dirtyPageIndices.length < totalPages; + + if (canUseIncremental) { + await ensureImagesForPages(dirtyPageIndices); + + try { + const payload = buildPayload(); + if (!payload) { + return; + } + + const { document, filename } = payload; + const dirtyPageSet = new Set(dirtyPageIndices); + const partialPages = + document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? []; + + const partialDocument: PdfJsonDocument = { + metadata: document.metadata, + xmpMetadata: document.xmpMetadata, + fonts: document.fonts, + lazyImages: true, + pages: partialPages, + }; + + const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ''); + const expectedName = `${baseName || 'document'}.pdf`; + const response = await apiClient.post( + `/api/v1/convert/pdf/text-editor/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`, + partialDocument, + { + responseType: 'blob', + }, + ); + + const contentDisposition = response.headers?.['content-disposition'] ?? ''; + const detectedName = getFilenameFromHeaders(contentDisposition); + const downloadName = detectedName || expectedName; + + downloadBlob(response.data, downloadName); + + if (onComplete && !skipComplete) { + const pdfFile = new File([response.data], downloadName, { type: 'application/pdf' }); + onComplete([pdfFile]); + } + setErrorMessage(null); + return; + } catch (incrementalError) { + console.warn( + '[handleGeneratePdf] Incremental export failed, falling back to full export', + incrementalError, + ); + } + } + + if (isLazyMode && totalPages > 0) { + const allPageIndices = Array.from({ length: totalPages }, (_, index) => index); + await ensureImagesForPages(allPageIndices); + } + + const payload = buildPayload(); + if (!payload) { + return; + } + + const { document, filename } = payload; + const serialized = JSON.stringify(document); + const jsonFile = new File([serialized], filename, { type: 'application/json' }); + + const formData = new FormData(); + formData.append('fileInput', jsonFile); + const response = await apiClient.post(CONVERSION_ENDPOINTS['text-editor-pdf'], formData, { + responseType: 'blob', + }); + + const contentDisposition = response.headers?.['content-disposition'] ?? ''; + const detectedName = getFilenameFromHeaders(contentDisposition); + const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ''); + const downloadName = detectedName || `${baseName || 'document'}.pdf`; + + downloadBlob(response.data, downloadName); + + if (onComplete && !skipComplete) { + const pdfFile = new File([response.data], downloadName, { type: 'application/pdf' }); + onComplete([pdfFile]); + } + setErrorMessage(null); + } catch (error: any) { + console.error('Failed to convert JSON back to PDF', error); + const message = + error?.response?.data || + error?.message || + t('pdfTextEditor.errors.pdfConversion', 'Unable to convert the edited JSON back into a PDF.'); + const msgString = typeof message === 'string' ? message : String(message); + setErrorMessage(msgString); + if (onError) { + onError(msgString); + } + } finally { + setIsGeneratingPdf(false); + } + }, [ + buildPayload, + cachedJobId, + dirtyPages, + isLazyMode, + loadImagesForPage, + onComplete, + onError, + t, + ]); + + const requestPagePreview = useCallback( + async (pageIndex: number, scale: number) => { + if (!hasVectorPreview || !pdfDocumentRef.current) { + return; + } + const currentToken = previewRequestIdRef.current; + const recordedScale = previewScaleRef.current.get(pageIndex); + if ( + pagePreviewsRef.current.has(pageIndex) && + recordedScale !== undefined && + Math.abs(recordedScale - scale) < 0.05 + ) { + return; + } + if (previewRenderingRef.current.has(pageIndex)) { + return; + } + previewRenderingRef.current.add(pageIndex); + try { + const page = await pdfDocumentRef.current.getPage(pageIndex + 1); + const viewport = page.getViewport({ scale: Math.max(scale, 0.5) }); + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const context = canvas.getContext('2d'); + if (!context) { + page.cleanup(); + return; + } + await page.render({ canvas, canvasContext: context, viewport }).promise; + + try { + const textContent = await page.getTextContent(); + const maskMarginX = 0; + const maskMarginTop = 0; + const maskMarginBottom = Math.max(3 * scale, 3); + context.save(); + context.globalCompositeOperation = 'destination-out'; + context.fillStyle = '#000000'; + for (const item of textContent.items) { + // Skip TextMarkedContent items, only process TextItem + if (!('transform' in item)) continue; + + const transform = Util.transform(viewport.transform, item.transform); + const a = transform[0]; + const b = transform[1]; + const c = transform[2]; + const d = transform[3]; + const e = transform[4]; + const f = transform[5]; + const angle = Math.atan2(b, a); + + const width = (item.width || 0) * viewport.scale + maskMarginX * 2; + const fontHeight = Math.hypot(c, d); + const rawHeight = item.height ? item.height * viewport.scale : fontHeight; + const height = Math.max(rawHeight + maskMarginTop + maskMarginBottom, fontHeight + maskMarginTop + maskMarginBottom); + const baselineOffset = height - maskMarginBottom; + + context.save(); + context.translate(e, f); + context.rotate(angle); + context.fillRect(-maskMarginX, -baselineOffset, width, height); + context.restore(); + } + context.restore(); + } catch (textError) { + console.warn('[PdfTextEditor] Failed to strip text from preview', textError); + } + + // Also mask out images to prevent ghost/shadow images when they're moved + try { + const pageImages = imagesByPage[pageIndex] ?? []; + if (pageImages.length > 0) { + context.save(); + context.globalCompositeOperation = 'destination-out'; + context.fillStyle = '#000000'; + for (const image of pageImages) { + if (!image) continue; + // Get image bounds in PDF coordinates + const left = image.left ?? image.x ?? 0; + const bottom = image.bottom ?? image.y ?? 0; + const width = image.width ?? Math.max((image.right ?? left) - left, 0); + const height = image.height ?? Math.max((image.top ?? bottom) - bottom, 0); + const _right = left + width; + const top = bottom + height; + + // Convert to canvas coordinates (PDF origin is bottom-left, canvas is top-left) + const canvasX = left * scale; + const canvasY = canvas.height - top * scale; + const canvasWidth = width * scale; + const canvasHeight = height * scale; + context.fillRect(canvasX, canvasY, canvasWidth, canvasHeight); + } + context.restore(); + } + } catch (imageError) { + console.warn('[PdfTextEditor] Failed to strip images from preview', imageError); + } + const dataUrl = canvas.toDataURL('image/png'); + page.cleanup(); + if (previewRequestIdRef.current !== currentToken) { + return; + } + previewScaleRef.current.set(pageIndex, scale); + setPagePreviews((prev) => { + const next = new Map(prev); + next.set(pageIndex, dataUrl); + return next; + }); + } catch (error) { + console.warn('[PdfTextEditor] Failed to render page preview', error); + } finally { + previewRenderingRef.current.delete(pageIndex); + } + }, + [hasVectorPreview, imagesByPage], + ); + + // Re-group text when grouping mode changes without forcing a full reload + useEffect(() => { + const currentDocument = loadedDocumentRef.current; + if (currentDocument) { + resetToDocument(currentDocument, groupingMode); + } + }, [groupingMode, resetToDocument]); + + const viewData = useMemo(() => ({ + document: loadedDocument, + groupsByPage, + imagesByPage, + pagePreviews, + selectedPage, + dirtyPages, + hasDocument, + hasVectorPreview, + fileName, + errorMessage, + isGeneratingPdf, + isConverting, + conversionProgress, + hasChanges, + forceSingleTextElement, + groupingMode, + requestPagePreview, + onSelectPage: handleSelectPage, + onGroupEdit: handleGroupTextChange, + onGroupDelete: handleGroupDelete, + onImageTransform: handleImageTransform, + onImageReset: handleImageReset, + onReset: handleResetEdits, + onDownloadJson: handleDownloadJson, + onGeneratePdf: handleGeneratePdf, + onGeneratePdfForNavigation: async () => { + // Generate PDF without triggering tool completion + await handleGeneratePdf(true); + }, + onForceSingleTextElementChange: setForceSingleTextElement, + onGroupingModeChange: setGroupingMode, + onMergeGroups: handleMergeGroups, + onUngroupGroup: handleUngroupGroup, + }), [ + handleMergeGroups, + handleUngroupGroup, + handleImageTransform, + imagesByPage, + pagePreviews, + dirtyPages, + errorMessage, + fileName, + groupsByPage, + handleDownloadJson, + handleGeneratePdf, + handleGroupTextChange, + handleGroupDelete, + handleImageReset, + handleResetEdits, + handleSelectPage, + hasChanges, + hasDocument, + hasVectorPreview, + isGeneratingPdf, + isConverting, + conversionProgress, + loadedDocument, + selectedPage, + forceSingleTextElement, + groupingMode, + requestPagePreview, + setForceSingleTextElement, + ]); + + const latestViewDataRef = useRef(viewData); + latestViewDataRef.current = viewData; + + // Trigger initial image loading in lazy mode + useEffect(() => { + if (isLazyMode && loadedDocument) { + void loadImagesForPage(selectedPage); + } + }, [isLazyMode, loadedDocument, selectedPage, loadImagesForPage]); + + useEffect(() => { + if (selectedFiles.length === 0) { + autoLoadKeyRef.current = null; + return; + } + + if (navigationState.selectedTool !== 'pdfTextEditor') { + return; + } + + const file = selectedFiles[0]; + if (!file) { + return; + } + + const fileKey = getAutoLoadKey(file); + if (autoLoadKeyRef.current === fileKey) { + return; + } + + autoLoadKeyRef.current = fileKey; + void handleLoadFile(file); + }, [selectedFiles, navigationState.selectedTool, handleLoadFile]); + + // Auto-navigate to workbench when tool is selected + const hasAutoOpenedWorkbenchRef = useRef(false); + useEffect(() => { + if (navigationState.selectedTool !== 'pdfTextEditor') { + hasAutoOpenedWorkbenchRef.current = false; + return; + } + + if (hasAutoOpenedWorkbenchRef.current) { + return; + } + + hasAutoOpenedWorkbenchRef.current = true; + // Use timeout to ensure registration effect has run first + setTimeout(() => { + navigationActions.setWorkbench(WORKBENCH_ID); + }, 0); + }, [navigationActions, navigationState.selectedTool]); + + useEffect(() => { + registerCustomWorkbenchView({ + id: WORKBENCH_VIEW_ID, + workbenchId: WORKBENCH_ID, + label: viewLabel, + icon: , + component: PdfTextEditorView, + }); + setLeftPanelView('hidden'); + setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, latestViewDataRef.current); + + return () => { + // Clear backend cache if we were using lazy loading + clearCachedJob(cachedJobIdRef.current); + clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID); + unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID); + setLeftPanelView('toolPicker'); + }; + }, [ + clearCachedJob, + clearCustomWorkbenchViewData, + registerCustomWorkbenchView, + setCustomWorkbenchViewData, + setLeftPanelView, + viewLabel, + unregisterCustomWorkbenchView, + ]); + + // Note: Compare tool doesn't auto-force workbench, and neither should we + // The workbench should be set when the tool is selected via proper channels + // (tool registry, tool picker, etc.) - not forced here + + // Keep hasChanges in a ref for the checker to access + const hasChangesRef = useRef(hasChanges); + useEffect(() => { + hasChangesRef.current = hasChanges; + console.log('[PdfTextEditor] hasChanges updated to:', hasChanges); + }, [hasChanges]); + + // Register unsaved changes checker for navigation guard + useEffect(() => { + const checker = () => { + console.log('[PdfTextEditor] Checking unsaved changes:', hasChangesRef.current); + return hasChangesRef.current; + }; + registerUnsavedChangesChecker(checker); + console.log('[PdfTextEditor] Registered unsaved changes checker'); + return () => { + console.log('[PdfTextEditor] Unregistered unsaved changes checker'); + unregisterUnsavedChangesChecker(); + }; + }, [registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); + + const lastSentViewDataRef = useRef(null); + + useEffect(() => { + if (lastSentViewDataRef.current === viewData) { + return; + } + lastSentViewDataRef.current = viewData; + setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, viewData); + }, [setCustomWorkbenchViewData, viewData]); + + // All editing happens in the custom workbench view. + return null; +}; + +(PdfTextEditor as ToolComponent).tool = () => { + throw new Error('PDF JSON Editor does not support automation operations.'); +}; + +(PdfTextEditor as ToolComponent).getDefaultParameters = () => ({ + groups: [], +}); + +export default PdfTextEditor as ToolComponent; diff --git a/frontend/src/proprietary/tools/pdfTextEditor/fontAnalysis.ts b/frontend/src/proprietary/tools/pdfTextEditor/fontAnalysis.ts new file mode 100644 index 0000000000..3582b9b787 --- /dev/null +++ b/frontend/src/proprietary/tools/pdfTextEditor/fontAnalysis.ts @@ -0,0 +1,421 @@ +import { PdfJsonDocument, PdfJsonFont } from '@app/tools/pdfTextEditor/pdfTextEditorTypes'; + +export type FontStatus = 'perfect' | 'embedded-subset' | 'system-fallback' | 'missing' | 'unknown'; + +export interface FontAnalysis { + fontId: string; + baseName: string; + status: FontStatus; + embedded: boolean; + isSubset: boolean; + isStandard14: boolean; + hasWebFormat: boolean; + webFormat?: string; + subtype?: string; + encoding?: string; + warnings: string[]; + suggestions: string[]; +} + +export interface DocumentFontAnalysis { + fonts: FontAnalysis[]; + canReproducePerfectly: boolean; + hasWarnings: boolean; + summary: { + perfect: number; + embeddedSubset: number; + systemFallback: number; + missing: number; + unknown: number; + }; +} + +/** + * Determines if a font name indicates it's a subset font. + * Subset fonts typically have a 6-character prefix like "ABCDEE+" + */ +const isSubsetFont = (baseName: string | null | undefined): boolean => { + if (!baseName) return false; + // Check for common subset patterns: ABCDEF+FontName + return /^[A-Z]{6}\+/.test(baseName); +}; + +/** + * Checks if a font is one of the standard 14 PDF fonts that are guaranteed + * to be available on all PDF readers + */ +const isStandard14Font = (font: PdfJsonFont): boolean => { + if (font.standard14Name) return true; + + const baseName = (font.baseName || '').toLowerCase().replace(/[-_\s]/g, ''); + + const standard14Patterns = [ + 'timesroman', 'timesbold', 'timesitalic', 'timesbolditalic', + 'helvetica', 'helveticabold', 'helveticaoblique', 'helveticaboldoblique', + 'courier', 'courierbold', 'courieroblique', 'courierboldoblique', + 'symbol', 'zapfdingbats' + ]; + + // Check exact matches or if the base name contains the pattern + return standard14Patterns.some(pattern => { + // Exact match + if (baseName === pattern) return true; + // Contains pattern (e.g., "ABCDEF+Helvetica" matches "helvetica") + if (baseName.includes(pattern)) return true; + return false; + }); +}; + +/** + * Checks if a font has a fallback available on the backend. + * These fonts are embedded in the Stirling PDF backend and can be used + * for PDF export even if not in the original PDF. + * + * Based on PdfJsonFallbackFontService.java + */ +const hasBackendFallbackFont = (font: PdfJsonFont): boolean => { + const baseName = (font.baseName || '').toLowerCase().replace(/[-_\s]/g, ''); + + // Backend has these font families available (from PdfJsonFallbackFontService) + const backendFonts = [ + // Liberation fonts (metric-compatible with MS core fonts) + 'arial', 'helvetica', 'arimo', + 'times', 'timesnewroman', 'tinos', + 'courier', 'couriernew', 'cousine', + 'liberation', 'liberationsans', 'liberationserif', 'liberationmono', + // DejaVu fonts + 'dejavu', 'dejavusans', 'dejavuserif', 'dejavumono', 'dejavusansmono', + // Noto fonts + 'noto', 'notosans' + ]; + + return backendFonts.some(pattern => { + if (baseName === pattern) return true; + if (baseName.includes(pattern)) return true; + return false; + }); +}; + +/** + * Extracts the base font name from a subset font name + * e.g., "ABCDEF+Arial" -> "Arial" + */ +const extractBaseFontName = (baseName: string | null | undefined): string | null => { + if (!baseName) return null; + const match = baseName.match(/^[A-Z]{6}\+(.+)$/); + return match ? match[1] : baseName; +}; + +/** + * Analyzes a single font to determine if it can be reproduced perfectly + * Takes allFonts to check if full versions of subset fonts are available + */ +export const analyzeFontReproduction = (font: PdfJsonFont, allFonts?: PdfJsonFont[]): FontAnalysis => { + const fontId = font.id || font.uid || 'unknown'; + const baseName = font.baseName || 'Unknown Font'; + const isSubset = isSubsetFont(font.baseName); + const isStandard14 = isStandard14Font(font); + const hasBackendFallback = hasBackendFallbackFont(font); + const embedded = font.embedded ?? false; + + // Check available web formats (ordered by preference) + const webFormats = [ + { key: 'webProgram', format: font.webProgramFormat }, + { key: 'pdfProgram', format: font.pdfProgramFormat }, + { key: 'program', format: font.programFormat }, + ]; + + const availableWebFormat = webFormats.find(f => f.format); + const hasWebFormat = !!availableWebFormat; + const webFormat = availableWebFormat?.format || undefined; + + const warnings: string[] = []; + const suggestions: string[] = []; + let status: FontStatus = 'unknown'; + + // Check if we have the full font when this is a subset + let hasFullFontVersion = false; + if (isSubset && allFonts) { + const baseFont = extractBaseFontName(font.baseName); + if (baseFont) { + // Look for a non-subset version of this font with a web format + hasFullFontVersion = allFonts.some(f => { + const otherBaseName = extractBaseFontName(f.baseName); + const isNotSubset = !isSubsetFont(f.baseName); + const hasFormat = !!(f.webProgramFormat || f.pdfProgramFormat || f.programFormat); + const sameBase = otherBaseName?.toLowerCase() === baseFont.toLowerCase(); + return sameBase && isNotSubset && hasFormat && (f.embedded ?? false); + }); + } + } + + // Analyze font status - focusing on PDF export quality + if (isStandard14) { + // Standard 14 fonts are always available in PDF readers - perfect for export! + status = 'perfect'; + suggestions.push('Standard PDF font (Times, Helvetica, or Courier). Always available in PDF readers.'); + suggestions.push('Exported PDFs will render consistently across all PDF readers.'); + } else if (embedded && !isSubset) { + // Perfect: Fully embedded with complete character set + status = 'perfect'; + suggestions.push('Font is fully embedded. Exported PDFs will reproduce text perfectly, even with edits.'); + } else if (embedded && isSubset && (hasFullFontVersion || hasBackendFallback)) { + // Subset but we have the full font or backend fallback - perfect! + status = 'perfect'; + if (hasFullFontVersion) { + suggestions.push('Full font version is also available in the document. Exported PDFs can reproduce all characters.'); + } else if (hasBackendFallback) { + suggestions.push('Backend has the full font available. Exported PDFs can reproduce all characters, including new text.'); + } + } else if (embedded && isSubset) { + // Good, but subset: May have missing characters if user adds new text + status = 'embedded-subset'; + warnings.push('This is a subset font - only specific characters are embedded in the PDF.'); + warnings.push('Exported PDFs may have missing characters if you add new text with this font.'); + suggestions.push('Existing text will export correctly. New characters may render as boxes (☐) or fallback glyphs.'); + } else if (!embedded && hasBackendFallback) { + // Not embedded, but backend has it - perfect for export! + status = 'perfect'; + suggestions.push('Backend has this font available. Exported PDFs will use the backend fallback font.'); + suggestions.push('Text will export correctly with consistent appearance.'); + } else if (!embedded) { + // Not embedded - must rely on system fonts (risky for export) + status = 'missing'; + warnings.push('Font is not embedded in the PDF.'); + warnings.push('Exported PDFs will substitute with a fallback font, which may look very different.'); + suggestions.push('Consider re-embedding fonts or accepting that the exported PDF will use fallback fonts.'); + } else if (embedded && !hasWebFormat) { + // Embedded but no web format available (still okay for export) + status = 'perfect'; + suggestions.push('Font is embedded in the PDF. Exported PDFs will reproduce correctly.'); + suggestions.push('Web preview may use a fallback font, but the final PDF export will be accurate.'); + } + + // Additional warnings based on font properties + if (font.subtype === 'Type0' && font.cidSystemInfo) { + const registry = font.cidSystemInfo.registry || ''; + const ordering = font.cidSystemInfo.ordering || ''; + if (registry.includes('Adobe') && (ordering.includes('Identity') || ordering.includes('UCS'))) { + // CID fonts with Identity encoding are common for Asian languages + if (!embedded || !hasWebFormat) { + warnings.push('This CID font may contain Asian or Unicode characters.'); + } + } + } + + if (font.encoding && !font.encoding.includes('WinAnsiEncoding') && !font.encoding.includes('MacRomanEncoding')) { + // Custom encodings may cause issues + if (font.encoding !== 'Identity-H' && font.encoding !== 'Identity-V') { + warnings.push(`Custom encoding detected: ${font.encoding}`); + } + } + + return { + fontId, + baseName, + status, + embedded, + isSubset, + isStandard14, + hasWebFormat, + webFormat, + subtype: font.subtype || undefined, + encoding: font.encoding || undefined, + warnings, + suggestions, + }; +}; + +/** + * Gets fonts used on a specific page + */ +export const getFontsForPage = ( + document: PdfJsonDocument | null, + pageIndex: number +): PdfJsonFont[] => { + if (!document?.fonts || !document?.pages || pageIndex < 0 || pageIndex >= document.pages.length) { + return []; + } + + const page = document.pages[pageIndex]; + if (!page?.textElements) { + return []; + } + + // Get unique font IDs used on this page + const fontIdsOnPage = new Set(); + page.textElements.forEach(element => { + if (element?.fontId) { + fontIdsOnPage.add(element.fontId); + } + }); + + // Filter fonts to only those used on this page + const allFonts = document.fonts.filter((font): font is PdfJsonFont => font !== null && font !== undefined); + + const fontsOnPage = allFonts.filter(font => { + // Match by ID + if (font.id && fontIdsOnPage.has(font.id)) { + return true; + } + // Match by UID + if (font.uid && fontIdsOnPage.has(font.uid)) { + return true; + } + // Match by page-specific ID (pageNumber:id format) + if (font.pageNumber === pageIndex + 1 && font.id) { + const pageSpecificId = `${font.pageNumber}:${font.id}`; + if (fontIdsOnPage.has(pageSpecificId) || fontIdsOnPage.has(font.id)) { + return true; + } + } + return false; + }); + + // Deduplicate by base font name to avoid showing the same font multiple times + const uniqueFonts = new Map(); + fontsOnPage.forEach(font => { + const baseName = extractBaseFontName(font.baseName) || font.baseName || font.id || 'unknown'; + const key = baseName.toLowerCase(); + + // Keep the first occurrence, or prefer non-subset over subset + const existing = uniqueFonts.get(key); + if (!existing) { + uniqueFonts.set(key, font); + } else { + // Prefer non-subset fonts over subset fonts + const existingIsSubset = isSubsetFont(existing.baseName); + const currentIsSubset = isSubsetFont(font.baseName); + if (existingIsSubset && !currentIsSubset) { + uniqueFonts.set(key, font); + } + } + }); + + return Array.from(uniqueFonts.values()); +}; + +/** + * Analyzes all fonts in a PDF document (or just fonts for a specific page) + */ +export const analyzeDocumentFonts = ( + document: PdfJsonDocument | null, + pageIndex?: number +): DocumentFontAnalysis => { + if (!document?.fonts || document.fonts.length === 0) { + return { + fonts: [], + canReproducePerfectly: true, + hasWarnings: false, + summary: { + perfect: 0, + embeddedSubset: 0, + systemFallback: 0, + missing: 0, + unknown: 0, + }, + }; + } + + const allFonts = document.fonts.filter((font): font is PdfJsonFont => font !== null && font !== undefined); + + // Filter to page-specific fonts if pageIndex is provided + const fontsToAnalyze = pageIndex !== undefined + ? getFontsForPage(document, pageIndex) + : allFonts; + + if (fontsToAnalyze.length === 0) { + return { + fonts: [], + canReproducePerfectly: true, + hasWarnings: false, + summary: { + perfect: 0, + embeddedSubset: 0, + systemFallback: 0, + missing: 0, + unknown: 0, + }, + }; + } + + const fontAnalyses = fontsToAnalyze.map(font => analyzeFontReproduction(font, allFonts)); + + // Calculate summary + const summary = { + perfect: fontAnalyses.filter(f => f.status === 'perfect').length, + embeddedSubset: fontAnalyses.filter(f => f.status === 'embedded-subset').length, + systemFallback: fontAnalyses.filter(f => f.status === 'system-fallback').length, + missing: fontAnalyses.filter(f => f.status === 'missing').length, + unknown: fontAnalyses.filter(f => f.status === 'unknown').length, + }; + + // Can reproduce perfectly ONLY if all fonts are truly perfect (not subsets) + const canReproducePerfectly = fontAnalyses.every(f => f.status === 'perfect'); + + // Has warnings if any font has issues (including subsets) + const hasWarnings = fontAnalyses.some( + f => f.warnings.length > 0 || f.status === 'missing' || f.status === 'system-fallback' || f.status === 'embedded-subset' + ); + + return { + fonts: fontAnalyses, + canReproducePerfectly, + hasWarnings, + summary, + }; +}; + +/** + * Gets a human-readable description of the font status + */ +export const getFontStatusDescription = (status: FontStatus): string => { + switch (status) { + case 'perfect': + return 'Fully embedded - perfect reproduction'; + case 'embedded-subset': + return 'Embedded (subset) - existing text will render correctly'; + case 'system-fallback': + return 'Using system font - appearance may differ'; + case 'missing': + return 'Not embedded - will use fallback font'; + case 'unknown': + return 'Unknown status'; + } +}; + +/** + * Gets a color indicator for the font status + */ +export const getFontStatusColor = (status: FontStatus): string => { + switch (status) { + case 'perfect': + return 'green'; + case 'embedded-subset': + return 'blue'; + case 'system-fallback': + return 'yellow'; + case 'missing': + return 'red'; + case 'unknown': + return 'gray'; + } +}; + +/** + * Gets an icon indicator for the font status + */ +export const getFontStatusIcon = (status: FontStatus): string => { + switch (status) { + case 'perfect': + return '✓'; + case 'embedded-subset': + return '⚠'; + case 'system-fallback': + return '⚠'; + case 'missing': + return '✗'; + case 'unknown': + return '?'; + } +}; diff --git a/frontend/src/proprietary/tools/pdfTextEditor/pdfTextEditorTypes.ts b/frontend/src/proprietary/tools/pdfTextEditor/pdfTextEditorTypes.ts new file mode 100644 index 0000000000..8439bb4c18 --- /dev/null +++ b/frontend/src/proprietary/tools/pdfTextEditor/pdfTextEditorTypes.ts @@ -0,0 +1,228 @@ +export interface PdfJsonFontCidSystemInfo { + registry?: string | null; + ordering?: string | null; + supplement?: number | null; +} + +export interface PdfJsonTextColor { + colorSpace?: string | null; + components?: number[] | null; +} + +export interface PdfJsonCosValue { + type?: string | null; + value?: unknown; + items?: PdfJsonCosValue[] | null; + entries?: Record | null; + stream?: PdfJsonStream | null; +} + +export interface PdfJsonFont { + id?: string; + pageNumber?: number | null; + uid?: string | null; + baseName?: string | null; + subtype?: string | null; + encoding?: string | null; + cidSystemInfo?: PdfJsonFontCidSystemInfo | null; + embedded?: boolean | null; + program?: string | null; + programFormat?: string | null; + webProgram?: string | null; + webProgramFormat?: string | null; + pdfProgram?: string | null; + pdfProgramFormat?: string | null; + toUnicode?: string | null; + standard14Name?: string | null; + fontDescriptorFlags?: number | null; + ascent?: number | null; + descent?: number | null; + capHeight?: number | null; + xHeight?: number | null; + italicAngle?: number | null; + unitsPerEm?: number | null; + cosDictionary?: PdfJsonCosValue | null; +} + +export interface PdfJsonTextElement { + text?: string | null; + fontId?: string | null; + fontSize?: number | null; + fontMatrixSize?: number | null; + fontSizeInPt?: number | null; + characterSpacing?: number | null; + wordSpacing?: number | null; + spaceWidth?: number | null; + zOrder?: number | null; + horizontalScaling?: number | null; + leading?: number | null; + rise?: number | null; + renderingMode?: number | null; + x?: number | null; + y?: number | null; + width?: number | null; + height?: number | null; + textMatrix?: number[] | null; + fillColor?: PdfJsonTextColor | null; + strokeColor?: PdfJsonTextColor | null; + charCodes?: number[] | null; + fallbackUsed?: boolean | null; +} + +export interface PdfJsonImageElement { + id?: string | null; + objectName?: string | null; + inlineImage?: boolean | null; + nativeWidth?: number | null; + nativeHeight?: number | null; + x?: number | null; + y?: number | null; + width?: number | null; + height?: number | null; + left?: number | null; + right?: number | null; + top?: number | null; + bottom?: number | null; + transform?: number[] | null; + zOrder?: number | null; + imageData?: string | null; + imageFormat?: string | null; +} + +export interface PdfJsonStream { + dictionary?: Record | null; + rawData?: string | null; +} + +export interface PdfJsonPage { + pageNumber?: number | null; + width?: number | null; + height?: number | null; + rotation?: number | null; + mediaBox?: number[] | null; + cropBox?: number[] | null; + textElements?: PdfJsonTextElement[] | null; + imageElements?: PdfJsonImageElement[] | null; + resources?: unknown; + contentStreams?: PdfJsonStream[] | null; +} + +export interface PdfJsonMetadata { + title?: string | null; + author?: string | null; + subject?: string | null; + keywords?: string | null; + creator?: string | null; + producer?: string | null; + creationDate?: string | null; + modificationDate?: string | null; + trapped?: string | null; + numberOfPages?: number | null; +} + +export interface PdfJsonDocument { + metadata?: PdfJsonMetadata | null; + xmpMetadata?: string | null; + fonts?: PdfJsonFont[] | null; + pages?: PdfJsonPage[] | null; + lazyImages?: boolean | null; +} + +export interface PdfJsonPageDimension { + pageNumber?: number | null; + width?: number | null; + height?: number | null; + rotation?: number | null; +} + +export interface PdfJsonDocumentMetadata { + metadata?: PdfJsonMetadata | null; + xmpMetadata?: string | null; + fonts?: PdfJsonFont[] | null; + pageDimensions?: PdfJsonPageDimension[] | null; + formFields?: unknown[] | null; + lazyImages?: boolean | null; +} + +export interface BoundingBox { + left: number; + right: number; + top: number; + bottom: number; +} + +export interface TextGroup { + id: string; + pageIndex: number; + fontId?: string | null; + fontSize?: number | null; + fontMatrixSize?: number | null; + lineSpacing?: number | null; + lineElementCounts?: number[] | null; + color?: string | null; + fontWeight?: number | 'normal' | 'bold' | null; + rotation?: number | null; + anchor?: { x: number; y: number } | null; + baselineLength?: number | null; + baseline?: number | null; + elements: PdfJsonTextElement[]; + originalElements: PdfJsonTextElement[]; + text: string; + originalText: string; + bounds: BoundingBox; + childLineGroups?: TextGroup[] | null; +} + +export const DEFAULT_PAGE_WIDTH = 612; +export const DEFAULT_PAGE_HEIGHT = 792; + +export interface ConversionProgress { + percent: number; + stage: string; + message: string; + current?: number; + total?: number; +} + +export interface PdfTextEditorViewData { + document: PdfJsonDocument | null; + groupsByPage: TextGroup[][]; + imagesByPage: PdfJsonImageElement[][]; + pagePreviews: Map; + selectedPage: number; + dirtyPages: boolean[]; + hasDocument: boolean; + hasVectorPreview: boolean; + fileName: string; + errorMessage: string | null; + isGeneratingPdf: boolean; + isConverting: boolean; + conversionProgress: ConversionProgress | null; + hasChanges: boolean; + forceSingleTextElement: boolean; + groupingMode: 'auto' | 'paragraph' | 'singleLine'; + requestPagePreview: (pageIndex: number, scale: number) => void; + onSelectPage: (pageIndex: number) => void; + onGroupEdit: (pageIndex: number, groupId: string, value: string) => void; + onGroupDelete: (pageIndex: number, groupId: string) => void; + onImageTransform: ( + pageIndex: number, + imageId: string, + next: { + left: number; + bottom: number; + width: number; + height: number; + transform: number[]; + }, + ) => void; + onImageReset: (pageIndex: number, imageId: string) => void; + onReset: () => void; + onDownloadJson: () => void; + onGeneratePdf: () => void; + onGeneratePdfForNavigation: () => Promise; + onForceSingleTextElementChange: (value: boolean) => void; + onGroupingModeChange: (value: 'auto' | 'paragraph' | 'singleLine') => void; + onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean; + onUngroupGroup: (pageIndex: number, groupId: string) => boolean; +} diff --git a/frontend/src/proprietary/tools/pdfTextEditor/pdfTextEditorUtils.ts b/frontend/src/proprietary/tools/pdfTextEditor/pdfTextEditorUtils.ts new file mode 100644 index 0000000000..bfc2e112f7 --- /dev/null +++ b/frontend/src/proprietary/tools/pdfTextEditor/pdfTextEditorUtils.ts @@ -0,0 +1,1399 @@ +import { + BoundingBox, + PdfJsonDocument, + PdfJsonPage, + PdfJsonTextElement, + PdfJsonImageElement, + TextGroup, + DEFAULT_PAGE_HEIGHT, + DEFAULT_PAGE_WIDTH, +} from '@app/tools/pdfTextEditor/pdfTextEditorTypes'; + +const LINE_TOLERANCE = 2; +const GAP_FACTOR = 0.6; +const SPACE_MIN_GAP = 1.5; +const MIN_CHAR_WIDTH_FACTOR = 0.35; +const MAX_CHAR_WIDTH_FACTOR = 1.25; +const EXTRA_GAP_RATIO = 0.8; + +type FontMetrics = { + unitsPerEm: number; + ascent: number; + descent: number; +}; + +type FontMetricsMap = Map; + +const sanitizeParagraphText = (text: string | undefined | null): string => { + if (!text) { + return ''; + } + return text.replace(/\r?\n/g, ''); +}; + +const splitParagraphIntoLines = (text: string | undefined | null): string[] => { + if (text === null || text === undefined) { + return ['']; + } + return text.replace(/\r/g, '').split('\n'); +}; + +const extractElementBaseline = (element: PdfJsonTextElement): number | null => { + if (!element) { + return null; + } + if (element.textMatrix && element.textMatrix.length >= 6) { + const baseline = element.textMatrix[5]; + return typeof baseline === 'number' ? baseline : null; + } + if (typeof element.y === 'number') { + return element.y; + } + return null; +}; + +const shiftElementsBy = (elements: PdfJsonTextElement[], delta: number): PdfJsonTextElement[] => { + if (delta === 0) { + return elements.map(cloneTextElement); + } + return elements.map((element) => { + const clone = cloneTextElement(element); + if (clone.textMatrix && clone.textMatrix.length >= 6) { + const matrix = [...clone.textMatrix]; + matrix[5] = (matrix[5] ?? 0) + delta; + clone.textMatrix = matrix; + } + if (typeof clone.y === 'number') { + clone.y += delta; + } else if (clone.y === null || clone.y === undefined) { + clone.y = delta; + } + return clone; + }); +}; + +const countGraphemes = (text: string): number => { + if (!text) { + return 0; + } + return Array.from(text).length; +}; + +const metricsFor = (metrics: FontMetricsMap | undefined, fontId?: string | null): FontMetrics | undefined => { + if (!metrics || !fontId) { + return undefined; + } + return metrics.get(fontId) ?? undefined; +}; + +const buildFontMetrics = (document: PdfJsonDocument | null | undefined): FontMetricsMap => { + const metrics: FontMetricsMap = new Map(); + document?.fonts?.forEach((font) => { + if (!font) { + return; + } + const unitsPerEm = font.unitsPerEm && font.unitsPerEm > 0 ? font.unitsPerEm : 1000; + const ascent = font.ascent ?? unitsPerEm * 0.8; + const descent = font.descent ?? -(unitsPerEm * 0.2); + const metric: FontMetrics = { unitsPerEm, ascent, descent }; + if (font.id) { + metrics.set(font.id, metric); + } + if (font.uid) { + metrics.set(font.uid, metric); + } + }); + return metrics; +}; + +export const valueOr = (value: number | null | undefined, fallback = 0): number => { + if (value === null || value === undefined || Number.isNaN(value)) { + return fallback; + } + return value; +}; + +export const cloneTextElement = (element: PdfJsonTextElement): PdfJsonTextElement => ({ + ...element, + textMatrix: element.textMatrix ? [...element.textMatrix] : element.textMatrix ?? undefined, +}); + +const clearGlyphHints = (element: PdfJsonTextElement): void => { + if (!element) { + return; + } + element.charCodes = undefined; +}; + +export const cloneImageElement = (element: PdfJsonImageElement): PdfJsonImageElement => ({ + ...element, + transform: element.transform ? [...element.transform] : element.transform ?? undefined, +}); + +const getBaseline = (element: PdfJsonTextElement): number => { + if (element.textMatrix && element.textMatrix.length === 6) { + return valueOr(element.textMatrix[5]); + } + return valueOr(element.y); +}; + +const getX = (element: PdfJsonTextElement): number => { + if (element.textMatrix && element.textMatrix.length === 6) { + return valueOr(element.textMatrix[4]); + } + return valueOr(element.x); +}; + +const getWidth = (element: PdfJsonTextElement, metrics?: FontMetricsMap): number => { + const width = valueOr(element.width, 0); + if (width > 0) { + return width; + } + + const text = element.text ?? ''; + const glyphCount = Math.max(1, countGraphemes(text)); + const spacingFallback = Math.max( + valueOr(element.spaceWidth, 0), + valueOr(element.wordSpacing, 0), + valueOr(element.characterSpacing, 0), + ); + + if (spacingFallback > 0 && text.trim().length === 0) { + return spacingFallback; + } + + const fontSize = getFontSize(element); + const fontMetrics = metricsFor(metrics, element.fontId); + if (fontMetrics) { + const unitsPerEm = fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000; + const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8; + const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2)); + const combinedUnits = Math.max(unitsPerEm * 0.8, ascentUnits + descentUnits); + const averageAdvanceUnits = Math.max(unitsPerEm * 0.5, combinedUnits / Math.max(1, glyphCount)); + const fallbackWidth = (averageAdvanceUnits / unitsPerEm) * glyphCount * fontSize; + if (fallbackWidth > 0) { + return fallbackWidth; + } + } + + return fontSize * glyphCount * 0.5; +}; + +const getFontSize = (element: PdfJsonTextElement): number => valueOr(element.fontMatrixSize ?? element.fontSize, 12); + +const getHeight = (element: PdfJsonTextElement, metrics?: FontMetricsMap): number => { + const height = valueOr(element.height, 0); + if (height > 0) { + return height; + } + const fontSize = getFontSize(element); + const fontMetrics = metricsFor(metrics, element.fontId); + if (fontMetrics) { + const unitsPerEm = fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000; + const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8; + const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2)); + const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits); + if (totalUnits > 0) { + return (totalUnits / unitsPerEm) * fontSize; + } + } + return fontSize; +}; + +const getElementBounds = ( + element: PdfJsonTextElement, + metrics?: FontMetricsMap, +): BoundingBox => { + const left = getX(element); + const width = getWidth(element, metrics); + const baseline = getBaseline(element); + const height = getHeight(element, metrics); + + let ascentRatio = 0.8; + let descentRatio = 0.2; + const fontMetrics = metricsFor(metrics, element.fontId); + if (fontMetrics) { + const unitsPerEm = fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000; + const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8; + const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2)); + const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits); + if (totalUnits > 0) { + ascentRatio = ascentUnits / totalUnits; + descentRatio = descentUnits / totalUnits; + } + } + + const bottom = baseline + height * ascentRatio; + const top = baseline - height * descentRatio; + return { + left, + right: left + width, + top, + bottom, + }; +}; + +export const getImageBounds = (element: PdfJsonImageElement): BoundingBox => { + const left = valueOr(element.left ?? element.x, 0); + const computedWidth = valueOr(element.width, Math.max(valueOr(element.right, left) - left, 0)); + const right = valueOr(element.right ?? left + computedWidth, left + computedWidth); + const bottom = valueOr(element.bottom ?? element.y, 0); + const computedHeight = valueOr(element.height, Math.max(valueOr(element.top, bottom) - bottom, 0)); + const top = valueOr(element.top ?? bottom + computedHeight, bottom + computedHeight); + return { + left, + right, + bottom, + top, + }; +}; + +const getSpacingHint = (element: PdfJsonTextElement): number => { + const spaceWidth = valueOr(element.spaceWidth, 0); + if (spaceWidth > 0) { + return spaceWidth; + } + const wordSpacing = valueOr(element.wordSpacing, 0); + if (wordSpacing > 0) { + return wordSpacing; + } + const characterSpacing = valueOr(element.characterSpacing, 0); + return Math.max(characterSpacing, 0); +}; + +const estimateCharWidth = ( + element: PdfJsonTextElement, + avgFontSize: number, + metrics?: FontMetricsMap, +): number => { + const rawWidth = getWidth(element, metrics); + const minWidth = avgFontSize * MIN_CHAR_WIDTH_FACTOR; + const maxWidth = avgFontSize * MAX_CHAR_WIDTH_FACTOR; + return Math.min(Math.max(rawWidth, minWidth), maxWidth); +}; + +const mergeBounds = (bounds: BoundingBox[]): BoundingBox => { + if (bounds.length === 0) { + return { left: 0, right: 0, top: 0, bottom: 0 }; + } + return bounds.reduce( + (acc, current) => ({ + left: Math.min(acc.left, current.left), + right: Math.max(acc.right, current.right), + top: Math.min(acc.top, current.top), + bottom: Math.max(acc.bottom, current.bottom), + }), + { ...bounds[0] } + ); +}; + +const shouldInsertSpace = ( + prev: PdfJsonTextElement, + current: PdfJsonTextElement, + metrics?: FontMetricsMap, +): boolean => { + const prevRight = getX(prev) + getWidth(prev, metrics); + const trailingGap = Math.max(0, getX(current) - prevRight); + const avgFontSize = (getFontSize(prev) + getFontSize(current)) / 2; + const baselineAdvance = Math.max(0, getX(current) - getX(prev)); + const charWidthEstimate = estimateCharWidth(prev, avgFontSize, metrics); + const inferredGap = Math.max(0, baselineAdvance - charWidthEstimate); + const spacingHint = Math.max( + SPACE_MIN_GAP, + getSpacingHint(prev), + getSpacingHint(current), + avgFontSize * GAP_FACTOR, + ); + + if (trailingGap > spacingHint) { + return true; + } + + if (inferredGap > spacingHint * EXTRA_GAP_RATIO) { + return true; + } + + const prevText = (prev.text ?? '').trimEnd(); + if (prevText.endsWith('-')) { + return false; + } + + return false; +}; + +const buildGroupText = (elements: PdfJsonTextElement[], metrics?: FontMetricsMap): string => { + let result = ''; + elements.forEach((element, index) => { + const value = element.text ?? ''; + if (index === 0) { + result += value; + return; + } + + const previous = elements[index - 1]; + const needsSpace = shouldInsertSpace(previous, element, metrics); + const startsWithWhitespace = /^\s/u.test(value); + + if (needsSpace && !startsWithWhitespace) { + result += ' '; + } + result += value; + }); + return result; +}; + +const rgbToCss = (components: number[]): string => { + if (components.length >= 3) { + const r = Math.round(Math.max(0, Math.min(1, components[0])) * 255); + const g = Math.round(Math.max(0, Math.min(1, components[1])) * 255); + const b = Math.round(Math.max(0, Math.min(1, components[2])) * 255); + return `rgb(${r}, ${g}, ${b})`; + } + return 'rgb(0, 0, 0)'; +}; + +const cmykToCss = (components: number[]): string => { + if (components.length >= 4) { + const c = Math.max(0, Math.min(1, components[0])); + const m = Math.max(0, Math.min(1, components[1])); + const y = Math.max(0, Math.min(1, components[2])); + const k = Math.max(0, Math.min(1, components[3])); + const r = Math.round(255 * (1 - c) * (1 - k)); + const g = Math.round(255 * (1 - m) * (1 - k)); + const b = Math.round(255 * (1 - y) * (1 - k)); + return `rgb(${r}, ${g}, ${b})`; + } + return 'rgb(0, 0, 0)'; +}; + +const grayToCss = (components: number[]): string => { + if (components.length >= 1) { + const gray = Math.round(Math.max(0, Math.min(1, components[0])) * 255); + return `rgb(${gray}, ${gray}, ${gray})`; + } + return 'rgb(0, 0, 0)'; +}; + +const extractColor = (element: PdfJsonTextElement): string | null => { + const fillColor = element.fillColor; + if (!fillColor || !fillColor.components || fillColor.components.length === 0) { + return null; + } + + const colorSpace = (fillColor.colorSpace ?? '').toLowerCase(); + + if (colorSpace.includes('rgb') || colorSpace.includes('srgb')) { + return rgbToCss(fillColor.components); + } + if (colorSpace.includes('cmyk')) { + return cmykToCss(fillColor.components); + } + if (colorSpace.includes('gray') || colorSpace.includes('grey')) { + return grayToCss(fillColor.components); + } + + // Default to RGB interpretation + if (fillColor.components.length >= 3) { + return rgbToCss(fillColor.components); + } + if (fillColor.components.length === 1) { + return grayToCss(fillColor.components); + } + + return null; +}; + +const RAD_TO_DEG = 180 / Math.PI; + +const normalizeAngle = (angle: number): number => { + let normalized = angle % 360; + if (normalized > 180) { + normalized -= 360; + } else if (normalized <= -180) { + normalized += 360; + } + return normalized; +}; + +const extractElementRotation = (element: PdfJsonTextElement): number | null => { + const matrix = element.textMatrix; + if (!matrix || matrix.length !== 6) { + return null; + } + const a = matrix[0]; + const b = matrix[1]; + if (Math.abs(a) < 1e-6 && Math.abs(b) < 1e-6) { + return null; + } + const angle = Math.atan2(b, a) * RAD_TO_DEG; + if (Math.abs(angle) < 0.5) { + return null; + } + return normalizeAngle(angle); +}; + +const computeGroupRotation = (elements: PdfJsonTextElement[]): number | null => { + const angles = elements + .map(extractElementRotation) + .filter((angle): angle is number => angle !== null); + if (angles.length === 0) { + return null; + } + const vector = angles.reduce( + (acc, angle) => { + const radians = (angle * Math.PI) / 180; + acc.x += Math.cos(radians); + acc.y += Math.sin(radians); + return acc; + }, + { x: 0, y: 0 }, + ); + if (Math.abs(vector.x) < 1e-6 && Math.abs(vector.y) < 1e-6) { + return null; + } + const average = Math.atan2(vector.y, vector.x) * RAD_TO_DEG; + const normalized = normalizeAngle(average); + return Math.abs(normalized) < 0.5 ? null : normalized; +}; + +const getAnchorPoint = (element: PdfJsonTextElement): { x: number; y: number } => { + if (element.textMatrix && element.textMatrix.length === 6) { + return { + x: valueOr(element.textMatrix[4]), + y: valueOr(element.textMatrix[5]), + }; + } + return { + x: valueOr(element.x), + y: valueOr(element.y), + }; +}; + +const computeBaselineLength = ( + elements: PdfJsonTextElement[], + metrics?: FontMetricsMap, +): number => elements.reduce((acc, current) => acc + getWidth(current, metrics), 0); + +const computeAverageBaseline = (elements: PdfJsonTextElement[]): number | null => { + if (elements.length === 0) { + return null; + } + let sum = 0; + elements.forEach((element) => { + sum += getBaseline(element); + }); + return sum / elements.length; +}; + +const createGroup = ( + pageIndex: number, + idSuffix: number, + elements: PdfJsonTextElement[], + metrics?: FontMetricsMap, +): TextGroup => { + const clones = elements.map(cloneTextElement); + const originalClones = clones.map(cloneTextElement); + const bounds = mergeBounds(elements.map((element) => getElementBounds(element, metrics))); + const firstElement = elements[0]; + const rotation = computeGroupRotation(elements); + const anchor = rotation !== null ? getAnchorPoint(firstElement) : null; + const baselineLength = computeBaselineLength(elements, metrics); + const baseline = computeAverageBaseline(elements); + + return { + id: `${pageIndex}-${idSuffix}`, + pageIndex, + fontId: firstElement?.fontId, + fontSize: firstElement?.fontSize, + fontMatrixSize: firstElement?.fontMatrixSize, + color: firstElement ? extractColor(firstElement) : null, + fontWeight: null, // Will be determined from font descriptor + rotation, + anchor, + baselineLength, + baseline, + elements: clones, + originalElements: originalClones, + text: buildGroupText(elements, metrics), + originalText: buildGroupText(elements, metrics), + bounds, + }; +}; + +const cloneLineTemplate = (line: TextGroup): TextGroup => ({ + ...line, + childLineGroups: null, + lineElementCounts: null, + lineSpacing: null, + elements: line.elements.map(cloneTextElement), + originalElements: line.originalElements.map(cloneTextElement), +}); + +const groupLinesIntoParagraphs = ( + lineGroups: TextGroup[], + pageWidth: number, + metrics?: FontMetricsMap, +): TextGroup[] => { + if (lineGroups.length === 0) { + return []; + } + + const paragraphs: TextGroup[][] = []; + let currentParagraph: TextGroup[] = [lineGroups[0]]; + const bulletFlags = new Map(); + bulletFlags.set(lineGroups[0].id, false); + + for (let i = 1; i < lineGroups.length; i++) { + const prevLine = lineGroups[i - 1]; + const currentLine = lineGroups[i]; + + // Calculate line spacing + const prevBaseline = prevLine.baseline ?? 0; + const currentBaseline = currentLine.baseline ?? 0; + const lineSpacing = Math.abs(prevBaseline - currentBaseline); + + // Calculate average font size + const prevFontSize = prevLine.fontSize ?? 12; + const currentFontSize = currentLine.fontSize ?? 12; + const avgFontSize = (prevFontSize + currentFontSize) / 2; + + // Check horizontal alignment (left edge) + const prevLeft = prevLine.bounds.left; + const currentLeft = currentLine.bounds.left; + const leftAlignmentTolerance = avgFontSize * 0.3; + const isLeftAligned = Math.abs(prevLeft - currentLeft) <= leftAlignmentTolerance; + + // Check if fonts match + const sameFont = prevLine.fontId === currentLine.fontId; + + // Check for consistent spacing rather than expected spacing + // Line spacing in PDFs can range from 1.0x to 3.0x font size + // We just want to ensure spacing is consistent between consecutive lines + // and not excessively large (which would indicate a paragraph break) + const maxReasonableSpacing = avgFontSize * 3.0; // Max ~3x font size for normal line spacing + const hasReasonableSpacing = lineSpacing <= maxReasonableSpacing; + + // Check if current line looks like a bullet/list item + const prevRight = prevLine.bounds.right; + const currentRight = currentLine.bounds.right; + const prevWidth = prevRight - prevLeft; + const currentWidth = currentRight - currentLeft; + + // Count word count to help identify bullets (typically short) + const prevWords = (prevLine.text ?? '').split(/\s+/).filter(w => w.length > 0).length; + const currentWords = (currentLine.text ?? '').split(/\s+/).filter(w => w.length > 0).length; + const prevText = (prevLine.text ?? '').trim(); + const currentText = (currentLine.text ?? '').trim(); + + // Bullet detection - look for bullet markers or very short lines + const bulletMarkerRegex = /^[\u2022\u2023\u25E6\u2043\u2219•·◦‣⁃\-*]\s|^\d+[.)]\s|^[a-z][.)]\s/i; + const prevHasBulletMarker = bulletMarkerRegex.test(prevText); + const currentHasBulletMarker = bulletMarkerRegex.test(currentText); + + // True bullets are: + // 1. Have bullet markers/numbers OR + // 2. Very short (< 10 words) AND much narrower than average (< 60% of page width) + const headingKeywords = ['action items', 'next steps', 'notes', 'logistics', 'tasks']; + const normalizedPageWidth = pageWidth > 0 ? pageWidth : avgFontSize * 70; + const maxReferenceWidth = normalizedPageWidth > 0 ? normalizedPageWidth : avgFontSize * 70; + const indentDelta = currentLeft - prevLeft; + const indentThreshold = Math.max(avgFontSize * 0.6, 8); + const hasIndent = indentDelta > indentThreshold; + const currentWidthRatio = maxReferenceWidth > 0 ? currentWidth / maxReferenceWidth : 0; + const prevWidthRatio = maxReferenceWidth > 0 ? prevWidth / maxReferenceWidth : 0; + const prevLooksLikeHeading = + prevText.endsWith(':') || + (prevWords <= 4 && prevWidthRatio < 0.4) || + headingKeywords.some((keyword) => prevText.toLowerCase().includes(keyword)); + + const wrapCandidate = + !currentHasBulletMarker && + !hasIndent && + !prevLooksLikeHeading && + currentWords <= 12 && + currentWidthRatio < 0.45 && + Math.abs(prevLeft - currentLeft) <= leftAlignmentTolerance && + currentWidth < prevWidth * 0.85; + + const currentIsBullet = wrapCandidate + ? false + : currentHasBulletMarker || + (hasIndent && (currentWords <= 14 || currentWidthRatio <= 0.65)) || + (prevLooksLikeHeading && (currentWords <= 16 || currentWidthRatio <= 0.8 || prevWidthRatio < 0.35)) || + (currentWords <= 8 && currentWidthRatio <= 0.45 && prevWidth - currentWidth > avgFontSize * 4); + + const prevIsBullet = bulletFlags.get(prevLine.id) ?? prevHasBulletMarker; + bulletFlags.set(currentLine.id, currentIsBullet); + + // Detect paragraph→bullet transition + const likelyBulletStart = !prevIsBullet && currentIsBullet; + + // Don't merge two consecutive bullets + const bothAreBullets = prevIsBullet && currentIsBullet; + + // Merge into paragraph if: + // 1. Left aligned + // 2. Same font + // 3. Reasonable line spacing + // 4. NOT transitioning to bullets + // 5. NOT both are bullets + const shouldMerge = + isLeftAligned && + sameFont && + hasReasonableSpacing && + !likelyBulletStart && + !bothAreBullets && + !currentIsBullet; + + if (i < 10 || likelyBulletStart || bothAreBullets || !shouldMerge) { + console.log(` Line ${i}:`); + console.log(` prev: "${prevText.substring(0, 40)}" (${prevWords}w, ${prevWidth.toFixed(0)}pt, marker:${prevHasBulletMarker}, bullet:${prevIsBullet})`); + console.log(` curr: "${currentText.substring(0, 40)}" (${currentWords}w, ${currentWidth.toFixed(0)}pt, marker:${currentHasBulletMarker}, bullet:${currentIsBullet})`); + console.log(` checks: leftAlign:${isLeftAligned} (${Math.abs(prevLeft - currentLeft).toFixed(1)}pt), sameFont:${sameFont}, spacing:${hasReasonableSpacing} (${lineSpacing.toFixed(1)}pt/${maxReasonableSpacing.toFixed(1)}pt)`); + console.log(` decision: merge=${shouldMerge} (bulletStart:${likelyBulletStart}, bothBullets:${bothAreBullets})`); + } + + if (shouldMerge) { + currentParagraph.push(currentLine); + } else { + paragraphs.push(currentParagraph); + currentParagraph = [currentLine]; + } + } + + // Don't forget the last paragraph + if (currentParagraph.length > 0) { + paragraphs.push(currentParagraph); + } + + // Merge line groups into single paragraph groups + return paragraphs.map((lines, _paragraphIndex) => { + if (lines.length === 1) { + return lines[0]; + } + + // Combine all elements from all lines + const lineTemplates = lines.map(line => cloneLineTemplate(line)); + const flattenedLineTemplates = lineTemplates.flatMap((line) => + line.childLineGroups && line.childLineGroups.length > 0 + ? line.childLineGroups + : [line], + ); + const allLines = flattenedLineTemplates.length > 0 ? flattenedLineTemplates : lineTemplates; + const allElements = allLines.flatMap(line => line.originalElements); + const pageIndex = lines[0].pageIndex; + const lineElementCounts = allLines.map((line) => line.originalElements.length); + + // Create merged group with newlines between lines + const paragraphText = allLines.map(line => line.text).join('\n'); + const mergedBounds = mergeBounds(allLines.map(line => line.bounds)); + const spacingValues: number[] = []; + for (let i = 1; i < allLines.length; i++) { + const prevBaseline = allLines[i - 1].baseline ?? allLines[i - 1].bounds.bottom; + const currentBaseline = allLines[i].baseline ?? allLines[i].bounds.bottom; + const spacing = Math.abs(prevBaseline - currentBaseline); + if (spacing > 0) { + spacingValues.push(spacing); + } + } + const averageSpacing = + spacingValues.length > 0 + ? spacingValues.reduce((sum, value) => sum + value, 0) / spacingValues.length + : null; + + const firstElement = allElements[0]; + const rotation = computeGroupRotation(allElements); + const anchor = rotation !== null ? getAnchorPoint(firstElement) : null; + const baselineLength = computeBaselineLength(allElements, metrics); + const baseline = computeAverageBaseline(allElements); + + return { + id: lines[0].id, // Keep the first line's ID + pageIndex, + fontId: firstElement?.fontId, + fontSize: firstElement?.fontSize, + fontMatrixSize: firstElement?.fontMatrixSize, + lineSpacing: averageSpacing, + lineElementCounts: lines.length > 1 ? lineElementCounts : null, + color: firstElement ? extractColor(firstElement) : null, + fontWeight: null, + rotation, + anchor, + baselineLength, + baseline, + elements: allElements.map(cloneTextElement), + originalElements: allElements.map(cloneTextElement), + text: paragraphText, + originalText: paragraphText, + bounds: mergedBounds, + childLineGroups: allLines, + }; + }); +}; + +export const groupPageTextElements = ( + page: PdfJsonPage | null | undefined, + pageIndex: number, + metrics?: FontMetricsMap, + groupingMode: 'auto' | 'paragraph' | 'singleLine' = 'auto', +): TextGroup[] => { + if (!page?.textElements || page.textElements.length === 0) { + return []; + } + + const pageWidth = valueOr(page.width, DEFAULT_PAGE_WIDTH); + + const elements = page.textElements + .map(cloneTextElement) + .filter((element) => element.text !== null && element.text !== undefined); + + elements.sort((a, b) => getBaseline(b) - getBaseline(a)); + + const lines: { baseline: number; elements: PdfJsonTextElement[] }[] = []; + + elements.forEach((element) => { + const baseline = getBaseline(element); + const fontSize = getFontSize(element); + const tolerance = Math.max(LINE_TOLERANCE, fontSize * 0.12); + + const existingLine = lines.find((line) => Math.abs(line.baseline - baseline) <= tolerance); + + if (existingLine) { + existingLine.elements.push(element); + } else { + lines.push({ baseline, elements: [element] }); + } + }); + + lines.forEach((line) => { + line.elements.sort((a, b) => getX(a) - getX(b)); + }); + + let groupCounter = 0; + const lineGroups: TextGroup[] = []; + + lines.forEach((line) => { + let currentBucket: PdfJsonTextElement[] = []; + + line.elements.forEach((element) => { + if (currentBucket.length === 0) { + currentBucket.push(element); + return; + } + + const previous = currentBucket[currentBucket.length - 1]; + const gap = getX(element) - (getX(previous) + getWidth(previous, metrics)); + const avgFontSize = (getFontSize(previous) + getFontSize(element)) / 2; + const splitThreshold = Math.max(SPACE_MIN_GAP, avgFontSize * GAP_FACTOR); + + const sameFont = previous.fontId === element.fontId; + let shouldSplit = gap > splitThreshold * (sameFont ? 1.4 : 1.0); + + if (shouldSplit) { + const prevBaseline = getBaseline(previous); + const currentBaseline = getBaseline(element); + const baselineDelta = Math.abs(prevBaseline - currentBaseline); + const prevEndX = getX(previous) + getWidth(previous, metrics); + const _prevEndY = prevBaseline; + const diagonalGap = Math.hypot(Math.max(0, getX(element) - prevEndX), baselineDelta); + const diagonalThreshold = Math.max(avgFontSize * 0.8, splitThreshold); + if (diagonalGap <= diagonalThreshold) { + shouldSplit = false; + } + } + + const previousRotation = extractElementRotation(previous); + const currentRotation = extractElementRotation(element); + if ( + shouldSplit && + previousRotation !== null && + currentRotation !== null && + Math.abs(normalizeAngle(previousRotation - currentRotation)) < 1 + ) { + shouldSplit = false; + } + + if (shouldSplit) { + lineGroups.push(createGroup(pageIndex, groupCounter, currentBucket, metrics)); + groupCounter += 1; + currentBucket = [element]; + } else { + currentBucket.push(element); + } + }); + + if (currentBucket.length > 0) { + lineGroups.push(createGroup(pageIndex, groupCounter, currentBucket, metrics)); + groupCounter += 1; + } + }); + + // Apply paragraph grouping based on mode + if (groupingMode === 'singleLine') { + // Single line mode: skip paragraph grouping + return lineGroups; + } + + if (groupingMode === 'paragraph') { + // Paragraph mode: always apply grouping + return groupLinesIntoParagraphs(lineGroups, pageWidth, metrics); + } + + // Auto mode: use heuristic to determine if we should group + // Analyze the page content to decide + let multiLineGroups = 0; + let totalWords = 0; + let longTextGroups = 0; + let totalGroups = 0; + const wordCounts: number[] = []; + let fullWidthLines = 0; + + // Define "full width" as extending to at least 70% of page width + const fullWidthThreshold = pageWidth * 0.7; + + lineGroups.forEach((group) => { + const text = (group.text || '').trim(); + if (text.length === 0) return; + + totalGroups++; + const lines = text.split('\n'); + const lineCount = lines.length; + const wordCount = text.split(/\s+/).filter((w) => w.length > 0).length; + + totalWords += wordCount; + wordCounts.push(wordCount); + + if (lineCount > 1) { + multiLineGroups++; + } + + if (wordCount >= 10 || text.length >= 50) { + longTextGroups++; + } + + // Check if this line extends close to the right margin (paragraph-like) + const rightEdge = group.bounds.right; + if (rightEdge >= fullWidthThreshold) { + fullWidthLines++; + } + }); + + if (totalGroups === 0) { + return lineGroups; + } + + const avgWordsPerGroup = totalWords / totalGroups; + const longTextRatio = longTextGroups / totalGroups; + const fullWidthRatio = fullWidthLines / totalGroups; + + // Calculate variance in line lengths (paragraphs have varying lengths, lists are uniform) + const variance = wordCounts.reduce((sum, count) => { + const diff = count - avgWordsPerGroup; + return sum + diff * diff; + }, 0) / totalGroups; + const stdDev = Math.sqrt(variance); + const coefficientOfVariation = avgWordsPerGroup > 0 ? stdDev / avgWordsPerGroup : 0; + + // Check each criterion + const criterion1 = avgWordsPerGroup > 5; + const criterion2 = longTextRatio > 0.4; + const criterion3 = coefficientOfVariation > 0.5 || fullWidthRatio > 0.6; // High variance OR many full-width lines = paragraph text + + const isParagraphPage = criterion1 && criterion2 && criterion3; + + // Log detection stats + console.log(`📄 Page ${pageIndex} Grouping Analysis (mode: ${groupingMode}):`); + console.log(` Stats:`); + console.log(` • Page width: ${pageWidth.toFixed(1)}pt (full-width threshold: ${fullWidthThreshold.toFixed(1)}pt)`); + console.log(` • Multi-line groups: ${multiLineGroups}`); + console.log(` • Total groups: ${totalGroups}`); + console.log(` • Total words: ${totalWords}`); + console.log(` • Long text groups (≥10 words or ≥50 chars): ${longTextGroups}`); + console.log(` • Full-width lines (≥70% page width): ${fullWidthLines}`); + console.log(` • Avg words per group: ${avgWordsPerGroup.toFixed(2)}`); + console.log(` • Long text ratio: ${(longTextRatio * 100).toFixed(1)}%`); + console.log(` • Full-width ratio: ${(fullWidthRatio * 100).toFixed(1)}%`); + console.log(` • Std deviation: ${stdDev.toFixed(2)}`); + console.log(` • Coefficient of variation: ${coefficientOfVariation.toFixed(2)}`); + console.log(` Criteria:`); + console.log(` 1. Avg Words Per Group: ${criterion1 ? '✅ PASS' : '❌ FAIL'}`); + console.log(` (${avgWordsPerGroup.toFixed(2)} > 5)`); + console.log(` 2. Long Text Ratio: ${criterion2 ? '✅ PASS' : '❌ FAIL'}`); + console.log(` (${(longTextRatio * 100).toFixed(1)}% > 40%)`); + console.log(` 3. Line Width Pattern: ${criterion3 ? '✅ PASS' : '❌ FAIL'}`); + console.log(` (CV ${coefficientOfVariation.toFixed(2)} > 0.5 OR ${(fullWidthRatio * 100).toFixed(1)}% > 60%)`); + console.log(` ${coefficientOfVariation > 0.5 ? '✓ High variance (varying line lengths)' : '✗ Low variance'} ${fullWidthRatio > 0.6 ? '✓ Many full-width lines (paragraph-like)' : '✗ Few full-width lines (list-like)'}`); + console.log(` Decision: ${isParagraphPage ? '📝 PARAGRAPH MODE' : '📋 LINE MODE'}`); + if (isParagraphPage) { + console.log(` Reason: All three criteria passed (AND logic)`); + } else { + const failedReasons = []; + if (!criterion1) failedReasons.push('low average words per group'); + if (!criterion2) failedReasons.push('low ratio of long text groups'); + if (!criterion3) failedReasons.push('low variance and few full-width lines (list-like structure)'); + console.log(` Reason: ${failedReasons.join(', ')}`); + } + console.log(''); + + // Only apply paragraph grouping if it looks like a paragraph-heavy page + if (isParagraphPage) { + console.log(`🔀 Applying paragraph grouping to page ${pageIndex}`); + return groupLinesIntoParagraphs(lineGroups, pageWidth, metrics); + } + + // For sparse pages, keep lines separate + console.log(`📋 Keeping lines separate for page ${pageIndex}`); + return lineGroups; +}; + +export const groupDocumentText = ( + document: PdfJsonDocument | null | undefined, + groupingMode: 'auto' | 'paragraph' | 'singleLine' = 'auto', +): TextGroup[][] => { + const pages = document?.pages ?? []; + const metrics = buildFontMetrics(document); + return pages.map((page, index) => groupPageTextElements(page, index, metrics, groupingMode)); +}; + +export const extractPageImages = ( + page: PdfJsonPage | null | undefined, + pageIndex: number, +): PdfJsonImageElement[] => { + const images = page?.imageElements ?? []; + return images.map((image, imageIndex) => { + const clone = cloneImageElement(image); + if (!clone.id || clone.id.trim().length === 0) { + clone.id = `page-${pageIndex}-image-${imageIndex}`; + } + return clone; + }); +}; + +export const extractDocumentImages = ( + document: PdfJsonDocument | null | undefined, +): PdfJsonImageElement[][] => { + const pages = document?.pages ?? []; + return pages.map((page, index) => extractPageImages(page, index)); +}; + +export const deepCloneDocument = (document: PdfJsonDocument): PdfJsonDocument => { + if (typeof structuredClone === 'function') { + return structuredClone(document); + } + return JSON.parse(JSON.stringify(document)); +}; + +export const pageDimensions = (page: PdfJsonPage | null | undefined): { width: number; height: number } => { + const width = valueOr(page?.width, DEFAULT_PAGE_WIDTH); + const height = valueOr(page?.height, DEFAULT_PAGE_HEIGHT); + + console.log(`📏 [pageDimensions] Calculating page size:`, { + hasPage: !!page, + rawWidth: page?.width, + rawHeight: page?.height, + mediaBox: page?.mediaBox, + cropBox: page?.cropBox, + rotation: page?.rotation, + calculatedWidth: width, + calculatedHeight: height, + DEFAULT_PAGE_WIDTH, + DEFAULT_PAGE_HEIGHT, + commonFormats: { + 'US Letter': '612 × 792 pt', + 'A4': '595 × 842 pt', + 'Legal': '612 × 1008 pt', + }, + }); + + return { width, height }; +}; + +export const createMergedElement = (group: TextGroup): PdfJsonTextElement => { + const reference = group.originalElements[0]; + const merged = cloneTextElement(reference); + merged.text = sanitizeParagraphText(group.text); + clearGlyphHints(merged); + if (reference.textMatrix && reference.textMatrix.length === 6) { + merged.textMatrix = [...reference.textMatrix]; + } + return merged; +}; + +const distributeTextAcrossElements = (text: string | undefined, elements: PdfJsonTextElement[]): boolean => { + if (elements.length === 0) { + return true; + } + + const normalizedText = sanitizeParagraphText(text); + const targetChars = Array.from(normalizedText); + if (targetChars.length === 0) { + elements.forEach((element) => { + element.text = ''; + clearGlyphHints(element); + }); + return true; + } + + const capacities = elements.map((element) => { + const originalText = element.text ?? ''; + const graphemeCount = Array.from(originalText).length; + return graphemeCount > 0 ? graphemeCount : 1; + }); + + let cursor = 0; + elements.forEach((element, index) => { + const remaining = targetChars.length - cursor; + let sliceLength = 0; + if (remaining > 0) { + if (index === elements.length - 1) { + sliceLength = remaining; + } else { + const capacity = Math.max(capacities[index], 1); + const minRemainingForRest = Math.max(elements.length - index - 1, 0); + sliceLength = Math.min(capacity, Math.max(remaining - minRemainingForRest, 1)); + } + } + + element.text = sliceLength > 0 ? targetChars.slice(cursor, cursor + sliceLength).join('') : ''; + clearGlyphHints(element); + cursor += sliceLength; + }); + + elements.forEach((element) => { + if (element.text == null) { + element.text = ''; + } + }); + + return true; +}; + +const sliceElementsByLineCounts = (group: TextGroup): PdfJsonTextElement[][] => { + const counts = group.lineElementCounts; + if (!counts || counts.length === 0) { + if (!group.originalElements.length) { + return []; + } + return [group.originalElements]; + } + + const result: PdfJsonTextElement[][] = []; + let cursor = 0; + counts.forEach((count) => { + if (count <= 0) { + return; + } + const slice = group.originalElements.slice(cursor, cursor + count); + if (slice.length > 0) { + result.push(slice); + } + cursor += count; + }); + return result; +}; + +const rebuildParagraphLineElements = (group: TextGroup): PdfJsonTextElement[] | null => { + if (!group.text || !group.text.includes('\n')) { + return null; + } + + const lineTexts = splitParagraphIntoLines(group.text); + if (lineTexts.length === 0) { + return []; + } + + const lineElementGroups = sliceElementsByLineCounts(group); + if (!lineElementGroups.length) { + return null; + } + + const lineBaselines = lineElementGroups.map((elements) => { + for (const element of elements) { + const baseline = extractElementBaseline(element); + if (baseline !== null) { + return baseline; + } + } + return group.baseline ?? null; + }); + + const spacingFromBaselines = (() => { + for (let i = 1; i < lineBaselines.length; i += 1) { + const prev = lineBaselines[i - 1]; + const current = lineBaselines[i]; + if (prev !== null && current !== null) { + const diff = Math.abs(prev - current); + if (diff > 0) { + return diff; + } + } + } + return null; + })(); + + const spacing = + (group.lineSpacing && group.lineSpacing > 0 + ? group.lineSpacing + : spacingFromBaselines) ?? + Math.max(group.fontMatrixSize ?? group.fontSize ?? 12, 6) * 1.2; + + let direction = -1; + for (let i = 1; i < lineBaselines.length; i += 1) { + const prev = lineBaselines[i - 1]; + const current = lineBaselines[i]; + if (prev !== null && current !== null && Math.abs(prev - current) > 0.05) { + direction = current < prev ? -1 : 1; + break; + } + } + + const templateCount = lineElementGroups.length; + const lastTemplateIndex = Math.max(templateCount - 1, 0); + const rebuilt: PdfJsonTextElement[] = []; + + for (let index = 0; index < lineTexts.length; index += 1) { + const templateIndex = Math.min(index, lastTemplateIndex); + const templateElements = lineElementGroups[templateIndex]; + if (!templateElements || templateElements.length === 0) { + return null; + } + + const shiftSteps = index - templateIndex; + const delta = shiftSteps * spacing * direction; + const clones = shiftElementsBy(templateElements, delta); + const normalizedLine = sanitizeParagraphText(lineTexts[index]); + const distributed = distributeTextAcrossElements(normalizedLine, clones); + + if (!distributed) { + const primary = clones[0]; + primary.text = normalizedLine; + clearGlyphHints(primary); + for (let i = 1; i < clones.length; i += 1) { + clones[i].text = ''; + clearGlyphHints(clones[i]); + } + } + + rebuilt.push(...clones); + } + + return rebuilt; +}; + +export const buildUpdatedDocument = ( + source: PdfJsonDocument, + groupsByPage: TextGroup[][], + imagesByPage: PdfJsonImageElement[][], +): PdfJsonDocument => { + const updated = deepCloneDocument(source); + const pages = updated.pages ?? []; + + updated.pages = pages.map((page, pageIndex) => { + const groups = groupsByPage[pageIndex] ?? []; + const images = imagesByPage[pageIndex] ?? []; + if (!groups.length) { + return { + ...page, + imageElements: images.map(cloneImageElement), + }; + } + + const updatedElements: PdfJsonTextElement[] = groups.flatMap((group) => { + if (group.text === group.originalText) { + return group.originalElements.map(cloneTextElement); + } + return [createMergedElement(group)]; + }); + + return { + ...page, + textElements: updatedElements, + imageElements: images.map(cloneImageElement), + contentStreams: page.contentStreams ?? [], + }; + }); + + return updated; +}; + +export const restoreGlyphElements = ( + source: PdfJsonDocument, + groupsByPage: TextGroup[][], + imagesByPage: PdfJsonImageElement[][], + originalImagesByPage: PdfJsonImageElement[][], + forceMergedGroups: boolean = false, +): PdfJsonDocument => { + const updated = deepCloneDocument(source); + const pages = updated.pages ?? []; + + updated.pages = pages.map((page, pageIndex) => { + const groups = groupsByPage[pageIndex] ?? []; + const images = imagesByPage[pageIndex] ?? []; + const _baselineImages = originalImagesByPage[pageIndex] ?? []; + + if (!groups.length) { + return { + ...page, + imageElements: images.map(cloneImageElement), + }; + } + + const rebuiltElements: PdfJsonTextElement[] = []; + + groups.forEach((group) => { + if (group.text !== group.originalText) { + // Always try to rebuild paragraph lines if text has newlines + const paragraphElements = rebuildParagraphLineElements(group); + if (paragraphElements && paragraphElements.length > 0) { + rebuiltElements.push(...paragraphElements); + return; + } + // If no newlines or rebuilding failed, check if we should force merge + if (forceMergedGroups) { + rebuiltElements.push(createMergedElement(group)); + return; + } + const originalGlyphCount = group.originalElements.reduce( + (sum, element) => sum + countGraphemes(element.text ?? ''), + 0, + ); + const normalizedText = sanitizeParagraphText(group.text); + const targetGlyphCount = countGraphemes(normalizedText); + + if (targetGlyphCount !== originalGlyphCount) { + rebuiltElements.push(createMergedElement(group)); + return; + } + + const originals = group.originalElements.map(cloneTextElement); + const distributed = distributeTextAcrossElements(normalizedText, originals); + if (distributed) { + rebuiltElements.push(...originals); + } else { + rebuiltElements.push(createMergedElement(group)); + } + return; + } + + rebuiltElements.push(...group.originalElements.map(cloneTextElement)); + }); + + return { + ...page, + textElements: rebuiltElements, + imageElements: images.map(cloneImageElement), + contentStreams: page.contentStreams ?? [], + }; + }); + + return updated; +}; + +const approxEqual = (a: number | null | undefined, b: number | null | undefined, tolerance = 0.25): boolean => { + const first = typeof a === 'number' && Number.isFinite(a) ? a : 0; + const second = typeof b === 'number' && Number.isFinite(b) ? b : 0; + return Math.abs(first - second) <= tolerance; +}; + +const arrayApproxEqual = ( + first: number[] | null | undefined, + second: number[] | null | undefined, + tolerance = 0.25, +): boolean => { + if (!first && !second) { + return true; + } + if (!first || !second) { + return false; + } + if (first.length !== second.length) { + return false; + } + for (let index = 0; index < first.length; index += 1) { + if (!approxEqual(first[index], second[index], tolerance)) { + return false; + } + } + return true; +}; + +const areImageElementsEqual = ( + current: PdfJsonImageElement, + original: PdfJsonImageElement, +): boolean => { + if (current === original) { + return true; + } + if (!current || !original) { + return false; + } + + const sameData = (current.imageData ?? null) === (original.imageData ?? null); + const sameFormat = (current.imageFormat ?? null) === (original.imageFormat ?? null); + + return ( + sameData && + sameFormat && + approxEqual(current.x, original.x) && + approxEqual(current.y, original.y) && + approxEqual(current.width, original.width) && + approxEqual(current.height, original.height) && + approxEqual(current.left, original.left) && + approxEqual(current.right, original.right) && + approxEqual(current.top, original.top) && + approxEqual(current.bottom, original.bottom) && + (current.zOrder ?? null) === (original.zOrder ?? null) && + arrayApproxEqual(current.transform, original.transform) + ); +}; + +export const areImageListsDifferent = ( + current: PdfJsonImageElement[], + original: PdfJsonImageElement[], +): boolean => { + if (current.length !== original.length) { + return true; + } + for (let index = 0; index < current.length; index += 1) { + if (!areImageElementsEqual(current[index], original[index])) { + return true; + } + } + return false; +}; + +export const getDirtyPages = ( + groupsByPage: TextGroup[][], + imagesByPage: PdfJsonImageElement[][], + originalGroupsByPage: TextGroup[][], + originalImagesByPage: PdfJsonImageElement[][], +): boolean[] => { + return groupsByPage.map((groups, index) => { + // Check if any text was modified + const textDirty = groups.some((group) => group.text !== group.originalText); + + // Check if any groups were deleted by comparing with original groups + const originalGroups = originalGroupsByPage[index] ?? []; + const groupCountChanged = groups.length !== originalGroups.length; + + const imageDirty = areImageListsDifferent( + imagesByPage[index] ?? [], + originalImagesByPage[index] ?? [], + ); + + const isDirty = textDirty || groupCountChanged || imageDirty; + + if (groupCountChanged || textDirty) { + console.log(`📄 Page ${index} dirty check:`, { + textDirty, + groupCountChanged, + originalGroupsLength: originalGroups.length, + currentGroupsLength: groups.length, + imageDirty, + isDirty, + }); + } + + return isDirty; + }); +}; diff --git a/frontend/src/proprietary/types/proprietaryToolId.ts b/frontend/src/proprietary/types/proprietaryToolId.ts index 084cffa29e..92e3548a3d 100644 --- a/frontend/src/proprietary/types/proprietaryToolId.ts +++ b/frontend/src/proprietary/types/proprietaryToolId.ts @@ -5,6 +5,7 @@ */ export const PROPRIETARY_REGULAR_TOOL_IDS = [ + 'pdfTextEditor', ] as const; export const PROPRIETARY_SUPER_TOOL_IDS = [ diff --git a/frontend/src/proprietary/utils/urlMapping.ts b/frontend/src/proprietary/utils/urlMapping.ts new file mode 100644 index 0000000000..13bc379fb9 --- /dev/null +++ b/frontend/src/proprietary/utils/urlMapping.ts @@ -0,0 +1,19 @@ +/** + * Proprietary URL mappings. + * This file overrides src/core/utils/urlMapping.ts + * to add proprietary-specific URL mappings. + */ + +import { ToolId } from '@app/types/toolId'; +import { URL_TO_TOOL_MAP as CORE_URL_TO_TOOL_MAP } from '@core/utils/urlMapping'; + +// Proprietary URL mappings +const PROPRIETARY_URL_MAPPINGS: Record = { + '/pdf-text-editor': 'pdfTextEditor', +}; + +// Merge core and proprietary mappings +export const URL_TO_TOOL_MAP: Record = { + ...CORE_URL_TO_TOOL_MAP, + ...PROPRIETARY_URL_MAPPINGS, +}; diff --git a/scripts/analyze_pdf_json.py b/scripts/analyze_pdf_json.py new file mode 100644 index 0000000000..1a9ba9b212 --- /dev/null +++ b/scripts/analyze_pdf_json.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" +Quick inspection utility for PDF→JSON exports. + +Usage: + python scripts/analyze_pdf_json.py path/to/export.json + +The script prints size and font statistics so we can confirm whether the +lightweight export (no COS dictionaries) is active and how large the font +payloads are. +""" +from __future__ import annotations + +import argparse +import base64 +import json +import math +from pathlib import Path +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Tuple + + +def human_bytes(value: float) -> str: + if value <= 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + order = min(int(math.log(value, 1024)), len(units) - 1) + scaled = value / (1024**order) + return f"{scaled:.1f} {units[order]}" + + +def base64_payload_size(encoded: str | None) -> int: + if not encoded: + return 0 + length = len(encoded.strip()) + if length == 0: + return 0 + return int(length * 0.75) + + +@dataclass +class FontBreakdown: + total: int = 0 + with_cos: int = 0 + with_program: int = 0 + with_web_program: int = 0 + with_pdf_program: int = 0 + program_bytes: int = 0 + web_program_bytes: int = 0 + pdf_program_bytes: int = 0 + metadata_bytes: int = 0 + sample_cos_ids: List[Tuple[str | None, str | None]] = None + + +@dataclass +class PageBreakdown: + page_count: int = 0 + total_text_elements: int = 0 + total_image_elements: int = 0 + text_payload_chars: int = 0 + text_struct_bytes: int = 0 + image_struct_bytes: int = 0 + resources_bytes: int = 0 + content_stream_bytes: int = 0 + annotations_bytes: int = 0 + + +@dataclass +class DocumentBreakdown: + total_bytes: int + fonts: FontBreakdown + pages: PageBreakdown + metadata_bytes: int + xmp_bytes: int + form_fields_bytes: int + lazy_flag_bytes: int + + +def approx_struct_size(obj: Any) -> int: + if obj is None: + return 0 + return len(json.dumps(obj, separators=(",", ":"))) + + +def analyze_fonts(fonts: Iterable[Dict[str, Any]]) -> FontBreakdown: + total = 0 + with_cos = 0 + with_prog = 0 + with_web_prog = 0 + with_pdf_prog = 0 + program_bytes = 0 + web_program_bytes = 0 + pdf_program_bytes = 0 + metadata_bytes = 0 + sample_cos_ids: List[Tuple[str | None, str | None]] = [] + + for font in fonts: + total += 1 + font_id = font.get("id") + uid = font.get("uid") + cos_value = font.get("cosDictionary") + if cos_value: + with_cos += 1 + if len(sample_cos_ids) < 5: + sample_cos_ids.append((font_id, uid)) + + metadata_bytes += approx_struct_size( + {k: v for k, v in font.items() if k not in {"program", "webProgram", "pdfProgram"}} + ) + + program = font.get("program") + web_program = font.get("webProgram") + pdf_program = font.get("pdfProgram") + + if program: + with_prog += 1 + program_bytes += base64_payload_size(program) + if web_program: + with_web_prog += 1 + web_program_bytes += base64_payload_size(web_program) + if pdf_program: + with_pdf_prog += 1 + pdf_program_bytes += base64_payload_size(pdf_program) + + return FontBreakdown( + total=total, + with_cos=with_cos, + with_program=with_prog, + with_web_program=with_web_prog, + with_pdf_program=with_pdf_prog, + program_bytes=program_bytes, + web_program_bytes=web_program_bytes, + pdf_program_bytes=pdf_program_bytes, + metadata_bytes=metadata_bytes, + sample_cos_ids=sample_cos_ids, + ) + + +def analyze_pages(pages: Iterable[Dict[str, Any]]) -> PageBreakdown: + page_count = 0 + total_text = 0 + total_images = 0 + text_chars = 0 + text_struct_bytes = 0 + image_struct_bytes = 0 + resources_bytes = 0 + stream_bytes = 0 + annotations_bytes = 0 + + for page in pages: + page_count += 1 + texts = page.get("textElements") or [] + images = page.get("imageElements") or [] + resources = page.get("resources") + streams = page.get("contentStreams") or [] + annotations = page.get("annotations") or [] + + total_text += len(texts) + total_images += len(images) + text_struct_bytes += approx_struct_size(texts) + image_struct_bytes += approx_struct_size(images) + resources_bytes += approx_struct_size(resources) + stream_bytes += approx_struct_size(streams) + annotations_bytes += approx_struct_size(annotations) + + for elem in texts: + text = elem.get("text") + if text: + text_chars += len(text) + + return PageBreakdown( + page_count=page_count, + total_text_elements=total_text, + total_image_elements=total_images, + text_payload_chars=text_chars, + text_struct_bytes=text_struct_bytes, + image_struct_bytes=image_struct_bytes, + resources_bytes=resources_bytes, + content_stream_bytes=stream_bytes, + annotations_bytes=annotations_bytes, + ) + + +def analyze_document(document: Dict[str, Any], total_size: int) -> DocumentBreakdown: + fonts = document.get("fonts") or [] + pages = document.get("pages") or [] + metadata = document.get("metadata") or {} + + font_stats = analyze_fonts(fonts) + page_stats = analyze_pages(pages) + + return DocumentBreakdown( + total_bytes=total_size, + fonts=font_stats, + pages=page_stats, + metadata_bytes=approx_struct_size(metadata), + xmp_bytes=base64_payload_size(document.get("xmpMetadata")), + form_fields_bytes=approx_struct_size(document.get("formFields")), + lazy_flag_bytes=approx_struct_size(document.get("lazyImages")), + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Inspect a PDF JSON export.") + parser.add_argument("json_path", type=Path, help="Path to the JSON export.") + args = parser.parse_args() + + json_path = args.json_path + if not json_path.exists(): + raise SystemExit(f"File not found: {json_path}") + + file_size = json_path.stat().st_size + print(f"File: {json_path}") + print(f"Size: {human_bytes(file_size)} ({file_size:,} bytes)") + + with json_path.open("r", encoding="utf-8") as handle: + document = json.load(handle) + + if not isinstance(document, dict): + raise SystemExit("Unexpected JSON structure (expected an object at root).") + + summary = analyze_document(document, file_size) + page_stats = summary.pages + print(f"Pages: {page_stats.page_count}") + print(f"Total text elements: {page_stats.total_text_elements:,}") + print(f"Total image elements: {page_stats.total_image_elements:,}") + print( + f"Page structural bytes (text arrays + images + streams + annotations): " + f"{human_bytes(page_stats.text_struct_bytes + page_stats.image_struct_bytes + page_stats.content_stream_bytes + page_stats.annotations_bytes)}" + ) + + font_stats = summary.fonts + print("\nFont summary:") + print(f" Fonts total: {font_stats.total}") + print(f" Fonts with cosDictionary: {font_stats.with_cos}") + print(f" Fonts with program: {font_stats.with_program}") + print(f" Fonts with webProgram: {font_stats.with_web_program}") + print(f" Fonts with pdfProgram: {font_stats.with_pdf_program}") + print( + " Payload sizes:" + f" program={human_bytes(font_stats.program_bytes)}," + f" webProgram={human_bytes(font_stats.web_program_bytes)}," + f" pdfProgram={human_bytes(font_stats.pdf_program_bytes)}," + f" metadata={human_bytes(font_stats.metadata_bytes)}" + ) + if font_stats.sample_cos_ids: + print(" Sample fonts still carrying cosDictionary:") + for idx, (font_id, uid) in enumerate(font_stats.sample_cos_ids, start=1): + print(f" {idx}. id={font_id!r}, uid={uid!r}") + else: + print(" No fonts retain cosDictionary entries.") + + print("\nOther sections:") + print(f" Metadata bytes: {human_bytes(summary.metadata_bytes)}") + print(f" XMP metadata bytes: {human_bytes(summary.xmp_bytes)}") + print(f" Form fields bytes: {human_bytes(summary.form_fields_bytes)}") + print(f" Lazy flag bytes: {summary.lazy_flag_bytes}") + print( + f" Text payload characters (not counting JSON overhead): " + f"{page_stats.text_payload_chars:,}" + ) + print( + f" Approx text structure bytes: {human_bytes(page_stats.text_struct_bytes)}" + ) + print( + f" Approx image structure bytes: {human_bytes(page_stats.image_struct_bytes)}" + ) + print( + f" Approx content stream bytes: {human_bytes(page_stats.content_stream_bytes)}" + ) + print( + f" Approx annotations bytes: {human_bytes(page_stats.annotations_bytes)}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/convert_cff_to_ttf.py b/scripts/convert_cff_to_ttf.py new file mode 100644 index 0000000000..7a7f99270b --- /dev/null +++ b/scripts/convert_cff_to_ttf.py @@ -0,0 +1,492 @@ +#!/usr/bin/env python3 +""" +Wrap raw CFF/Type1C data (extracted from PDFs) as OpenType-CFF for web compatibility. +Builds proper Unicode cmap from PDF ToUnicode data. +""" +import sys +import re +from pathlib import Path +from io import BytesIO +from fontTools.ttLib import TTFont, newTable +from fontTools.cffLib import CFFFontSet +from fontTools.ttLib.tables._c_m_a_p import cmap_format_4, cmap_format_12 +from fontTools.ttLib.tables._n_a_m_e import NameRecord +from fontTools.ttLib.tables.O_S_2f_2 import Panose + +def parse_unicode_mapping(mapping_path): + """ + Parse Unicode mapping (either JSON with CharCode→CID→GID→Unicode or raw ToUnicode CMap). + + Returns: + dict[int, int]: GID → Unicode codepoint + """ + try: + with open(mapping_path, 'rb') as f: + data = f.read().decode('utf-8', errors='ignore') + + # Try parsing as JSON first (CID font with complete mapping) + if data.strip().startswith('{'): + import json + try: + mapping_data = json.loads(data) + if mapping_data.get('isCID'): + # Build GID → Unicode mapping from entries + gid_to_unicode = {} + for entry in mapping_data.get('entries', []): + gid = entry['gid'] + unicode_val = entry['unicode'] + if unicode_val > 0: + gid_to_unicode[gid] = unicode_val + print(f"Parsed JSON mapping: {len(gid_to_unicode)} GID→Unicode entries", file=sys.stderr) + return gid_to_unicode + except json.JSONDecodeError: + pass + + # Fall back to parsing raw ToUnicode CMap (non-CID fonts) + # For non-CID fonts, CID/GID is the same as array index + gid_to_unicode = {} + + # Pattern for bfchar entries + bfchar_pattern = r'<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>' + for match in re.finditer(bfchar_pattern, data): + gid = int(match.group(1), 16) # For non-CID, char code == GID + unicode_val = int(match.group(2), 16) + if unicode_val > 0: + gid_to_unicode[gid] = unicode_val + + # Pattern for bfrange entries + bfrange_pattern = r'<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>' + for match in re.finditer(bfrange_pattern, data): + start_gid = int(match.group(1), 16) + end_gid = int(match.group(2), 16) + start_unicode = int(match.group(3), 16) + for i, gid in enumerate(range(start_gid, end_gid + 1)): + unicode_val = start_unicode + i + if unicode_val > 0: + gid_to_unicode[gid] = unicode_val + + print(f"Parsed ToUnicode CMap: {len(gid_to_unicode)} mappings", file=sys.stderr) + return gid_to_unicode + + except Exception as e: + print(f"Warning: Failed to parse Unicode mapping: {e}", file=sys.stderr) + return {} + +def wrap_cff_as_otf(input_path, output_path, tounicode_path=None): + """ + Wrap raw CFF data (from PDF font stream) as OpenType-CFF. + + Args: + input_path: Path to input CFF data file + output_path: Path to output OTF font + tounicode_path: Optional path to ToUnicode CMap file + + Returns: + True if successful, False otherwise + """ + try: + # Read raw CFF data + with open(input_path, 'rb') as f: + cff_data = f.read() + + # Parse raw CFF data + cff_fontset = CFFFontSet() + cff_fontset.decompile(BytesIO(cff_data), None) + + # Get the first (and usually only) font in the CFF set + if len(cff_fontset.fontNames) == 0: + print("ERROR: No fonts found in CFF data", file=sys.stderr) + return False + + cff_font = cff_fontset[cff_fontset.fontNames[0]] + + # Parse Unicode mapping (JSON or raw ToUnicode CMap) if provided + gid_to_unicode = {} + if tounicode_path: + gid_to_unicode = parse_unicode_mapping(tounicode_path) + + # Create a new OTF font + otf = TTFont(sfntVersion='OTTO') # 'OTTO' = CFF-flavored OpenType + + # Get glyph names + if hasattr(cff_font, 'charset') and cff_font.charset is not None: + glyph_order = ['.notdef'] + [name for name in cff_font.charset if name != '.notdef'] + else: + # Fallback to CharStrings keys + charstrings = cff_font.CharStrings + glyph_order = ['.notdef'] + [name for name in charstrings.keys() if name != '.notdef'] + + otf.setGlyphOrder(glyph_order) + + # === Add CFF table (the actual font outlines) === + cff_table = newTable('CFF ') + cff_table.cff = cff_fontset + otf['CFF '] = cff_table + + # === Calculate metrics from CFF === + charstrings = cff_font.CharStrings + + # Get defaults from CFF Private dict + private_dict = getattr(cff_font, 'Private', None) + default_width = getattr(private_dict, 'defaultWidthX', 500) if private_dict else 500 + + # Calculate bounding box, widths, and LSBs + x_min = 0 + y_min = -200 + x_max = 1000 + y_max = 800 + max_advance = 0 + min_lsb = 0 + min_rsb = 0 + max_extent = 0 + + widths = {} + lsbs = {} + + for glyph_name in glyph_order: + lsb = 0 + width = int(default_width) + + if glyph_name in charstrings: + try: + cs = charstrings[glyph_name] + + # Get width from charstring + if hasattr(cs, 'width'): + width = int(cs.width) + + # Calculate bounds for LSB and bbox + try: + bounds = cs.calcBounds(None) + if bounds: + glyph_xmin = int(bounds[0]) + glyph_ymin = int(bounds[1]) + glyph_xmax = int(bounds[2]) + glyph_ymax = int(bounds[3]) + + lsb = glyph_xmin + rsb = width - glyph_xmax + extent = lsb + glyph_xmax + + # Update global bounds + x_min = min(x_min, glyph_xmin) + y_min = min(y_min, glyph_ymin) + x_max = max(x_max, glyph_xmax) + y_max = max(y_max, glyph_ymax) + + # Update hhea metrics + min_lsb = min(min_lsb, lsb) + min_rsb = min(min_rsb, rsb) + max_extent = max(max_extent, extent) + except: + pass # Some glyphs may not have outlines + + except Exception as e: + pass # Use defaults + + widths[glyph_name] = width + lsbs[glyph_name] = lsb + max_advance = max(max_advance, width) + + if max_advance == 0: + max_advance = 1000 + if max_extent == 0: + max_extent = x_max + + units_per_em = 1000 # Standard for Type1/CFF + + # === Create head table === + head = newTable('head') + head.tableVersion = 1.0 + head.fontRevision = 1.0 + head.checkSumAdjustment = 0 + head.magicNumber = 0x5F0F3CF5 + head.flags = 0x000B # Baseline at y=0, LSB at x=0, integer PPEM + head.unitsPerEm = units_per_em + head.created = 3600000000 + head.modified = 3600000000 + head.xMin = x_min + head.yMin = y_min + head.xMax = x_max + head.yMax = y_max + head.macStyle = 0 + head.fontDirectionHint = 2 + head.indexToLocFormat = 0 + head.glyphDataFormat = 0 + head.lowestRecPPEM = 8 + otf['head'] = head + + # === Create hhea table with correct metrics === + hhea = newTable('hhea') + hhea.tableVersion = 0x00010000 + hhea.ascent = max(y_max, 800) + hhea.descent = min(y_min, -200) + hhea.lineGap = 0 + hhea.advanceWidthMax = max_advance + hhea.minLeftSideBearing = min_lsb + hhea.minRightSideBearing = min_rsb + hhea.xMaxExtent = max_extent + hhea.caretSlopeRise = 1 + hhea.caretSlopeRun = 0 + hhea.caretOffset = 0 + hhea.reserved0 = 0 + hhea.reserved1 = 0 + hhea.reserved2 = 0 + hhea.reserved3 = 0 + hhea.metricDataFormat = 0 + hhea.numberOfHMetrics = len(glyph_order) + otf['hhea'] = hhea + + # === Create hmtx table with correct LSBs === + hmtx = newTable('hmtx') + hmtx.metrics = {} + for glyph_name in glyph_order: + hmtx.metrics[glyph_name] = (widths.get(glyph_name, default_width), lsbs.get(glyph_name, 0)) + otf['hmtx'] = hmtx + + # === Create maxp table (simpler for CFF) === + maxp = newTable('maxp') + maxp.tableVersion = 0x00005000 # CFF version (0.5) + maxp.numGlyphs = len(glyph_order) + otf['maxp'] = maxp + + # === Build Unicode cmap from GID→Unicode mapping === + unicode_to_glyph = {} + + if gid_to_unicode: + # Debug: Show first few glyph names to understand naming convention + sample_glyphs = glyph_order[:min(10, len(glyph_order))] + print(f"Sample glyph names: {sample_glyphs}", file=sys.stderr) + + # Debug: Show which GIDs we have mappings for + sample_gids = sorted(gid_to_unicode.keys())[:10] + print(f"Sample GIDs from mapping: {sample_gids}", file=sys.stderr) + + # For CID fonts: glyph names are "cid00123" (5-digit zero-padded) + # For non-CID fonts: glyph names vary but GID == array index + is_cid_font = any(gn.startswith('cid') for gn in glyph_order[1:6]) # Check first few non-.notdef glyphs + + for gid, unicode_val in gid_to_unicode.items(): + if unicode_val > 0: + if is_cid_font: + # Build glyph name as cidNNNNN (5 digits, zero-padded) + glyph_name = f"cid{gid:05d}" + # Verify this glyph exists in glyph_order + if glyph_name in glyph_order: + unicode_to_glyph[unicode_val] = glyph_name + else: + # Try without padding (some fonts use "cid123" not "cid00123") + glyph_name_alt = f"cid{gid}" + if glyph_name_alt in glyph_order: + unicode_to_glyph[unicode_val] = glyph_name_alt + else: + # Non-CID font: GID is array index + if 0 <= gid < len(glyph_order): + glyph_name = glyph_order[gid] + unicode_to_glyph[unicode_val] = glyph_name + + print(f"Mapped {len(unicode_to_glyph)} Unicode codepoints (isCID={is_cid_font if gid_to_unicode else 'unknown'})", file=sys.stderr) + + # Also try to map from glyph names (uni0041 → U+0041) + for glyph_name in glyph_order: + if glyph_name.startswith('uni') and len(glyph_name) == 7: + try: + unicode_val = int(glyph_name[3:], 16) + if unicode_val not in unicode_to_glyph: + unicode_to_glyph[unicode_val] = glyph_name + except: + pass + elif glyph_name.startswith('u') and len(glyph_name) >= 5: + try: + unicode_val = int(glyph_name[1:], 16) + if unicode_val not in unicode_to_glyph: + unicode_to_glyph[unicode_val] = glyph_name + except: + pass + + # === Create cmap table === + cmap = newTable('cmap') + cmap.tableVersion = 0 + cmap_tables = [] + + # Windows Unicode BMP (format 4) - required + cmap4_win = cmap_format_4(4) + cmap4_win.platformID = 3 # Windows + cmap4_win.platEncID = 1 # Unicode BMP + cmap4_win.language = 0 + cmap4_win.cmap = {cp: gn for cp, gn in unicode_to_glyph.items() if cp <= 0xFFFF} + cmap_tables.append(cmap4_win) + + # Windows Unicode UCS-4 (format 12) - for >BMP + if any(cp > 0xFFFF for cp in unicode_to_glyph): + cmap12_win = cmap_format_12(12) + cmap12_win.platformID = 3 # Windows + cmap12_win.platEncID = 10 # Unicode UCS-4 + cmap12_win.language = 0 + cmap12_win.cmap = dict(unicode_to_glyph) + cmap_tables.append(cmap12_win) + + # Mac Unicode (format 4) - for compatibility + cmap4_mac = cmap_format_4(4) + cmap4_mac.platformID = 1 # Mac + cmap4_mac.platEncID = 0 # Roman + cmap4_mac.language = 0 + cmap4_mac.cmap = {cp: gn for cp, gn in unicode_to_glyph.items() if cp <= 0xFFFF} + cmap_tables.append(cmap4_mac) + + cmap.tables = [t for t in cmap_tables if t.cmap] or [cmap4_win] # Ensure at least one + otf['cmap'] = cmap + + print(f"Built cmap with {len(unicode_to_glyph)} Unicode mappings", file=sys.stderr) + + # === Create OS/2 table with correct metrics === + os2 = newTable('OS/2') + os2.version = 4 + os2.xAvgCharWidth = int(sum(widths.values()) / len(widths)) if widths else 500 + os2.usWeightClass = 400 # Normal + os2.usWidthClass = 5 # Medium + os2.fsType = 0 # Installable embedding + os2.ySubscriptXSize = 650 + os2.ySubscriptYSize = 600 + os2.ySubscriptXOffset = 0 + os2.ySubscriptYOffset = 75 + os2.ySuperscriptXSize = 650 + os2.ySuperscriptYSize = 600 + os2.ySuperscriptXOffset = 0 + os2.ySuperscriptYOffset = 350 + os2.yStrikeoutSize = 50 + os2.yStrikeoutPosition = 300 + os2.sFamilyClass = 0 + + # PANOSE - use proper object structure + os2.panose = Panose() + os2.panose.bFamilyType = 0 + os2.panose.bSerifStyle = 0 + os2.panose.bWeight = 0 + os2.panose.bProportion = 0 + os2.panose.bContrast = 0 + os2.panose.bStrokeVariation = 0 + os2.panose.bArmStyle = 0 + os2.panose.bLetterForm = 0 + os2.panose.bMidline = 0 + os2.panose.bXHeight = 0 + + os2.ulUnicodeRange1 = 0 + os2.ulUnicodeRange2 = 0 + os2.ulUnicodeRange3 = 0 + os2.ulUnicodeRange4 = 0 + os2.achVendID = 'SPDF' + os2.fsSelection = 0x0040 # REGULAR bit + + # Set character index range from actual cmap + if unicode_to_glyph: + codepoints = sorted(unicode_to_glyph.keys()) + os2.usFirstCharIndex = codepoints[0] + os2.usLastCharIndex = codepoints[-1] + else: + os2.usFirstCharIndex = 0x20 # space + os2.usLastCharIndex = 0x7E # tilde + + # Typo metrics match hhea + os2.sTypoAscender = hhea.ascent + os2.sTypoDescender = hhea.descent + os2.sTypoLineGap = hhea.lineGap + + # Windows metrics (positive values, cover bbox) + os2.usWinAscent = max(0, y_max) + os2.usWinDescent = max(0, -y_min) + + os2.ulCodePageRange1 = 0x00000001 # Latin 1 + os2.ulCodePageRange2 = 0 + os2.sxHeight = 500 + os2.sCapHeight = 700 + os2.usDefaultChar = 0 + os2.usBreakChar = 32 + os2.usMaxContext = 0 + otf['OS/2'] = os2 + + # === Create name table with Windows and Mac records === + name = newTable('name') + name.names = [] + + # Get font name from CFF if available + font_name = cff_fontset.fontNames[0] if cff_fontset.fontNames else "Converted" + + name_strings = { + 1: font_name, # Font Family + 2: "Regular", # Subfamily + 3: f"Stirling-PDF: {font_name}", # Unique ID + 4: font_name, # Full Name + 5: "Version 1.0", # Version + 6: font_name.replace(' ', '-'), # PostScript Name + } + + # Add both Windows and Mac name records + for name_id, value in name_strings.items(): + # Windows (platform 3, encoding 1, language 0x0409 = en-US) + rec_win = NameRecord() + rec_win.nameID = name_id + rec_win.platformID = 3 + rec_win.platEncID = 1 + rec_win.langID = 0x0409 + rec_win.string = value + name.names.append(rec_win) + + # Mac (platform 1, encoding 0, language 0) + rec_mac = NameRecord() + rec_mac.nameID = name_id + rec_mac.platformID = 1 + rec_mac.platEncID = 0 + rec_mac.langID = 0 + rec_mac.string = value + name.names.append(rec_mac) + + otf['name'] = name + + # === Create post table (format 3.0 for smaller web fonts) === + post = newTable('post') + post.formatType = 3.0 # No glyph names (smaller, web-optimized) + post.italicAngle = 0 + post.underlinePosition = -100 + post.underlineThickness = 50 + post.isFixedPitch = 0 + post.minMemType42 = 0 + post.maxMemType42 = 0 + post.minMemType1 = 0 + post.maxMemType1 = 0 + otf['post'] = post + + # Save the OTF font + otf.save(output_path) + otf.close() + + return True + + except Exception as e: + print(f"ERROR: Conversion failed: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + return False + +def main(): + if len(sys.argv) < 3: + print("Usage: convert_cff_to_ttf.py [tounicode.cmap]", file=sys.stderr) + sys.exit(1) + + input_path = Path(sys.argv[1]) + output_path = Path(sys.argv[2]) + tounicode_path = Path(sys.argv[3]) if len(sys.argv) > 3 else None + + if not input_path.exists(): + print(f"ERROR: Input file not found: {input_path}", file=sys.stderr) + sys.exit(1) + + if tounicode_path and not tounicode_path.exists(): + print(f"Warning: ToUnicode file not found: {tounicode_path}", file=sys.stderr) + tounicode_path = None + + success = wrap_cff_as_otf(str(input_path), str(output_path), str(tounicode_path) if tounicode_path else None) + sys.exit(0 if success else 1) + +if __name__ == '__main__': + main() diff --git a/scripts/download_pdf_samples.py b/scripts/download_pdf_samples.py new file mode 100644 index 0000000000..6a882c56ff --- /dev/null +++ b/scripts/download_pdf_samples.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Download large batches of PDF URLs into a local directory so they can be fed to +scripts/harvest_type3_fonts.py (or any other processing pipeline). + +Usage examples: + + # Download every URL listed in pdf_urls.txt into tmp/type3-pdfs + python scripts/download_pdf_samples.py \ + --urls-file pdf_urls.txt \ + --output-dir tmp/type3-pdfs + + # Mix inline URLs with a file and use 16 concurrent downloads + python scripts/download_pdf_samples.py \ + --urls https://example.com/a.pdf https://example.com/b.pdf \ + --urls-file more_urls.txt \ + --output-dir tmp/type3-pdfs \ + --workers 16 +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import hashlib +import os +import re +import sys +from pathlib import Path +from typing import Iterable, List, Optional, Set, Tuple +from urllib.parse import unquote, urlparse + +import requests + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Bulk download PDF URLs.") + parser.add_argument( + "--urls", + nargs="*", + default=[], + help="Inline list of PDF URLs (can be combined with --urls-file).", + ) + parser.add_argument( + "--urls-file", + action="append", + help="Text file containing one URL per line (can be repeated).", + ) + parser.add_argument( + "--output-dir", + default="tmp/harvest-pdfs", + help="Directory to store downloaded PDFs (default: %(default)s).", + ) + parser.add_argument( + "--workers", + type=int, + default=min(8, (os.cpu_count() or 4) * 2), + help="Number of concurrent downloads (default: %(default)s).", + ) + parser.add_argument( + "--timeout", + type=int, + default=120, + help="Per-request timeout in seconds (default: %(default)s).", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite existing files (default: skip already downloaded PDFs).", + ) + return parser.parse_args() + + +def load_urls(args: argparse.Namespace) -> List[str]: + urls: List[str] = [] + seen: Set[str] = set() + + def add(url: str) -> None: + clean = url.strip() + if not clean or clean.startswith("#"): + return + if clean not in seen: + seen.add(clean) + urls.append(clean) + + for url in args.urls: + add(url) + if args.urls_file: + for file in args.urls_file: + path = Path(file) + if not path.exists(): + print(f"[WARN] URL file not found: {file}", file=sys.stderr) + continue + with path.open("r", encoding="utf-8") as handle: + for line in handle: + add(line) + if not urls: + raise SystemExit("No URLs supplied. Use --urls and/or --urls-file.") + return urls + + +def sanitize_filename(name: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("_") or "download" + + +def build_filename(url: str, output_dir: Path) -> Path: + parsed = urlparse(url) + candidate = Path(unquote(parsed.path)).name + if not candidate: + candidate = "download.pdf" + candidate = sanitize_filename(candidate) + if not candidate.lower().endswith(".pdf"): + candidate += ".pdf" + target = output_dir / candidate + if not target.exists(): + return target + stem = target.stem + suffix = target.suffix + digest = hashlib.sha1(url.encode("utf-8")).hexdigest()[:8] + return output_dir / f"{stem}-{digest}{suffix}" + + +def download_pdf( + url: str, + output_dir: Path, + timeout: int, + overwrite: bool, +) -> Tuple[str, Optional[Path], Optional[str]]: + try: + dest = build_filename(url, output_dir) + if dest.exists() and not overwrite: + return url, dest, "exists" + + response = requests.get(url, stream=True, timeout=timeout) + response.raise_for_status() + + content_type = response.headers.get("Content-Type", "").lower() + if "pdf" not in content_type and not url.lower().endswith(".pdf"): + # Peek into the first bytes to be safe + peek = response.raw.read(5, decode_content=True) + if not peek.startswith(b"%PDF"): + return url, None, f"Skipping non-PDF content-type ({content_type or 'unknown'})" + content = peek + response.content[len(peek):] + else: + content = response.content + + output_dir.mkdir(parents=True, exist_ok=True) + dest.write_bytes(content) + return url, dest, None + except Exception as exc: # pylint: disable=broad-except + return url, None, str(exc) + + +def main() -> None: + args = parse_args() + urls = load_urls(args) + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"Downloading {len(urls)} PDFs to {output_dir} using {args.workers} workers...") + + successes = 0 + skipped = 0 + failures: List[Tuple[str, str]] = [] + + with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor: + future_to_url = { + executor.submit( + download_pdf, url, output_dir, args.timeout, args.overwrite + ): url + for url in urls + } + for future in concurrent.futures.as_completed(future_to_url): + url = future_to_url[future] + result_url, path, error = future.result() + if error == "exists": + skipped += 1 + print(f"[SKIP] {url} (already downloaded)") + elif error: + failures.append((result_url, error)) + print(f"[FAIL] {url} -> {error}", file=sys.stderr) + else: + successes += 1 + print(f"[OK] {url} -> {path}") + + print() + print(f"Completed. Success: {successes}, Skipped: {skipped}, Failures: {len(failures)}") + if failures: + print("Failures:") + for url, error in failures: + print(f" {url} -> {error}") + + +if __name__ == "__main__": + main() diff --git a/scripts/harvest_type3_fonts.py b/scripts/harvest_type3_fonts.py new file mode 100644 index 0000000000..5edb1b2a93 --- /dev/null +++ b/scripts/harvest_type3_fonts.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Bulk-harvest Type3 font signatures from a folder full of PDFs. + +The script iterates over every PDF (recursively) inside the supplied --input +paths, invokes the existing Gradle Type3SignatureTool for each document, and +collects the unique Type3 font signatures that were discovered. Signature JSON +files are stored under --signatures-dir; previously captured files are reused +so you can keep dropping new PDFs into the input directory and re-run the +harvester at any time. + +Example: + python scripts/harvest_type3_fonts.py \ + --input incoming-type3-pdfs \ + --signatures docs/type3/signatures \ + --report docs/type3/harvest_report.json +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import os +import re +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Bulk collect Type3 font signatures from PDFs.") + parser.add_argument( + "--input", + nargs="+", + required=True, + help="One or more PDF files or directories containing PDFs (searched recursively).", + ) + parser.add_argument( + "--signatures-dir", + default="docs/type3/signatures", + help="Destination directory for per-PDF signature JSON files.", + ) + parser.add_argument( + "--report", + default="docs/type3/harvest_report.json", + help="Summary JSON that lists every unique signature discovered so far.", + ) + default_gradle = "gradlew.bat" if os.name == "nt" else "./gradlew" + parser.add_argument( + "--gradle-cmd", + default=default_gradle, + help=f"Path to the Gradle wrapper used to invoke the Type3SignatureTool (default: {default_gradle}).", + ) + parser.add_argument( + "--force", + action="store_true", + help="Re-run the signature tool even if the output JSON already exists.", + ) + parser.add_argument( + "--pretty", + action="store_true", + help="Ask the Java tool to emit pretty-printed JSON (handy for diffs).", + ) + return parser.parse_args() + + +def discover_pdfs(paths: Sequence[str]) -> List[Path]: + pdfs: List[Path] = [] + for raw in paths: + path = Path(raw).resolve() + if path.is_file(): + if path.suffix.lower() == ".pdf": + pdfs.append(path) + elif path.is_dir(): + pdfs.extend(sorted(path.rglob("*.pdf"))) + unique = sorted(dict.fromkeys(pdfs)) + if not unique: + raise SystemExit("No PDF files found under the supplied --input paths.") + return unique + + +def sanitize_part(part: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", part) + return cleaned or "_" + + +def derive_signature_path(pdf: Path, signatures_dir: Path) -> Path: + """ + Mirror the PDF path under the signatures directory. + If the PDF lives outside the repo, fall back to a hashed filename. + """ + try: + rel = pdf.relative_to(REPO_ROOT) + except ValueError: + digest = hashlib.sha1(str(pdf).encode("utf-8")).hexdigest()[:10] + rel = Path("__external__") / f"{sanitize_part(pdf.stem)}-{digest}.pdf" + + sanitized_parts = [sanitize_part(part) for part in rel.parts] + signature_rel = Path(*sanitized_parts).with_suffix(".json") + return signatures_dir / signature_rel + + +def load_signature_file(path: Path) -> dict: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def collect_known_signatures(signatures_dir: Path) -> Dict[str, dict]: + known: Dict[str, dict] = {} + if not signatures_dir.exists(): + return known + for json_file in signatures_dir.rglob("*.json"): + try: + payload = load_signature_file(json_file) + except Exception: + continue + pdf = payload.get("pdf") + for font in payload.get("fonts", []): + signature = font.get("signature") + if not signature or signature in known: + continue + known[signature] = { + "signature": signature, + "alias": font.get("alias"), + "baseName": font.get("baseName"), + "glyphCount": font.get("glyphCount"), + "glyphCoverage": font.get("glyphCoverage"), + "samplePdf": pdf, + "signatureJson": str(json_file), + } + return known + + +def run_signature_tool( + gradle_cmd: str, pdf: Path, output_path: Path, pretty: bool, cwd: Path +) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + args = f"--pdf {shlex.quote(str(pdf))} --output {shlex.quote(str(output_path))}" + if pretty: + args += " --pretty" + # Use shell invocation so the quoted --args string is parsed correctly by Gradle. + cmd = f"{gradle_cmd} -q :proprietary:type3SignatureTool --args=\"{args}\"" + completed = subprocess.run( + cmd, + shell=True, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if completed.returncode != 0: + raise RuntimeError( + f"Gradle Type3SignatureTool failed for {pdf}:\n{completed.stderr.strip()}" + ) + + +def extract_fonts_from_payload(payload: dict) -> List[dict]: + pdf = payload.get("pdf") + fonts = [] + for font in payload.get("fonts", []): + signature = font.get("signature") + if not signature: + continue + fonts.append( + { + "signature": signature, + "alias": font.get("alias"), + "baseName": font.get("baseName"), + "glyphCount": font.get("glyphCount"), + "glyphCoverage": font.get("glyphCoverage"), + "samplePdf": pdf, + } + ) + return fonts + + +def write_report(report_path: Path, fonts_by_signature: Dict[str, dict]) -> None: + ordered = sorted(fonts_by_signature.values(), key=lambda entry: entry["signature"]) + report = { + "generatedAt": dt.datetime.utcnow().isoformat(timespec="seconds") + "Z", + "totalSignatures": len(ordered), + "fonts": ordered, + } + report_path.parent.mkdir(parents=True, exist_ok=True) + with report_path.open("w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + + +def main() -> None: + args = parse_args() + signatures_dir = Path(args.signatures_dir).resolve() + report_path = Path(args.report).resolve() + pdfs = discover_pdfs(args.input) + + known = collect_known_signatures(signatures_dir) + newly_added: List[Tuple[str, str]] = [] + + for pdf in pdfs: + signature_path = derive_signature_path(pdf, signatures_dir) + if signature_path.exists() and not args.force: + try: + payload = load_signature_file(signature_path) + except Exception as exc: + print(f"[WARN] Failed to parse cached signature {signature_path}: {exc}") + payload = None + else: + try: + run_signature_tool(args.gradle_cmd, pdf, signature_path, args.pretty, REPO_ROOT) + except Exception as exc: + print(f"[ERROR] Harvest failed for {pdf}: {exc}", file=sys.stderr) + continue + payload = load_signature_file(signature_path) + + if not payload: + continue + + for font in extract_fonts_from_payload(payload): + signature = font["signature"] + if signature in known: + continue + font["signatureJson"] = str(signature_path) + known[signature] = font + newly_added.append((signature, pdf.name)) + + write_report(report_path, known) + + print( + f"Processed {len(pdfs)} PDFs. " + f"Captured {len(newly_added)} new Type3 font signatures " + f"(total unique signatures: {len(known)})." + ) + if newly_added: + print("New signatures:") + for signature, sample in newly_added: + print(f" {signature} ({sample})") + + +if __name__ == "__main__": + main() diff --git a/scripts/index_type3_catalogue.py b/scripts/index_type3_catalogue.py new file mode 100644 index 0000000000..49dce500e4 --- /dev/null +++ b/scripts/index_type3_catalogue.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Build a Type3 font catalogue from sample PDFs.""" +import argparse +import json +import subprocess +from pathlib import Path + + +def run(cmd, cwd=None): + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"Command {' '.join(cmd)} failed: {result.stderr}") + return result.stdout + + +def parse_pdffonts(output): + lines = output.splitlines() + entries = [] + for line in lines[2:]: + if not line.strip(): + continue + parts = line.split() + if "Type" not in parts: + continue + idx = parts.index("Type") + type_value = parts[idx + 1] if idx + 1 < len(parts) else "" + if not type_value.startswith("3"): + continue + font_name = parts[0] + encoding = parts[-2] if len(parts) >= 2 else "" + entries.append((font_name, encoding)) + return entries + + +def main(): + parser = argparse.ArgumentParser(description="Index Type3 fonts from sample PDFs") + parser.add_argument( + "--samples", + default="app/core/src/main/resources/type3/samples", + help="Directory containing sample PDFs", + ) + parser.add_argument( + "--output", + default="app/core/src/main/resources/type3/catalogue.json", + help="Output JSON file", + ) + args = parser.parse_args() + + samples_dir = Path(args.samples) + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + + catalogue = [] + for pdf in sorted(samples_dir.glob("*.pdf")): + try: + output = run(["pdffonts", str(pdf)]) + except Exception as exc: + print(f"Skipping {pdf.name}: {exc}") + continue + for font_name, encoding in parse_pdffonts(output): + catalogue.append( + { + "source": pdf.name, + "fontName": font_name, + "encoding": encoding, + } + ) + + with out_path.open("w", encoding="utf-8") as handle: + json.dump(catalogue, handle, indent=2) + print(f"Wrote {len(catalogue)} entries to {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/summarize_type3_signatures.py b/scripts/summarize_type3_signatures.py new file mode 100644 index 0000000000..ae87069351 --- /dev/null +++ b/scripts/summarize_type3_signatures.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Summarize captured Type3 signature dumps as a Markdown inventory. + +Usage: + scripts/summarize_type3_signatures.py \ + --input docs/type3/signatures \ + --output docs/type3/signature_inventory.md +""" + +from __future__ import annotations + +import argparse +import json +from collections import defaultdict +from pathlib import Path +from typing import Dict, List + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Summarize Type3 signature JSON dumps.") + parser.add_argument( + "--input", + default="docs/type3/signatures", + help="Directory containing signature JSON files (default: %(default)s)", + ) + parser.add_argument( + "--output", + default="docs/type3/signature_inventory.md", + help="Markdown file to write (default: %(default)s)", + ) + return parser.parse_args() + + +def load_signatures(directory: Path) -> Dict[str, List[dict]]: + inventory: Dict[str, List[dict]] = defaultdict(list) + for path in sorted(directory.glob("*.json")): + with path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + source_pdf = payload.get("pdf") or path.name + for font in payload.get("fonts", []): + alias = (font.get("alias") or font.get("baseName") or "unknown").lower() + entry = { + "source": source_pdf, + "file": path.name, + "alias": alias, + "baseName": font.get("baseName"), + "signature": font.get("signature"), + "glyphCount": font.get("glyphCount"), + "glyphCoverage": font.get("glyphCoverage"), + } + inventory[alias].append(entry) + return inventory + + +def write_markdown(inventory: Dict[str, List[dict]], output: Path, input_dir: Path) -> None: + lines: List[str] = [] + lines.append("# Type3 Signature Inventory") + lines.append("") + lines.append( + f"_Generated from `{input_dir}`. " + "Run `scripts/summarize_type3_signatures.py` after capturing new samples._" + ) + lines.append("") + + for alias in sorted(inventory.keys()): + entries = inventory[alias] + lines.append(f"## Alias: `{alias}`") + lines.append("") + lines.append("| Signature | Samples | Glyph Count | Coverage (first 10) |") + lines.append("| --- | --- | --- | --- |") + for entry in entries: + signature = entry.get("signature") or "—" + sample = Path(entry["source"]).name + glyph_count = entry.get("glyphCount") if entry.get("glyphCount") is not None else "—" + coverage = entry.get("glyphCoverage") or [] + preview = ", ".join(str(code) for code in coverage[:10]) + lines.append(f"| `{signature}` | `{sample}` | {glyph_count} | {preview} |") + lines.append("") + + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("\n".join(lines), encoding="utf-8") + + +def main() -> None: + args = parse_args() + input_dir = Path(args.input) + if not input_dir.exists(): + raise SystemExit(f"Input directory not found: {input_dir}") + inventory = load_signatures(input_dir) + output_path = Path(args.output) + write_markdown(inventory, output_path, input_dir) + print(f"Wrote inventory for {len(inventory)} aliases to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/type3_to_cff.py b/scripts/type3_to_cff.py new file mode 100644 index 0000000000..0aaf132183 --- /dev/null +++ b/scripts/type3_to_cff.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +""" +Convert Stirling PDF Type3 glyph JSON into synthesised fonts using fontTools. + +The input JSON is expected to contain: + - fontId, pageNumber (optional metadata) + - fontMatrix: 3x3 matrix describing the Type3 glyph transform + - glyphs: array of glyph records with keys: + name, code, advanceWidth, bbox, unicode, outline (list of commands) + +The script produces an OpenType CFF font and, when requested, a companion +TrueType font for web-preview usage. Only the fontTools package is required, +avoiding heavyweight build dependencies such as fontmake/ufoLib2. +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +from fontTools.fontBuilder import FontBuilder +from fontTools.misc.fixedTools import otRound +from fontTools.pens.cu2quPen import Cu2QuPen +from fontTools.pens.t2CharStringPen import T2CharStringPen +from fontTools.pens.ttGlyphPen import TTGlyphPen + + +Command = Dict[str, object] +Matrix = Tuple[float, float, float, float, float, float] + + +@dataclass +class GlyphSource: + name: str + width: float + unicode: Optional[int] + char_code: Optional[int] + outline: Sequence[Command] + + +@dataclass +class GlyphBuildResult: + name: str + width: int + charstring: object + ttf_glyph: Optional[object] + unicode: Optional[int] + char_code: Optional[int] + bounds: Optional[Tuple[float, float, float, float]] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Synthesize fonts from Type3 glyph JSON.") + parser.add_argument("--input", required=True, help="Path to glyph JSON emitted by the backend") + parser.add_argument("--otf-output", required=True, help="Destination path for the CFF/OTF font") + parser.add_argument("--ttf-output", help="Optional destination path for a TrueType font") + parser.add_argument("--family-name", default="Type3 Synth", help="Family name for the output") + parser.add_argument("--style-name", default="Regular", help="Style name for the output") + parser.add_argument("--units-per-em", type=int, default=1000, help="Units per EM value") + parser.add_argument("--cu2qu-error", type=float, default=1.0, help="Max error for cubic→quadratic conversion") + return parser.parse_args() + + +def load_json(path: Path) -> Dict[str, object]: + try: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + except Exception as exc: # pragma: no cover - fatal configuration error + print(f"ERROR: Failed to load glyph JSON '{path}': {exc}", file=sys.stderr) + sys.exit(2) + + +def parse_font_matrix(rows: Optional[Iterable[Iterable[float]]]) -> Matrix: + """ + Retrieve the raw 2×3 FontMatrix entries for diagnostics. Type3 glyph + outlines in our extractor are emitted in their native coordinate system, so + the returned matrix is currently informational only. + """ + if not rows: + return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) + values: List[List[float]] = [] + for row in rows: + try: + values.append([float(col) for col in row]) + except (TypeError, ValueError): + return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) + if len(values) < 3 or len(values[0]) < 2 or len(values[1]) < 2: + return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) + return ( + float(values[0][0]), + float(values[0][1]), + float(values[1][0]), + float(values[1][1]), + float(values[2][0]), + float(values[2][1]), + ) + + +def resolve_width(raw_width: float, default: int) -> int: + try: + value = float(raw_width) + except (TypeError, ValueError): + return default + if not math.isfinite(value) or value <= 0: + return default + width = otRound(value) + return width if width > 0 else default + + +def quadratic_to_cubic( + current: Tuple[float, float], + ctrl: Tuple[float, float], + end: Tuple[float, float], +) -> Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]: + """ + Convert a quadratic Bézier segment to cubic control points. + """ + c1 = ( + current[0] + (2.0 / 3.0) * (ctrl[0] - current[0]), + current[1] + (2.0 / 3.0) * (ctrl[1] - current[1]), + ) + c2 = ( + end[0] + (2.0 / 3.0) * (ctrl[0] - end[0]), + end[1] + (2.0 / 3.0) * (ctrl[1] - end[1]), + ) + return c1, c2, end + + +def iterate_glyphs(data: Dict[str, object]) -> List[GlyphSource]: + glyph_records = data.get("glyphs") or [] + sources: List[GlyphSource] = [] + for index, record in enumerate(glyph_records, start=1): + if not isinstance(record, dict): + continue + name = record.get("name") + if not isinstance(name, str) or not name: + name = f"g{index}" + width = record.get("advanceWidth") + if not isinstance(width, (int, float)) or math.isnan(width): + width = 1000.0 + unicode_value = record.get("unicode") + if not isinstance(unicode_value, int) or unicode_value <= 0: + unicode_value = None + char_code_value = record.get("charCode") + if not isinstance(char_code_value, int): + char_code_value = record.get("code") + if not isinstance(char_code_value, int): + char_code_value = record.get("charCodeRaw") + if not isinstance(char_code_value, int) or not (0 <= char_code_value <= 0x10FFFF): + char_code_value = None + outline = record.get("outline") + if not isinstance(outline, list): + outline = [] + sources.append( + GlyphSource( + name=name, + width=float(width), + unicode=unicode_value, + char_code=char_code_value, + outline=outline)) + return sources + + +def build_cff_charstring( + glyph: GlyphSource, + width: int, +) -> Tuple[object, Optional[Tuple[float, float, float, float]]]: + pen = T2CharStringPen(width=width, glyphSet=None) + bounds = [math.inf, math.inf, -math.inf, -math.inf] + + def update_bounds(point: Tuple[float, float]) -> None: + x, y = point + bounds[0] = min(bounds[0], x) + bounds[1] = min(bounds[1], y) + bounds[2] = max(bounds[2], x) + bounds[3] = max(bounds[3], y) + + current: Optional[Tuple[float, float]] = None + start_point: Optional[Tuple[float, float]] = None + open_path = False + + for command in glyph.outline: + if not isinstance(command, dict): + continue + op = command.get("cmd") + if op == "M": + if open_path: + pen.endPath() + open_path = False + point = (float(command.get("x", 0.0)), float(command.get("y", 0.0))) + pen.moveTo(point) + update_bounds(point) + current = point + start_point = point + open_path = True + elif op == "L" and current is not None: + point = (float(command.get("x", current[0])), float(command.get("y", current[1]))) + pen.lineTo(point) + update_bounds(point) + current = point + elif op == "C" and current is not None: + ctrl1 = ( + float(command.get("x1", current[0])), + float(command.get("y1", current[1])), + ) + ctrl2 = ( + float(command.get("x2", current[0])), + float(command.get("y2", current[1])), + ) + end = ( + float(command.get("x", current[0])), + float(command.get("y", current[1])), + ) + pen.curveTo(ctrl1, ctrl2, end) + update_bounds(ctrl1) + update_bounds(ctrl2) + update_bounds(end) + current = end + elif op == "Q" and current is not None: + ctrl = ( + float(command.get("x1", current[0])), + float(command.get("y1", current[1])), + ) + end = ( + float(command.get("x", current[0])), + float(command.get("y", current[1])), + ) + c1, c2, end_point = quadratic_to_cubic(current, ctrl, end) + pen.curveTo(c1, c2, end_point) + update_bounds(ctrl) + update_bounds(end_point) + current = end_point + elif op == "Z" and open_path: + pen.closePath() + open_path = False + if start_point is not None: + current = start_point + # Ignore unsupported commands silently. + + if open_path: + pen.endPath() + + charstring = pen.getCharString() + bbox = None + if bounds[0] <= bounds[2] and bounds[1] <= bounds[3]: + bbox = (bounds[0], bounds[1], bounds[2], bounds[3]) + return charstring, bbox + + +def build_ttf_glyph(glyph: GlyphSource, max_error: float) -> Optional[object]: + pen = TTGlyphPen(glyphSet=None) + draw_pen = Cu2QuPen(pen, max_error, reverse_direction=False) + + current_exists = False + + for command in glyph.outline: + if not isinstance(command, dict): + continue + op = command.get("cmd") + if op == "M": + x = float(command.get("x", 0.0)) + y = float(command.get("y", 0.0)) + draw_pen.moveTo((x, y)) + current_exists = True + elif op == "L" and current_exists: + x = float(command.get("x", 0.0)) + y = float(command.get("y", 0.0)) + draw_pen.lineTo((x, y)) + elif op == "C" and current_exists: + ctrl1 = (float(command.get("x1", 0.0)), float(command.get("y1", 0.0))) + ctrl2 = (float(command.get("x2", 0.0)), float(command.get("y2", 0.0))) + end = (float(command.get("x", 0.0)), float(command.get("y", 0.0))) + draw_pen.curveTo(ctrl1, ctrl2, end) + elif op == "Q" and current_exists: + ctrl = (float(command.get("x1", 0.0)), float(command.get("y1", 0.0))) + end = (float(command.get("x", 0.0)), float(command.get("y", 0.0))) + draw_pen.qCurveTo(ctrl, end) + elif op == "Z" and current_exists: + draw_pen.closePath() + current_exists = False + + if current_exists: + draw_pen.endPath() + + try: + glyph_obj = pen.glyph() + except Exception: + return None + return glyph_obj + + +def synthesise_fonts( + data: Dict[str, object], + otf_output: Path, + ttf_output: Optional[Path], + family_name: str, + style_name: str, + units_per_em: int, + cu2qu_error: float, +) -> None: + _font_matrix = parse_font_matrix(data.get("fontMatrix")) + glyphs = iterate_glyphs(data) + + results: List[GlyphBuildResult] = [] + global_y_min = math.inf + global_y_max = -math.inf + + default_width = max(1, units_per_em // 2) + + for glyph in glyphs: + width = resolve_width(glyph.width, default_width) + charstring, bounds = build_cff_charstring(glyph, width) + ttf_glyph = None + if ttf_output is not None: + ttf_glyph = build_ttf_glyph(glyph, cu2qu_error) + if ttf_glyph is not None: + ttf_glyph.width = width + if bounds is not None: + global_y_min = min(global_y_min, bounds[1]) + global_y_max = max(global_y_max, bounds[3]) + results.append( + GlyphBuildResult( + name=glyph.name, + width=width, + charstring=charstring, + ttf_glyph=ttf_glyph, + unicode=glyph.unicode, + char_code=glyph.char_code, + bounds=bounds, + ) + ) + + if not results: + raise RuntimeError("No glyphs provided in input JSON") + + ascent = global_y_max if math.isfinite(global_y_max) else units_per_em * 0.8 + descent = global_y_min if math.isfinite(global_y_min) else -units_per_em * 0.2 + ascent = otRound(ascent) + descent = otRound(descent) + if ascent <= 0: + ascent = otRound(units_per_em * 0.8) + if descent >= 0: + descent = -otRound(units_per_em * 0.2) + + glyph_order = [".notdef"] + [result.name for result in results] + horizontal_metrics = {result.name: (result.width, 0) for result in results} + horizontal_metrics[".notdef"] = (default_width, 0) + + cmap: Dict[int, str] = {} + next_private = 0xF000 + for result in results: + code_point = result.unicode + if code_point is None: + raw_code = result.char_code + if raw_code is not None: + code_point = raw_code + else: + code_point = next_private + next_private += 1 + cmap[code_point] = result.name + + notdef_pen = T2CharStringPen(width=default_width, glyphSet=None) + notdef_pen.endPath() + charstrings = {result.name: result.charstring for result in results} + charstrings[".notdef"] = notdef_pen.getCharString() + + name_table_entries = { + "familyName": family_name, + "styleName": style_name, + "psName": f"{family_name.replace(' ', '')}-{style_name}", + "fullName": f"{family_name} {style_name}", + } + + # Build OTF (CFF) font. + fb = FontBuilder(units_per_em, isTTF=False) + fb.setupGlyphOrder(glyph_order) + fb.setupCharacterMap(cmap) + fb.setupHorizontalMetrics(horizontal_metrics) + fb.setupHorizontalHeader(ascent=ascent, descent=descent) + fb.setupOS2( + sTypoAscender=ascent, + sTypoDescender=descent, + usWinAscent=max(ascent, 0), + usWinDescent=abs(min(descent, 0)), + sxHeight=otRound(units_per_em * 0.5), + sCapHeight=otRound(units_per_em * 0.7), + ) + fb.setupNameTable(name_table_entries) + fb.setupPost() + fb.setupCFF( + name_table_entries["psName"], + { + "FullName": name_table_entries["fullName"], + "FamilyName": name_table_entries["familyName"], + "Weight": style_name, + }, + charstrings, + {"BlueValues": []}, + ) + fb.font.save(str(otf_output)) + + if ttf_output is None: + return + + glyph_objects: Dict[str, object] = {} + empty_pen = TTGlyphPen(None) + empty_pen.moveTo((0, 0)) + empty_pen.lineTo((0, 0)) + empty_pen.closePath() + empty_glyph = empty_pen.glyph() + empty_glyph.width = default_width + glyph_objects[".notdef"] = empty_glyph + for result in results: + glyph_obj = result.ttf_glyph + if glyph_obj is None: + temp_pen = TTGlyphPen(None) + temp_pen.moveTo((0, 0)) + temp_pen.lineTo((0, 0)) + temp_pen.closePath() + glyph_obj = temp_pen.glyph() + glyph_obj.width = result.width + glyph_objects[result.name] = glyph_obj + + ttf_fb = FontBuilder(units_per_em, isTTF=True) + ttf_fb.setupGlyphOrder(glyph_order) + ttf_fb.setupCharacterMap(cmap) + ttf_fb.setupHorizontalMetrics(horizontal_metrics) + ttf_fb.setupHorizontalHeader(ascent=ascent, descent=descent) + ttf_fb.setupOS2( + sTypoAscender=ascent, + sTypoDescender=descent, + usWinAscent=max(ascent, 0), + usWinDescent=abs(min(descent, 0)), + sxHeight=otRound(units_per_em * 0.5), + sCapHeight=otRound(units_per_em * 0.7), + ) + ttf_fb.setupNameTable(name_table_entries) + ttf_fb.setupPost() + ttf_fb.setupGlyf(glyph_objects) + ttf_fb.setupDummyDSIG() + ttf_fb.font.save(str(ttf_output)) + + +def main() -> None: + args = parse_args() + input_path = Path(args.input).resolve() + otf_output = Path(args.otf_output).resolve() + ttf_output = Path(args.ttf_output).resolve() if args.ttf_output else None + + data = load_json(input_path) + try: + synthesise_fonts( + data=data, + otf_output=otf_output, + ttf_output=ttf_output, + family_name=args.family_name, + style_name=args.style_name, + units_per_em=args.units_per_em, + cu2qu_error=args.cu2qu_error, + ) + except Exception as exc: + print(f"ERROR: Failed to generate fonts: {exc}", file=sys.stderr) + if otf_output.exists(): + otf_output.unlink() + if ttf_output and ttf_output.exists(): + ttf_output.unlink() + sys.exit(1) + + message = f"Generated font at {otf_output}" + if ttf_output: + message += f" and {ttf_output}" + print(message, file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/scripts/update_type3_library.py b/scripts/update_type3_library.py new file mode 100644 index 0000000000..fe4068e0f2 --- /dev/null +++ b/scripts/update_type3_library.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +Synchronize Type3 library index entries with captured signature dumps. + +The script scans docs/type3/signatures/*.json (or a custom --signatures-dir), +matches each font by alias/signature to app/core/src/main/resources/type3/library/index.json, +and updates the entry's signatures / glyphCoverage / aliases / source fields. + +Usage: + scripts/update_type3_library.py --apply + +Run without --apply to see a dry-run summary. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections import defaultdict +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SIGNATURES = REPO_ROOT / "docs" / "type3" / "signatures" +DEFAULT_INDEX = ( + REPO_ROOT / "app" / "core" / "src" / "main" / "resources" / "type3" / "library" / "index.json" +) + + +def normalize_alias(value: Optional[str]) -> Optional[str]: + if not value: + return None + trimmed = value.strip() + plus = trimmed.find("+") + if plus >= 0 and plus < len(trimmed) - 1: + trimmed = trimmed[plus + 1 :] + lowered = trimmed.lower() + return lowered if lowered else None + + +def load_json(path: Path): + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def dump_json(path: Path, data) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + + +def iter_signature_fonts(signature_file: Path): + payload = load_json(signature_file) + pdf_source = payload.get("pdf") + for font in payload.get("fonts", []): + alias = font.get("alias") or font.get("baseName") + normalized = normalize_alias(alias) or normalize_alias(font.get("baseName")) + yield { + "alias_raw": alias, + "alias": normalized, + "baseName": font.get("baseName"), + "signature": font.get("signature"), + "glyphCoverage": font.get("glyphCoverage") or [], + "pdf": pdf_source, + "file": signature_file, + } + + +def make_alias_index(entries: List[Dict]) -> Tuple[Dict[str, Dict], Dict[str, Dict]]: + alias_index: Dict[str, Dict] = {} + signature_index: Dict[str, Dict] = {} + for entry in entries: + for alias in entry.get("aliases", []) or []: + normalized = normalize_alias(alias) + if normalized: + alias_index.setdefault(normalized, entry) + base_name_alias = normalize_alias(entry.get("label")) + if base_name_alias: + alias_index.setdefault(base_name_alias, entry) + for signature in entry.get("signatures", []) or []: + signature_index.setdefault(signature.lower(), entry) + return alias_index, signature_index + + +def ensure_list(container: Dict, key: str) -> List: + value = container.get(key) + if isinstance(value, list): + return value + value = [] + container[key] = value + return value + + +def merge_sorted_unique(values: Iterable[int]) -> List[int]: + return sorted({int(v) for v in values if isinstance(v, int)}) + + +def normalize_source_path(pdf_path: Optional[str]) -> Optional[str]: + if not pdf_path: + return None + try: + source = Path(pdf_path) + rel = source.relative_to(REPO_ROOT) + except Exception: + rel = Path(pdf_path) + return str(rel).replace("\\", "/") + + +def update_library( + signatures_dir: Path, index_path: Path, apply_changes: bool +) -> Tuple[int, int, List[Tuple[str, Path]]]: + entries = load_json(index_path) + alias_index, signature_index = make_alias_index(entries) + + modifications = 0 + updated_entries = set() + unmatched: List[Tuple[str, Path]] = [] + + signature_files = sorted(signatures_dir.glob("*.json")) + if not signature_files: + print(f"No signature JSON files found under {signatures_dir}", file=sys.stderr) + return 0, 0, unmatched + + for sig_file in signature_files: + for font in iter_signature_fonts(sig_file): + signature = font["signature"] + norm_signature = signature.lower() if signature else None + alias = font["alias"] + + entry = None + if norm_signature and norm_signature in signature_index: + entry = signature_index[norm_signature] + elif alias and alias in alias_index: + entry = alias_index[alias] + + if entry is None: + unmatched.append((font.get("baseName") or font.get("alias_raw") or "unknown", sig_file)) + continue + + entry_modified = False + + # Signatures + if signature: + signature_list = ensure_list(entry, "signatures") + if signature not in signature_list: + signature_list.append(signature) + entry_modified = True + signature_index[signature.lower()] = entry + + # Aliases + alias_raw = font.get("alias_raw") + if alias_raw: + aliases = ensure_list(entry, "aliases") + if alias_raw not in aliases: + aliases.append(alias_raw) + entry_modified = True + normalized = normalize_alias(alias_raw) + if normalized: + alias_index.setdefault(normalized, entry) + + # Glyph coverage + coverage = font.get("glyphCoverage") or [] + if coverage: + existing = set(entry.get("glyphCoverage", [])) + merged = merge_sorted_unique(list(existing) + coverage) + if merged != entry.get("glyphCoverage"): + entry["glyphCoverage"] = merged + entry_modified = True + + # Source PDF + pdf_source = normalize_source_path(font.get("pdf")) + if pdf_source and not entry.get("source"): + entry["source"] = pdf_source + entry_modified = True + + if entry_modified: + modifications += 1 + updated_entries.add(entry.get("id", "")) + + if apply_changes and modifications > 0: + dump_json(index_path, entries) + + return modifications, len(updated_entries), unmatched + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Update Type3 library index using signature dumps.") + parser.add_argument( + "--signatures-dir", + type=Path, + default=DEFAULT_SIGNATURES, + help=f"Directory containing signature JSON files (default: {DEFAULT_SIGNATURES})", + ) + parser.add_argument( + "--index", + type=Path, + default=DEFAULT_INDEX, + help=f"Path to type3/library/index.json (default: {DEFAULT_INDEX})", + ) + parser.add_argument( + "--apply", + action="store_true", + help="Write changes back to the index file. Without this flag the script runs in dry-run mode.", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + signatures_dir = args.signatures_dir if args.signatures_dir.is_absolute() else (REPO_ROOT / args.signatures_dir) + index_path = args.index if args.index.is_absolute() else (REPO_ROOT / args.index) + + if not signatures_dir.exists(): + print(f"Signature directory not found: {signatures_dir}", file=sys.stderr) + sys.exit(2) + if not index_path.exists(): + print(f"Index file not found: {index_path}", file=sys.stderr) + sys.exit(2) + + modifications, updated_entries, unmatched = update_library( + signatures_dir, index_path, apply_changes=args.apply + ) + + mode = "APPLIED" if args.apply else "DRY-RUN" + print( + f"[{mode}] Processed signatures under {signatures_dir}. " + f"Updated entries: {updated_entries}, individual modifications: {modifications}." + ) + + if unmatched: + print("\nUnmatched fonts (no library entry yet):") + for alias, sig_file in unmatched: + print(f" - {alias} (from {sig_file})") + print("Add these fonts to index.json with the proper payload before rerunning.") + + if modifications == 0: + print("No changes detected; index.json already matches captured signatures.") + + +if __name__ == "__main__": + main()