diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java index 830efd511..d544d6dda 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java @@ -71,10 +71,12 @@ import org.apache.xmpbox.schema.PDFAIdentificationSchema; import org.apache.xmpbox.schema.XMPBasicSchema; import org.apache.xmpbox.xml.DomXmpParser; import org.apache.xmpbox.xml.XmpSerializer; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Operation; @@ -99,6 +101,7 @@ public class ConvertPDFToPDFA { private static final Pattern NON_PRINTABLE_ASCII = Pattern.compile("[^\\x20-\\x7E]"); private final RuntimePathConfig runtimePathConfig; + private final stirling.software.SPDF.service.VeraPDFService veraPDFService; private static final String ICC_RESOURCE_PATH = "/icc/sRGB2014.icc"; private static final int PDFA_COMPATIBILITY_POLICY = 1; @@ -587,7 +590,8 @@ public class ConvertPDFToPDFA { if (isPdfX) { return handlePdfXConversion(inputFile, outputFormat); } else { - return handlePdfAConversion(inputFile, outputFormat); + return handlePdfAConversion( + inputFile, outputFormat, request.getStrict() != null && request.getStrict()); } } @@ -1793,7 +1797,7 @@ public class ConvertPDFToPDFA { } private ResponseEntity handlePdfAConversion( - MultipartFile inputFile, String outputFormat) throws Exception { + MultipartFile inputFile, String outputFormat, boolean strict) throws Exception { PdfaProfile profile = PdfaProfile.fromRequest(outputFormat); // Get the original filename without extension @@ -1822,6 +1826,10 @@ public class ConvertPDFToPDFA { validateAndWarnPdfA(converted, profile, "Ghostscript"); + if (strict) { + verifyStrictCompliance(converted); + } + return WebResponseUtils.bytesToWebResponse( converted, outputFilename, MediaType.APPLICATION_PDF); } catch (IOException | InterruptedException e) { @@ -1839,14 +1847,42 @@ public class ConvertPDFToPDFA { // Validate with PDFBox preflight and warn if issues found validateAndWarnPdfA(converted, profile, "PDFBox/LibreOffice"); + if (strict) { + verifyStrictCompliance(converted); + } + return WebResponseUtils.bytesToWebResponse( converted, outputFilename, MediaType.APPLICATION_PDF); - } finally { deleteQuietly(workingDir); } } + private void verifyStrictCompliance(byte[] pdfBytes) throws IOException { + try (InputStream is = new ByteArrayInputStream(pdfBytes)) { + List results = + veraPDFService.validatePDF(is); + boolean isCompliant = results.stream().anyMatch(result -> result.isCompliant()); + if (!isCompliant) { + String details = + results.stream() + .map(r -> r.getStandard() + ": " + r.getComplianceSummary()) + .collect(Collectors.joining("; ")); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Strict PDF/A mode enabled: Conversion is not perfectly compliant. Details: " + + details); + } + } catch (Exception e) { + if (e instanceof ResponseStatusException) { + throw (ResponseStatusException) e; + } + log.error("Error during strict PDF/A verification", e); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Error during strict PDF/A verification"); + } + } + private Path sanitizePdfWithPdfBox(Path inputPdf, boolean addWhiteBackground) { try { Path sanitizedPath = diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java index 7cbd3d19b..bb0520a4b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java @@ -16,4 +16,9 @@ public class PdfToPdfARequest extends PDFFile { requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx"}) private String outputFormat; + + @Schema( + description = + "If true, the conversion will fail if the output is not perfectly compliant") + private Boolean strict = false; } diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index e0a761d4f..6e9f217ab 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -1303,6 +1303,8 @@ outputFormat = "Output Format" pdfaNote = "PDF/A-1b is more compatible, PDF/A-2b supports more features, PDF/A-3b supports embedded files." pdfaFormat = "PDF/A Format" pdfaDigitalSignatureWarning = "The PDF contains a digital signature. This will be removed in the next step." +strictMode = "Strict Mode" +strictModeDesc = "Error if conversion is not perfect (uses VeraPDF verification)" pdfxDigitalSignatureWarning = "The PDF contains a digital signature. This will be removed in the next step." pdfxDescription = "PDF/X is an ISO standard PDF subset for reliable printing and graphics exchange." fileFormat = "File Format" diff --git a/frontend/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/src/core/components/tools/convert/ConvertSettings.tsx index 5905ae4b9..b21f4224a 100644 --- a/frontend/src/core/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/core/components/tools/convert/ConvertSettings.tsx @@ -146,7 +146,8 @@ const ConvertSettings = ({ includeAllRecipients: false, }); onParameterChange('pdfaOptions', { - outputFormat: 'pdfa-1', + outputFormat: 'pdfa-2b', + strict: false, }); onParameterChange('pdfxOptions', { outputFormat: 'pdfx', @@ -234,7 +235,8 @@ const ConvertSettings = ({ includeAllRecipients: false, }); onParameterChange('pdfaOptions', { - outputFormat: 'pdfa-1', + outputFormat: 'pdfa-2b', + strict: false, }); onParameterChange('pdfxOptions', { outputFormat: 'pdfx', diff --git a/frontend/src/core/components/tools/convert/ConvertToPdfaSettings.tsx b/frontend/src/core/components/tools/convert/ConvertToPdfaSettings.tsx index 7325c8314..85746688e 100644 --- a/frontend/src/core/components/tools/convert/ConvertToPdfaSettings.tsx +++ b/frontend/src/core/components/tools/convert/ConvertToPdfaSettings.tsx @@ -1,4 +1,4 @@ -import { Stack, Text, Select, Alert } from '@mantine/core'; +import { Stack, Text, Select, Alert, Checkbox } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { ConvertParameters } from '@app/hooks/tools/convert/useConvertParameters'; import { usePdfSignatureDetection } from '@app/hooks/usePdfSignatureDetection'; @@ -23,8 +23,8 @@ const ConvertToPdfaSettings = ({ const pdfaFormatOptions = [ { value: 'pdfa-1', label: 'PDF/A-1b' }, - { value: 'pdfa', label: 'PDF/A-2b' }, - { value: 'pdfa-3', label: 'PDF/A-3b' } + { value: 'pdfa-2b', label: 'PDF/A-2b' }, + { value: 'pdfa-3b', label: 'PDF/A-3b' } ]; return ( @@ -45,7 +45,7 @@ const ConvertToPdfaSettings = ({ value={parameters.pdfaOptions.outputFormat} onChange={(value) => onParameterChange('pdfaOptions', { ...parameters.pdfaOptions, - outputFormat: value || 'pdfa-1' + outputFormat: value || 'pdfa-2b' })} data={pdfaFormatOptions} disabled={disabled || isChecking} @@ -56,6 +56,17 @@ const ConvertToPdfaSettings = ({ {t("convert.pdfaNote", "PDF/A-1b is more compatible, PDF/A-2b supports more features, PDF/A-3b supports embedded files.")} + + onParameterChange('pdfaOptions', { + ...parameters.pdfaOptions, + strict: event.currentTarget.checked + })} + disabled={disabled || isChecking} + /> ); }; diff --git a/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts b/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts index c13ab3211..082a2f975 100644 --- a/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts +++ b/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts @@ -48,7 +48,8 @@ export function useSuggestedAutomations(): SuggestedAutomation[] { fromExtension: 'pdf', toExtension: 'pdfa', pdfaOptions: { - outputFormat: 'pdfa-1', + outputFormat: 'pdfa-2b', + strict: false, } } }, diff --git a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts index 0f78cdbb0..2399ceaaf 100644 --- a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts @@ -79,6 +79,7 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile formData.append("includeAllRecipients", emailOptions.includeAllRecipients.toString()); } else if (fromExtension === 'pdf' && toExtension === 'pdfa') { formData.append("outputFormat", pdfaOptions.outputFormat); + formData.append("strict", String(!!pdfaOptions.strict)); } else if (fromExtension === 'pdf' && toExtension === 'pdfx') { // Use PDF/A endpoint with PDF/X format parameter formData.append("outputFormat", pdfxOptions?.outputFormat || 'pdfx'); diff --git a/frontend/src/core/hooks/tools/convert/useConvertParameters.test.ts b/frontend/src/core/hooks/tools/convert/useConvertParameters.test.ts index 340115a81..70299b8bf 100644 --- a/frontend/src/core/hooks/tools/convert/useConvertParameters.test.ts +++ b/frontend/src/core/hooks/tools/convert/useConvertParameters.test.ts @@ -24,7 +24,7 @@ describe('useConvertParameters', () => { expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(10); expect(result.current.parameters.emailOptions.downloadHtml).toBe(false); expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(false); - expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa-1'); + expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa-2b'); }); test('should update individual parameters', () => { @@ -95,7 +95,8 @@ describe('useConvertParameters', () => { act(() => { result.current.updateParameter('pdfaOptions', { - outputFormat: 'pdfa' + outputFormat: 'pdfa', + strict: false }); }); diff --git a/frontend/src/core/hooks/tools/convert/useConvertParameters.ts b/frontend/src/core/hooks/tools/convert/useConvertParameters.ts index 8a065df20..ee812927f 100644 --- a/frontend/src/core/hooks/tools/convert/useConvertParameters.ts +++ b/frontend/src/core/hooks/tools/convert/useConvertParameters.ts @@ -35,6 +35,7 @@ export interface ConvertParameters extends BaseParameters { }; pdfaOptions: { outputFormat: string; + strict?: boolean; }; pdfxOptions: { outputFormat: string; @@ -93,7 +94,8 @@ export const defaultParameters: ConvertParameters = { includeAllRecipients: false, }, pdfaOptions: { - outputFormat: 'pdfa-1', + outputFormat: 'pdfa-2b', + strict: false, }, pdfxOptions: { outputFormat: 'pdfx', diff --git a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx index cacb1ab37..40bc8583d 100644 --- a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx @@ -105,7 +105,7 @@ describe('Convert Tool Integration Tests', () => { beforeEach(() => { vi.clearAllMocks(); // Setup default apiClient mock - mockedApiClient.post = vi.fn(); + mockedApiClient.post = vi.fn() as any; }); afterEach(() => { @@ -150,7 +150,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -232,7 +233,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -292,7 +294,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -361,7 +364,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -434,7 +438,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -505,7 +510,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -572,7 +578,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -636,7 +643,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -702,7 +710,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -765,7 +774,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -834,7 +844,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' @@ -902,7 +913,8 @@ describe('Convert Tool Integration Tests', () => { includeAllRecipients: false }, pdfaOptions: { - outputFormat: '' + outputFormat: '', + strict: false }, pdfxOptions: { outputFormat: 'pdfx' diff --git a/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx index 9994d36a7..a5bea8e88 100644 --- a/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -396,7 +396,8 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { paramsResult.current.updateParameter('fromExtension', 'pdf'); paramsResult.current.updateParameter('toExtension', 'pdfa'); paramsResult.current.updateParameter('pdfaOptions', { - outputFormat: 'pdfa' + outputFormat: 'pdfa', + strict: false }); }); @@ -409,6 +410,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData; expect(formData.get('outputFormat')).toBe('pdfa'); + expect(formData.get('strict')).toBe('false'); expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/pdf/pdfa', expect.any(FormData), { responseType: 'blob' });