From 075de9487ac7612033d4e9db8a9e574ea1df753c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Tue, 11 Nov 2025 14:46:32 +0100 Subject: [PATCH 1/5] [V2] feat(security): add PDF standards verification feature using veraPDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented `PDFVerificationRequest` and `PDFVerificationResult` models for validation requests and responses - Developed `VeraPDFService` to validate PDFs against specific or auto-detected standards - Added `VerifyPDFController` with an endpoint for PDF verification - Integrated veraPDF dependencies into project build file - Deprecated unused `/verify-pdf` form in `SecurityWebController` - Updated `EndpointConfiguration` to include the new `verify-pdf` endpoint Signed-off-by: Balázs Szücs --- app/core/build.gradle | 7 + .../SPDF/config/EndpointConfiguration.java | 10 +- .../api/security/VerifyPDFController.java | 102 ++++++++++ .../controller/web/SecurityWebController.java | 8 + .../api/security/PDFVerificationRequest.java | 22 +++ .../api/security/PDFVerificationResult.java | 44 +++++ .../software/SPDF/service/VeraPDFService.java | 186 ++++++++++++++++++ 7 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/security/VerifyPDFController.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationRequest.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationResult.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java diff --git a/app/core/build.gradle b/app/core/build.gradle index d31125a26..e3ad9ca97 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -66,6 +66,13 @@ dependencies { implementation "org.apache.pdfbox:preflight:$pdfboxVersion" implementation "org.apache.pdfbox:xmpbox:$pdfboxVersion" + implementation 'org.verapdf:validation-model:1.28.2' + + // veraPDF still uses javax.xml.bind, not the new jakarta namespace + implementation 'javax.xml.bind:jaxb-api:2.3.1' + implementation 'com.sun.xml.bind:jaxb-impl:2.3.9' + implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1' + // https://mvnrepository.com/artifact/technology.tabula/tabula implementation ('technology.tabula:tabula:1.0.5') { exclude group: 'org.slf4j', module: 'slf4j-simple' diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index edb2e96cf..eb9afbe0f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -273,6 +273,7 @@ public class EndpointConfiguration { addEndpointToGroup("Security", "auto-redact"); addEndpointToGroup("Security", "redact"); addEndpointToGroup("Security", "validate-signature"); + addEndpointToGroup("Security", "verify-pdf"); addEndpointToGroup("Security", "stamp"); addEndpointToGroup("Security", "sign"); @@ -389,6 +390,8 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "add-attachments"); addEndpointToGroup("Java", "compress-pdf"); addEndpointToGroup("rar", "pdf-to-cbr"); + addEndpointToGroup("Java", "pdf-to-video"); + addEndpointToGroup("Java", "verify-pdf"); // Javascript addEndpointToGroup("Javascript", "pdf-organizer"); @@ -440,6 +443,9 @@ public class EndpointConfiguration { addEndpointToGroup("Weasyprint", "markdown-to-pdf"); addEndpointToGroup("Weasyprint", "eml-to-pdf"); + // veraPDF dependent endpoints + addEndpointToGroup("veraPDF", "verify-pdf"); + // Pdftohtml dependent endpoints addEndpointToGroup("Pdftohtml", "pdf-to-html"); addEndpointToGroup("Pdftohtml", "pdf-to-markdown"); @@ -489,7 +495,9 @@ public class EndpointConfiguration { || "Javascript".equals(group) || "Weasyprint".equals(group) || "Pdftohtml".equals(group) - || "rar".equals(group); + || "rar".equals(group) + || "FFmpeg".equals(group) + || "veraPDF".equals(group); } private boolean isEndpointEnabledDirectly(String endpoint) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/VerifyPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/VerifyPDFController.java new file mode 100644 index 000000000..2cd111516 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/VerifyPDFController.java @@ -0,0 +1,102 @@ +package stirling.software.SPDF.controller.api.security; + +import java.io.IOException; +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.verapdf.core.EncryptedPdfException; +import org.verapdf.core.ModelParsingException; +import org.verapdf.core.ValidationException; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.api.security.PDFVerificationRequest; +import stirling.software.SPDF.model.api.security.PDFVerificationResult; +import stirling.software.SPDF.service.VeraPDFService; +import stirling.software.common.util.ExceptionUtils; + +@RestController +@RequestMapping("/api/v1/security") +@Tag(name = "Security", description = "Security APIs") +@RequiredArgsConstructor +@Slf4j +public class VerifyPDFController { + + private final VeraPDFService veraPDFService; + + @Operation( + summary = "Verify PDF Standards Compliance", + description = + "Validates PDF files against PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards" + + " using veraPDF. Can auto-detect declared standards or verify against" + + " a specific standard. Input:PDF Output:JSON Type:SISO") + @PostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> verifyPDF( + @ModelAttribute PDFVerificationRequest request) { + + MultipartFile file = request.getFileInput(); + String standard = request.getStandard(); + + if (file == null || file.isEmpty()) { + throw ExceptionUtils.createRuntimeException( + "error.pdfRequired", "PDF file is required", null); + } + + try { + List results; + + if (standard != null && !standard.trim().isEmpty()) { + log.info( + "Verifying PDF '{}' against standard: {}", + file.getOriginalFilename(), + standard); + PDFVerificationResult result = + veraPDFService.validatePDF(file.getInputStream(), standard.trim()); + results = List.of(result); + } else { + log.info("Auto-detecting standards in PDF '{}'", file.getOriginalFilename()); + results = veraPDFService.validateAllDeclaredStandards(file.getInputStream()); + } + + log.info( + "Verification complete for '{}': {} standard(s) checked", + file.getOriginalFilename(), + results.size()); + + return ResponseEntity.ok(results); + + } catch (ValidationException e) { + log.error("Validation exception for file: {}", file.getOriginalFilename(), e); + throw ExceptionUtils.createRuntimeException( + "error.validationFailed", "PDF validation failed: {0}", e, e.getMessage()); + } catch (ModelParsingException e) { + log.error("Model parsing exception for file: {}", file.getOriginalFilename(), e); + throw ExceptionUtils.createRuntimeException( + "error.modelParsingFailed", "PDF model parsing failed: {0}", e, e.getMessage()); + } catch (EncryptedPdfException e) { + log.error("Encrypted PDF exception for file: {}", file.getOriginalFilename(), e); + throw ExceptionUtils.createRuntimeException( + "error.encryptedPdf", + "Cannot verify encrypted PDF. Please remove password first: {0}", + e, + e.getMessage()); + } catch (IOException e) { + log.error("IO exception for file: {}", file.getOriginalFilename(), e); + throw ExceptionUtils.createRuntimeException( + "error.ioException", + "IO error during PDF verification: {0}", + e, + e.getMessage()); + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java index e2a34e140..e6b5e8da8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java @@ -72,6 +72,14 @@ public class SecurityWebController { return "security/validate-signature"; } + @Deprecated + @Hidden + // @GetMapping("/verify-pdf") + public String verifyPdfForm(Model model) { + model.addAttribute("currentPage", "verify-pdf"); + return "security/verify-pdf"; + } + @Deprecated // @GetMapping("/remove-cert-sign") @Hidden diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationRequest.java new file mode 100644 index 000000000..4f2133ae3 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationRequest.java @@ -0,0 +1,22 @@ +package stirling.software.SPDF.model.api.security; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import stirling.software.common.model.api.PDFFile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class PDFVerificationRequest extends PDFFile { + + @Schema( + description = + "Specific PDF standard to verify against (e.g., '1b', '2a', '3u', 'ua1', 'ua2'," + + " 'wtpdf-1.0'). Leave empty to auto-detect and verify all declared" + + " standards. The response will include both errors (compliance failures)" + + " and warnings (non-critical issues) separately.", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private String standard; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationResult.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationResult.java new file mode 100644 index 000000000..9fd72d520 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationResult.java @@ -0,0 +1,44 @@ +package stirling.software.SPDF.model.api.security; + +import java.util.ArrayList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PDFVerificationResult { + + private String standard; + private String standardName; + private boolean compliant; + private int totalFailures; + private int totalWarnings; + private List failures = new ArrayList<>(); + private List warnings = new ArrayList<>(); + + public void addFailure(ValidationIssue failure) { + this.failures.add(failure); + this.totalFailures = this.failures.size(); + } + + public void addWarning(ValidationIssue warning) { + this.warnings.add(warning); + this.totalWarnings = this.warnings.size(); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ValidationIssue { + private String ruleId; + private String message; + private String location; + private String specification; + private String clause; + private String testNumber; + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java new file mode 100644 index 000000000..8f4b6bb70 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java @@ -0,0 +1,186 @@ +package stirling.software.SPDF.service; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.verapdf.core.EncryptedPdfException; +import org.verapdf.core.ModelParsingException; +import org.verapdf.core.ValidationException; +import org.verapdf.gf.foundry.VeraGreenfieldFoundryProvider; +import org.verapdf.pdfa.Foundries; +import org.verapdf.pdfa.PDFAParser; +import org.verapdf.pdfa.PDFAValidator; +import org.verapdf.pdfa.flavours.PDFAFlavour; +import org.verapdf.pdfa.flavours.PDFFlavours; +import org.verapdf.pdfa.results.TestAssertion; +import org.verapdf.pdfa.results.ValidationResult; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.api.security.PDFVerificationResult; + +@Service +@Slf4j +public class VeraPDFService { + + @PostConstruct + public void initialize() { + try { + VeraGreenfieldFoundryProvider.initialise(); + log.info("veraPDF Greenfield initialized successfully"); + } catch (Exception e) { + log.error("Failed to initialize veraPDF", e); + } + } + + public PDFVerificationResult validatePDF(InputStream pdfStream, String standardString) + throws IOException, ValidationException, ModelParsingException, EncryptedPdfException { + + PDFAFlavour flavour = PDFAFlavour.fromString(standardString); + + try (PDFAParser parser = Foundries.defaultInstance().createParser(pdfStream, flavour)) { + PDFAValidator validator = Foundries.defaultInstance().createValidator(flavour, false); + ValidationResult result = validator.validate(parser); + + return convertToVerificationResult(result); + } + } + + public List validateAllDeclaredStandards(InputStream pdfStream) + throws IOException, ValidationException, ModelParsingException, EncryptedPdfException { + + List results = new ArrayList<>(); + + try (PDFAParser parser = Foundries.defaultInstance().createParser(pdfStream)) { + List detectedFlavours = parser.getFlavours(); + List flavoursToValidate = new ArrayList<>(); + + // Filter for PDF/A, PDF/UA, and WTPDF standards + for (PDFAFlavour flavour : detectedFlavours) { + if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A) + || PDFFlavours.isFlavourFamily( + flavour, PDFAFlavour.SpecificationFamily.PDF_UA) + || PDFFlavours.isFlavourFamily( + flavour, PDFAFlavour.SpecificationFamily.WTPDF)) { + flavoursToValidate.add(flavour); + } + } + + if (flavoursToValidate.isEmpty()) { + log.info("No PDF/A, PDF/UA, or WTPDF standards declared in the document"); + PDFVerificationResult noStandardResult = new PDFVerificationResult(); + noStandardResult.setStandard("none"); + noStandardResult.setStandardName("No standards declared"); + noStandardResult.setCompliant(false); + noStandardResult.setTotalFailures(0); + noStandardResult.setTotalWarnings(0); + results.add(noStandardResult); + return results; + } + + for (PDFAFlavour flavour : flavoursToValidate) { + try { + PDFAValidator validator = + Foundries.defaultInstance().createValidator(flavour, false); + ValidationResult result = validator.validate(parser); + results.add(convertToVerificationResult(result)); + } catch (Exception e) { + log.error("Error validating standard {}: {}", flavour.getId(), e.getMessage()); + PDFVerificationResult errorResult = new PDFVerificationResult(); + errorResult.setStandard(flavour.getId()); + errorResult.setStandardName(getStandardName(flavour)); + errorResult.setCompliant(false); + errorResult.setTotalFailures(1); + errorResult.setTotalWarnings(0); + PDFVerificationResult.ValidationIssue failure = + new PDFVerificationResult.ValidationIssue(); + failure.setMessage("Validation error: " + e.getMessage()); + errorResult.addFailure(failure); + results.add(errorResult); + } + } + } + + return results; + } + + private PDFVerificationResult convertToVerificationResult(ValidationResult result) { + PDFVerificationResult verificationResult = new PDFVerificationResult(); + + PDFAFlavour flavour = result.getPDFAFlavour(); + verificationResult.setStandard(flavour.getId()); + verificationResult.setStandardName(getStandardName(flavour)); + verificationResult.setCompliant(result.isCompliant()); + + // Process all assertions and separate errors from warnings + List assertions = result.getTestAssertions(); + int errorCount = 0; + int warningCount = 0; + + for (TestAssertion assertion : assertions) { + TestAssertion.Status status = assertion.getStatus(); + + // Only process FAILED assertions (PASSED assertions are successful checks) + if (status == TestAssertion.Status.FAILED) { + + PDFVerificationResult.ValidationIssue issue = + new PDFVerificationResult.ValidationIssue(); + issue.setRuleId(assertion.getRuleId().toString()); + issue.setMessage(assertion.getMessage()); + issue.setLocation( + assertion.getLocation() != null + ? assertion.getLocation().toString() + : "Unknown"); + issue.setSpecification( + assertion.getRuleId().getSpecification() != null + ? assertion.getRuleId().getSpecification().toString() + : ""); + issue.setClause(assertion.getRuleId().getClause()); + int testNumber = assertion.getRuleId().getTestNumber(); + issue.setTestNumber(testNumber > 0 ? String.valueOf(testNumber) : ""); + verificationResult.addFailure(issue); + errorCount++; + } + } + + verificationResult.setTotalFailures(errorCount); + verificationResult.setTotalWarnings(warningCount); + + log.debug( + "Validation complete for {}: {} errors, {} warnings", + flavour.getId(), + errorCount, + warningCount); + + return verificationResult; + } + + private String getStandardName(PDFAFlavour flavour) { + String id = flavour.getId(); + String part = flavour.getPart().toString(); + String level = flavour.getLevel().toString(); + + // PDF/A standards + if (!id.isEmpty() && id.charAt(0) == '1' + || !id.isEmpty() && id.charAt(0) == '2' + || !id.isEmpty() && id.charAt(0) == '3' + || !id.isEmpty() && id.charAt(0) == '4') { + return "PDF/A-" + part + (level.isEmpty() ? "" : level); + } + // PDF/UA standards + else if (id.contains("ua")) { + return "PDF/UA-" + part; + } + // WTPDF standards + else if (id.contains("wtpdf")) { + return "WTPDF " + part; + } + + return flavour.toString(); + } +} From 3bdf7c7400031b1ede7febbd3edfe2a063bb9377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Tue, 11 Nov 2025 14:59:35 +0100 Subject: [PATCH 2/5] feat(licenses): extend allowed licenses in configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added "Mozilla Public License, Version 2.0" to allowed licenses - Added "Mozilla Public License 2.0 (MPL-2.0)" to allowed licenses - Added "CDDL+GPL License" to allowed licenses - Added "BSD" to allowed licenses Signed-off-by: Balázs Szücs --- app/allowed-licenses.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index 830ae037a..315c6bb18 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -96,6 +96,22 @@ "moduleName": ".*", "moduleLicense": "MPL 2.0" }, + { + "moduleName": ".*", + "moduleLicense": "Mozilla Public License, Version 2.0" + }, + { + "moduleName": ".*", + "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)" + }, + { + "moduleName": ".*", + "moduleLicense": "CDDL+GPL License" + }, + { + "moduleName": ".*", + "moduleLicense": "BSD" + }, { "moduleName": ".*", "moduleLicense": "UnboundID SCIM2 SDK Free Use License" From 9a7908b5e1ad62ea67ee8638fed4df08a3c27d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Tue, 11 Nov 2025 21:29:59 +0100 Subject: [PATCH 3/5] refactor(security): remove deprecated verifyPdfForm method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deleted the unused and deprecated `verifyPdfForm` method from `SecurityWebController` - Cleaned up associated comments and annotations Signed-off-by: Balázs Szücs --- .../SPDF/controller/web/SecurityWebController.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java index e6b5e8da8..e2a34e140 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java @@ -72,14 +72,6 @@ public class SecurityWebController { return "security/validate-signature"; } - @Deprecated - @Hidden - // @GetMapping("/verify-pdf") - public String verifyPdfForm(Model model) { - model.addAttribute("currentPage", "verify-pdf"); - return "security/verify-pdf"; - } - @Deprecated // @GetMapping("/remove-cert-sign") @Hidden From 9a5a9963b7da2e98e089dd7df8d96a93326ac21f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Fri, 14 Nov 2025 20:59:09 +0100 Subject: [PATCH 4/5] feat(verification): enhance PDF/A and compliance validation using veraPDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expanded `PDFVerificationResult` model to include additional fields: validationProfile, validationProfileName, complianceSummary, and declaredPdfa - Improved `VeraPDFService` to support PDF/A detection and validation by parsing XMP metadata - Added warning classification logic for validation issues based on rules, messages, and clauses - Refactored validation logic to handle multi-standard PDFs with precise error and warning tracking - Enhanced debug logs for better traceability during PDF verification operations Signed-off-by: Balázs Szücs --- .../api/security/PDFVerificationResult.java | 4 + .../software/SPDF/service/VeraPDFService.java | 441 ++++++++++++++---- 2 files changed, 359 insertions(+), 86 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationResult.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationResult.java index 9fd72d520..9845681b2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationResult.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/PDFVerificationResult.java @@ -14,6 +14,10 @@ public class PDFVerificationResult { private String standard; private String standardName; + private String validationProfile; + private String validationProfileName; + private String complianceSummary; + private boolean declaredPdfa; private boolean compliant; private int totalFailures; private int totalWarnings; diff --git a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java index 8f4b6bb70..7ddb146ea 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java @@ -1,10 +1,21 @@ package stirling.software.SPDF.service; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.common.PDMetadata; +import org.apache.xmpbox.XMPMetadata; +import org.apache.xmpbox.schema.PDFAIdentificationSchema; +import org.apache.xmpbox.xml.DomXmpParser; +import org.apache.xmpbox.xml.XmpParsingException; import org.springframework.stereotype.Service; import org.verapdf.core.EncryptedPdfException; import org.verapdf.core.ModelParsingException; @@ -28,6 +39,18 @@ import stirling.software.SPDF.model.api.security.PDFVerificationResult; @Slf4j public class VeraPDFService { + private static final Set WARNING_RULES = + Set.of( + "6.1.13-1", // Recommended metadata properties + "6.7.3-1", // Optional XMP metadata + "6.2.2-2" // Non-critical font issues + ); + + private static final Set CRITICAL_CLAUSE_PREFIXES = Set.of("6.1", "6.2", "6.3", "6.4"); + private static final String NOT_PDFA_STANDARD_ID = "not-pdfa"; + private static final String NOT_PDFA_STANDARD_NAME = + "Not PDF/A (no PDF/A identification metadata)"; + @PostConstruct public void initialize() { try { @@ -38,138 +61,384 @@ public class VeraPDFService { } } - public PDFVerificationResult validatePDF(InputStream pdfStream, String standardString) + public static PDFVerificationResult validatePDF(InputStream pdfStream, String standardString) throws IOException, ValidationException, ModelParsingException, EncryptedPdfException { - PDFAFlavour flavour = PDFAFlavour.fromString(standardString); + byte[] pdfBytes = pdfStream.readAllBytes(); + PDFAFlavour validationFlavour = PDFAFlavour.fromString(standardString); + Optional declaredPdfaFlavour = extractDeclaredPdfaFlavour(pdfBytes); - try (PDFAParser parser = Foundries.defaultInstance().createParser(pdfStream, flavour)) { - PDFAValidator validator = Foundries.defaultInstance().createValidator(flavour, false); + try (PDFAParser parser = + Foundries.defaultInstance() + .createParser(new ByteArrayInputStream(pdfBytes), validationFlavour)) { + PDFAValidator validator = + Foundries.defaultInstance().createValidator(validationFlavour, false); ValidationResult result = validator.validate(parser); - return convertToVerificationResult(result); + return convertToVerificationResult( + result, declaredPdfaFlavour.orElse(null), validationFlavour); } } - public List validateAllDeclaredStandards(InputStream pdfStream) + public static List validateAllDeclaredStandards(InputStream pdfStream) throws IOException, ValidationException, ModelParsingException, EncryptedPdfException { + byte[] pdfBytes = pdfStream.readAllBytes(); + Optional declaredPdfaFlavour = extractDeclaredPdfaFlavour(pdfBytes); List results = new ArrayList<>(); - try (PDFAParser parser = Foundries.defaultInstance().createParser(pdfStream)) { - List detectedFlavours = parser.getFlavours(); - List flavoursToValidate = new ArrayList<>(); + List detectedFlavours; + try (PDFAParser detectionParser = + Foundries.defaultInstance().createParser(new ByteArrayInputStream(pdfBytes))) { + detectedFlavours = detectionParser.getFlavours(); + } - // Filter for PDF/A, PDF/UA, and WTPDF standards - for (PDFAFlavour flavour : detectedFlavours) { - if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A) - || PDFFlavours.isFlavourFamily( - flavour, PDFAFlavour.SpecificationFamily.PDF_UA) - || PDFFlavours.isFlavourFamily( - flavour, PDFAFlavour.SpecificationFamily.WTPDF)) { + List flavoursToValidate = new ArrayList<>(); + + declaredPdfaFlavour.ifPresent(flavoursToValidate::add); + + for (PDFAFlavour flavour : detectedFlavours) { + if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A)) { + if (declaredPdfaFlavour.isPresent() && !declaredPdfaFlavour.get().equals(flavour)) { flavoursToValidate.add(flavour); + } else if (declaredPdfaFlavour.isEmpty()) { + log.debug( + "Ignoring detected PDF/A flavour {} because no PDF/A declaration exists in XMP", + flavour.getId()); } + } else if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_UA) + || PDFFlavours.isFlavourFamily( + flavour, PDFAFlavour.SpecificationFamily.WTPDF)) { + flavoursToValidate.add(flavour); } + } - if (flavoursToValidate.isEmpty()) { - log.info("No PDF/A, PDF/UA, or WTPDF standards declared in the document"); - PDFVerificationResult noStandardResult = new PDFVerificationResult(); - noStandardResult.setStandard("none"); - noStandardResult.setStandardName("No standards declared"); - noStandardResult.setCompliant(false); - noStandardResult.setTotalFailures(0); - noStandardResult.setTotalWarnings(0); - results.add(noStandardResult); - return results; - } + if (declaredPdfaFlavour.isEmpty()) { + results.add(createNoPdfaDeclarationResult()); + } - for (PDFAFlavour flavour : flavoursToValidate) { - try { - PDFAValidator validator = - Foundries.defaultInstance().createValidator(flavour, false); - ValidationResult result = validator.validate(parser); - results.add(convertToVerificationResult(result)); - } catch (Exception e) { - log.error("Error validating standard {}: {}", flavour.getId(), e.getMessage()); - PDFVerificationResult errorResult = new PDFVerificationResult(); - errorResult.setStandard(flavour.getId()); - errorResult.setStandardName(getStandardName(flavour)); - errorResult.setCompliant(false); - errorResult.setTotalFailures(1); - errorResult.setTotalWarnings(0); - PDFVerificationResult.ValidationIssue failure = - new PDFVerificationResult.ValidationIssue(); - failure.setMessage("Validation error: " + e.getMessage()); - errorResult.addFailure(failure); - results.add(errorResult); - } + if (flavoursToValidate.isEmpty()) { + log.info("No verifiable PDF/A, PDF/UA, or WTPDF standards declared via XMP metadata"); + return results; + } + + for (PDFAFlavour flavour : flavoursToValidate) { + try (PDFAParser parser = + Foundries.defaultInstance() + .createParser(new ByteArrayInputStream(pdfBytes), flavour)) { + PDFAValidator validator = + Foundries.defaultInstance().createValidator(flavour, false); + ValidationResult result = validator.validate(parser); + PDFAFlavour declaredForResult = + PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A) + ? declaredPdfaFlavour.orElse(null) + : flavour; + results.add(convertToVerificationResult(result, declaredForResult, flavour)); + } catch (Exception e) { + log.error("Error validating standard {}: {}", flavour.getId(), e.getMessage()); + results.add( + buildErrorResult( + declaredPdfaFlavour, + flavour, + "Validation error: " + e.getMessage())); } } return results; } - private PDFVerificationResult convertToVerificationResult(ValidationResult result) { + private static PDFVerificationResult convertToVerificationResult( + ValidationResult result, PDFAFlavour declaredFlavour, PDFAFlavour validationFlavour) { PDFVerificationResult verificationResult = new PDFVerificationResult(); - PDFAFlavour flavour = result.getPDFAFlavour(); - verificationResult.setStandard(flavour.getId()); - verificationResult.setStandardName(getStandardName(flavour)); - verificationResult.setCompliant(result.isCompliant()); + PDFAFlavour validationProfile = + validationFlavour != null ? validationFlavour : result.getPDFAFlavour(); + boolean validationIsPdfa = isPdfaFlavour(validationProfile); + + if (validationProfile != null) { + verificationResult.setValidationProfile(validationProfile.getId()); + verificationResult.setValidationProfileName(getStandardName(validationProfile)); + } + + if (declaredFlavour != null) { + verificationResult.setStandard(declaredFlavour.getId()); + verificationResult.setDeclaredPdfa(isPdfaFlavour(declaredFlavour)); + } else if (validationIsPdfa) { + verificationResult.setStandard(NOT_PDFA_STANDARD_ID); + verificationResult.setDeclaredPdfa(false); + } else if (validationProfile != null) { + verificationResult.setStandard(validationProfile.getId()); + verificationResult.setDeclaredPdfa(false); + } else { + verificationResult.setStandard(NOT_PDFA_STANDARD_ID); + verificationResult.setDeclaredPdfa(false); + } - // Process all assertions and separate errors from warnings List assertions = result.getTestAssertions(); - int errorCount = 0; - int warningCount = 0; for (TestAssertion assertion : assertions) { TestAssertion.Status status = assertion.getStatus(); - // Only process FAILED assertions (PASSED assertions are successful checks) if (status == TestAssertion.Status.FAILED) { - - PDFVerificationResult.ValidationIssue issue = - new PDFVerificationResult.ValidationIssue(); - issue.setRuleId(assertion.getRuleId().toString()); - issue.setMessage(assertion.getMessage()); - issue.setLocation( - assertion.getLocation() != null - ? assertion.getLocation().toString() - : "Unknown"); - issue.setSpecification( - assertion.getRuleId().getSpecification() != null - ? assertion.getRuleId().getSpecification().toString() - : ""); - issue.setClause(assertion.getRuleId().getClause()); - int testNumber = assertion.getRuleId().getTestNumber(); - issue.setTestNumber(testNumber > 0 ? String.valueOf(testNumber) : ""); - verificationResult.addFailure(issue); - errorCount++; + classifyAssertion(assertion, verificationResult); } } - verificationResult.setTotalFailures(errorCount); - verificationResult.setTotalWarnings(warningCount); + verificationResult.setCompliant(verificationResult.getTotalFailures() == 0); + + String baseName; + if (declaredFlavour != null) { + baseName = getStandardName(declaredFlavour); + } else if (validationIsPdfa) { + baseName = NOT_PDFA_STANDARD_NAME; + } else if (validationProfile != null) { + baseName = getStandardName(validationProfile); + } else { + baseName = "Unknown standard"; + } + + String standardDisplay = + formatStandardDisplay( + baseName, + verificationResult.getTotalFailures(), + verificationResult.getTotalWarnings(), + isPdfaFlavour(declaredFlavour), + validationIsPdfa && declaredFlavour == null); + verificationResult.setStandardName(standardDisplay); + verificationResult.setComplianceSummary(standardDisplay); log.debug( - "Validation complete for {}: {} errors, {} warnings", - flavour.getId(), - errorCount, - warningCount); + "Validation complete for profile {} (declared: {}): {} errors, {} warnings", + validationProfile != null ? validationProfile.getId() : "unknown", + declaredFlavour != null ? declaredFlavour.getId() : NOT_PDFA_STANDARD_ID, + verificationResult.getTotalFailures(), + verificationResult.getTotalWarnings()); return verificationResult; } - private String getStandardName(PDFAFlavour flavour) { + private static void classifyAssertion( + TestAssertion assertion, PDFVerificationResult verificationResult) { + + PDFVerificationResult.ValidationIssue issue = createValidationIssue(assertion); + String ruleId = assertion.getRuleId() != null ? assertion.getRuleId().toString() : ""; + String message = assertion.getMessage() != null ? assertion.getMessage() : ""; + String clause = assertion.getRuleId() != null ? assertion.getRuleId().getClause() : ""; + + if (isWarningRule(ruleId)) { + verificationResult.addWarning(issue); + return; + } + + if (isWarningByMessage(message)) { + verificationResult.addWarning(issue); + return; + } + + if (isWarningByClause(clause)) { + verificationResult.addWarning(issue); + return; + } + + verificationResult.addFailure(issue); + } + + private static PDFVerificationResult.ValidationIssue createValidationIssue( + TestAssertion assertion) { + PDFVerificationResult.ValidationIssue issue = new PDFVerificationResult.ValidationIssue(); + issue.setRuleId(assertion.getRuleId() != null ? assertion.getRuleId().toString() : ""); + issue.setMessage(assertion.getMessage()); + issue.setLocation( + assertion.getLocation() != null ? assertion.getLocation().toString() : "Unknown"); + issue.setSpecification( + assertion.getRuleId() != null && assertion.getRuleId().getSpecification() != null + ? assertion.getRuleId().getSpecification().toString() + : ""); + issue.setClause(assertion.getRuleId() != null ? assertion.getRuleId().getClause() : ""); + int testNumber = assertion.getRuleId() != null ? assertion.getRuleId().getTestNumber() : 0; + issue.setTestNumber(testNumber > 0 ? String.valueOf(testNumber) : ""); + return issue; + } + + private static boolean isWarningRule(String ruleId) { + return ruleId != null && WARNING_RULES.contains(ruleId); + } + + private static boolean isWarningByMessage(String message) { + // isBlank() already handles null and empty strings + if (message == null || message.isBlank()) { + return false; + } + + String normalized = message.toLowerCase(Locale.ROOT); + + return normalized.contains("recommended") + || normalized.contains("should") + || normalized.contains("optional") + || normalized.contains("missing recommended"); + } + + private static boolean isWarningByClause(String clause) { + // isBlank() already handles null and empty strings + if (clause == null || clause.isBlank()) { + return false; + } + + if (clause.startsWith("6.7")) { + return true; + } + + for (String criticalPrefix : CRITICAL_CLAUSE_PREFIXES) { + if (clause.startsWith(criticalPrefix)) { + return false; + } + } + + return true; + } + + private static PDFVerificationResult createNoPdfaDeclarationResult() { + PDFVerificationResult result = new PDFVerificationResult(); + result.setStandard(NOT_PDFA_STANDARD_ID); + result.setStandardName(NOT_PDFA_STANDARD_NAME); + result.setComplianceSummary(NOT_PDFA_STANDARD_NAME); + result.setCompliant(false); + result.setDeclaredPdfa(false); + PDFVerificationResult.ValidationIssue issue = new PDFVerificationResult.ValidationIssue(); + issue.setMessage("Document does not declare PDF/A compliance in its XMP metadata."); + issue.setSpecification("XMP pdfaid"); + result.addFailure(issue); + return result; + } + + private static Optional extractDeclaredPdfaFlavour(byte[] pdfBytes) { + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + PDMetadata metadata = document.getDocumentCatalog().getMetadata(); + if (metadata == null) { + return Optional.empty(); + } + + try (InputStream xmpStream = metadata.createInputStream()) { + if (xmpStream == null) { + return Optional.empty(); + } + DomXmpParser parser = new DomXmpParser(); + XMPMetadata xmpMetadata = parser.parse(xmpStream); + PDFAIdentificationSchema pdfaid = xmpMetadata.getPDFAIdentificationSchema(); + if (pdfaid == null) { + return Optional.empty(); + } + + Integer part = pdfaid.getPart(); + String conformance = pdfaid.getConformance(); + + if (part == null || conformance == null || conformance.isBlank()) { + return Optional.empty(); + } + + String flavourId = part + conformance.trim().toLowerCase(Locale.ROOT); + return Optional.ofNullable(PDFAFlavour.fromString(flavourId)); + } + } catch (XmpParsingException e) { + log.warn( + "Invalid XMP metadata encountered while checking PDF/A declaration: {}", + e.getMessage()); + log.debug("XMP parsing error", e); + return Optional.empty(); + } catch (Exception e) { + log.warn("Unable to extract PDF/A declaration from XMP: {}", e.getMessage()); + log.debug("XMP extraction error", e); + return Optional.empty(); + } + } + + private static PDFVerificationResult buildErrorResult( + Optional declaredPdfaFlavour, + PDFAFlavour validationFlavour, + String errorMessage) { + + PDFVerificationResult errorResult = new PDFVerificationResult(); + PDFAFlavour declaredForResult = + validationFlavour != null && isPdfaFlavour(validationFlavour) + ? declaredPdfaFlavour.orElse(null) + : validationFlavour; + + if (declaredForResult != null) { + errorResult.setStandard(declaredForResult.getId()); + errorResult.setStandardName(getStandardName(declaredForResult) + " with errors"); + errorResult.setDeclaredPdfa(isPdfaFlavour(declaredForResult)); + } else if (validationFlavour != null && isPdfaFlavour(validationFlavour)) { + errorResult.setStandard(NOT_PDFA_STANDARD_ID); + errorResult.setStandardName(NOT_PDFA_STANDARD_NAME); + errorResult.setDeclaredPdfa(false); + } else { + errorResult.setStandard( + validationFlavour != null ? validationFlavour.getId() : NOT_PDFA_STANDARD_ID); + errorResult.setStandardName( + (validationFlavour != null + ? getStandardName(validationFlavour) + : "Unknown standard") + + " with errors"); + errorResult.setDeclaredPdfa(false); + } + + errorResult.setValidationProfile( + validationFlavour != null ? validationFlavour.getId() : NOT_PDFA_STANDARD_ID); + errorResult.setValidationProfileName( + validationFlavour != null + ? getStandardName(validationFlavour) + : "Unknown standard"); + errorResult.setComplianceSummary(errorResult.getStandardName()); + errorResult.setCompliant(false); + + PDFVerificationResult.ValidationIssue failure = new PDFVerificationResult.ValidationIssue(); + failure.setMessage(errorMessage); + errorResult.addFailure(failure); + + return errorResult; + } + + private static boolean isPdfaFlavour(PDFAFlavour flavour) { + return PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A); + } + + private static String formatStandardDisplay( + String baseName, + int errorCount, + int warningCount, + boolean declaredPdfa, + boolean inferredPdfaWithoutDeclaration) { + + if (inferredPdfaWithoutDeclaration) { + return NOT_PDFA_STANDARD_NAME; + } + + if (!declaredPdfa && NOT_PDFA_STANDARD_NAME.equals(baseName)) { + return NOT_PDFA_STANDARD_NAME; + } + + if (errorCount > 0) { + return baseName + " with errors"; + } + + if (warningCount > 0) { + return baseName + " with warnings"; + } + + return baseName + " compliant"; + } + + private static String getStandardName(PDFAFlavour flavour) { String id = flavour.getId(); String part = flavour.getPart().toString(); String level = flavour.getLevel().toString(); - // PDF/A standards - if (!id.isEmpty() && id.charAt(0) == '1' - || !id.isEmpty() && id.charAt(0) == '2' - || !id.isEmpty() && id.charAt(0) == '3' - || !id.isEmpty() && id.charAt(0) == '4') { + // PDF/A standards - Fixed: proper length check and parentheses + if (!id.isEmpty() + && (id.charAt(0) == '1' + || id.charAt(0) == '2' + || id.charAt(0) == '3' + || id.charAt(0) == '4')) { return "PDF/A-" + part + (level.isEmpty() ? "" : level); } // PDF/UA standards From 525864ff2c0bc9b8d47b1f4c86ff406127503b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Fri, 14 Nov 2025 21:36:21 +0100 Subject: [PATCH 5/5] refactor(service): make validation methods non-static in VeraPDFService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed `validatePDF` and `validateAllDeclaredStandards` methods from static to instance methods - Updated comments to include explicit null checks for better NPE prevention in warning logic Signed-off-by: Balázs Szücs --- .../software/SPDF/service/VeraPDFService.java | 194 +++++++++--------- 1 file changed, 97 insertions(+), 97 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java index 7ddb146ea..002a91543 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java @@ -61,90 +61,37 @@ public class VeraPDFService { } } - public static PDFVerificationResult validatePDF(InputStream pdfStream, String standardString) - throws IOException, ValidationException, ModelParsingException, EncryptedPdfException { - - byte[] pdfBytes = pdfStream.readAllBytes(); - PDFAFlavour validationFlavour = PDFAFlavour.fromString(standardString); - Optional declaredPdfaFlavour = extractDeclaredPdfaFlavour(pdfBytes); - - try (PDFAParser parser = - Foundries.defaultInstance() - .createParser(new ByteArrayInputStream(pdfBytes), validationFlavour)) { - PDFAValidator validator = - Foundries.defaultInstance().createValidator(validationFlavour, false); - ValidationResult result = validator.validate(parser); - - return convertToVerificationResult( - result, declaredPdfaFlavour.orElse(null), validationFlavour); + private static boolean isWarningByMessage(String message) { + // Check for null or blank message - explicit null check is necessary to avoid NPE + if (message == null || message.isBlank()) { + return false; } + + String normalized = message.toLowerCase(Locale.ROOT); + + return normalized.contains("recommended") + || normalized.contains("should") + || normalized.contains("optional") + || normalized.contains("missing recommended"); } - public static List validateAllDeclaredStandards(InputStream pdfStream) - throws IOException, ValidationException, ModelParsingException, EncryptedPdfException { - - byte[] pdfBytes = pdfStream.readAllBytes(); - Optional declaredPdfaFlavour = extractDeclaredPdfaFlavour(pdfBytes); - List results = new ArrayList<>(); - - List detectedFlavours; - try (PDFAParser detectionParser = - Foundries.defaultInstance().createParser(new ByteArrayInputStream(pdfBytes))) { - detectedFlavours = detectionParser.getFlavours(); + private static boolean isWarningByClause(String clause) { + // Check for null or blank clause - explicit null check is necessary to avoid NPE + if (clause == null || clause.isBlank()) { + return false; } - List flavoursToValidate = new ArrayList<>(); + if (clause.startsWith("6.7")) { + return true; + } - declaredPdfaFlavour.ifPresent(flavoursToValidate::add); - - for (PDFAFlavour flavour : detectedFlavours) { - if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A)) { - if (declaredPdfaFlavour.isPresent() && !declaredPdfaFlavour.get().equals(flavour)) { - flavoursToValidate.add(flavour); - } else if (declaredPdfaFlavour.isEmpty()) { - log.debug( - "Ignoring detected PDF/A flavour {} because no PDF/A declaration exists in XMP", - flavour.getId()); - } - } else if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_UA) - || PDFFlavours.isFlavourFamily( - flavour, PDFAFlavour.SpecificationFamily.WTPDF)) { - flavoursToValidate.add(flavour); + for (String criticalPrefix : CRITICAL_CLAUSE_PREFIXES) { + if (clause.startsWith(criticalPrefix)) { + return false; } } - if (declaredPdfaFlavour.isEmpty()) { - results.add(createNoPdfaDeclarationResult()); - } - - if (flavoursToValidate.isEmpty()) { - log.info("No verifiable PDF/A, PDF/UA, or WTPDF standards declared via XMP metadata"); - return results; - } - - for (PDFAFlavour flavour : flavoursToValidate) { - try (PDFAParser parser = - Foundries.defaultInstance() - .createParser(new ByteArrayInputStream(pdfBytes), flavour)) { - PDFAValidator validator = - Foundries.defaultInstance().createValidator(flavour, false); - ValidationResult result = validator.validate(parser); - PDFAFlavour declaredForResult = - PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A) - ? declaredPdfaFlavour.orElse(null) - : flavour; - results.add(convertToVerificationResult(result, declaredForResult, flavour)); - } catch (Exception e) { - log.error("Error validating standard {}: {}", flavour.getId(), e.getMessage()); - results.add( - buildErrorResult( - declaredPdfaFlavour, - flavour, - "Validation error: " + e.getMessage())); - } - } - - return results; + return true; } private static PDFVerificationResult convertToVerificationResult( @@ -264,37 +211,90 @@ public class VeraPDFService { return ruleId != null && WARNING_RULES.contains(ruleId); } - private static boolean isWarningByMessage(String message) { - // isBlank() already handles null and empty strings - if (message == null || message.isBlank()) { - return false; + public PDFVerificationResult validatePDF(InputStream pdfStream, String standardString) + throws IOException, ValidationException, ModelParsingException, EncryptedPdfException { + + byte[] pdfBytes = pdfStream.readAllBytes(); + PDFAFlavour validationFlavour = PDFAFlavour.fromString(standardString); + Optional declaredPdfaFlavour = extractDeclaredPdfaFlavour(pdfBytes); + + try (PDFAParser parser = + Foundries.defaultInstance() + .createParser(new ByteArrayInputStream(pdfBytes), validationFlavour)) { + PDFAValidator validator = + Foundries.defaultInstance().createValidator(validationFlavour, false); + ValidationResult result = validator.validate(parser); + + return convertToVerificationResult( + result, declaredPdfaFlavour.orElse(null), validationFlavour); } - - String normalized = message.toLowerCase(Locale.ROOT); - - return normalized.contains("recommended") - || normalized.contains("should") - || normalized.contains("optional") - || normalized.contains("missing recommended"); } - private static boolean isWarningByClause(String clause) { - // isBlank() already handles null and empty strings - if (clause == null || clause.isBlank()) { - return false; + public List validateAllDeclaredStandards(InputStream pdfStream) + throws IOException, ValidationException, ModelParsingException, EncryptedPdfException { + + byte[] pdfBytes = pdfStream.readAllBytes(); + Optional declaredPdfaFlavour = extractDeclaredPdfaFlavour(pdfBytes); + List results = new ArrayList<>(); + + List detectedFlavours; + try (PDFAParser detectionParser = + Foundries.defaultInstance().createParser(new ByteArrayInputStream(pdfBytes))) { + detectedFlavours = detectionParser.getFlavours(); } - if (clause.startsWith("6.7")) { - return true; - } + List flavoursToValidate = new ArrayList<>(); - for (String criticalPrefix : CRITICAL_CLAUSE_PREFIXES) { - if (clause.startsWith(criticalPrefix)) { - return false; + declaredPdfaFlavour.ifPresent(flavoursToValidate::add); + + for (PDFAFlavour flavour : detectedFlavours) { + if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A)) { + if (declaredPdfaFlavour.isPresent() && !declaredPdfaFlavour.get().equals(flavour)) { + flavoursToValidate.add(flavour); + } else if (declaredPdfaFlavour.isEmpty()) { + log.debug( + "Ignoring detected PDF/A flavour {} because no PDF/A declaration exists in XMP", + flavour.getId()); + } + } else if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_UA) + || PDFFlavours.isFlavourFamily( + flavour, PDFAFlavour.SpecificationFamily.WTPDF)) { + flavoursToValidate.add(flavour); } } - return true; + if (declaredPdfaFlavour.isEmpty()) { + results.add(createNoPdfaDeclarationResult()); + } + + if (flavoursToValidate.isEmpty()) { + log.info("No verifiable PDF/A, PDF/UA, or WTPDF standards declared via XMP metadata"); + return results; + } + + for (PDFAFlavour flavour : flavoursToValidate) { + try (PDFAParser parser = + Foundries.defaultInstance() + .createParser(new ByteArrayInputStream(pdfBytes), flavour)) { + PDFAValidator validator = + Foundries.defaultInstance().createValidator(flavour, false); + ValidationResult result = validator.validate(parser); + PDFAFlavour declaredForResult = + PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A) + ? declaredPdfaFlavour.orElse(null) + : flavour; + results.add(convertToVerificationResult(result, declaredForResult, flavour)); + } catch (Exception e) { + log.error("Error validating standard {}: {}", flavour.getId(), e.getMessage()); + results.add( + buildErrorResult( + declaredPdfaFlavour, + flavour, + "Validation error: " + e.getMessage())); + } + } + + return results; } private static PDFVerificationResult createNoPdfaDeclarationResult() {