Convert to PDF/A, validate it, and repair what fails

A practical guide for Rust developers working with PDF/A. Two calls do the work -- convert_to_pdfa and validate_pdfa -- and this page shows what each of them can and cannot promise.

Convert a PDF to PDF/A-1b, PDF/A-2b, or PDF/A-3b in Rust

Convert a standard PDF into an archival PDF/A document. `convert_to_pdfa` embeds fonts, normalises colour with an output intent, drops the constructs PDF/A forbids, and writes the PDF/A identification metadata. It returns a new document; the one you opened is left as it was.

rust
use pdfluent::{PdfAProfile, PdfDocument};

fn main() -> pdfluent::Result<()> {
    let doc = PdfDocument::open("report.pdf")?;

    // A new document. The source is not modified.
    let archived = doc.convert_to_pdfa(PdfAProfile::A2b)?;

    let report = archived.validate_pdfa(PdfAProfile::A2b)?;
    println!("compliant: {}", report.is_compliant());

    archived.save("report_pdfa2b.pdf")?;
    Ok(())
}
  1. Open the source PDF

    Open the document you want to archive. Conversion reads it and builds a second document, so a shared reference is enough.

    rust
    use pdfluent::PdfDocument;
    
    let doc = PdfDocument::open("report.pdf")?;
  2. Choose the conformance level

    PdfAProfile has three variants: A1b, A2b, and A3b. A2b is the usual choice; pick A1b only when a policy demands the older, stricter profile, and A3b when the archive has to carry attachments.

    rust
    use pdfluent::PdfAProfile;
    
    let profile = PdfAProfile::A2b;
  3. Run the conversion

    convert_to_pdfa runs the whole pipeline in one call: font embedding and repair, colour normalisation with an output intent, removal of JavaScript and other forbidden constructs, and the XMP identification block. Damaged input is repaired first where that is possible.

    rust
    let archived = doc.convert_to_pdfa(profile)?;
  4. Validate what came out

    Conversion is best effort, so check the result rather than assume it. Some documents cannot be made conformant without changing how they look, and an archival converter must not do that quietly.

    rust
    let report = archived.validate_pdfa(profile)?;
    for violation in &report.violations {
        eprintln!("[{}] {}", violation.rule, violation.message);
    }
  5. Save the converted document

    Write the archival copy to its own path. The original file on disk is untouched.

    rust
    archived.save("report_pdfa2b.pdf")?;
  • PDF/A-1b does not allow transparency. If your pages use it, PDF/A-2b or PDF/A-3b is the honest target; forcing A1b flattens artwork and changes how the page looks.
  • The output intent -- the ICC profile that says which colour space the page was made for -- is written by the conversion. PDFluent 1.0 has no separate API for setting one on an existing document.
  • JavaScript actions, launch actions and embedded movies are removed, because PDF/A forbids them.
  • Fonts that may not be embedded under their licence cannot be embedded here either. Those documents come back with violations that no converter can clear; replacing the font is the only route.

Validate PDF/A compliance in Rust

Check a document against PDF/A-1b, PDF/A-2b, or PDF/A-3b. `validate_pdfa` returns a report holding every finding, each with the rule it comes from, a message, and a severity.

rust
use pdfluent::{PdfAProfile, PdfDocument};

fn main() -> pdfluent::Result<()> {
    let doc = PdfDocument::open("archive_candidate.pdf")?;
    let report = doc.validate_pdfa(PdfAProfile::A2b)?;

    println!("compliant: {}", report.is_compliant());
    for violation in &report.violations {
        println!("[{}] {}", violation.rule, violation.message);
    }
    Ok(())
}
  1. Open the PDF

    Validation reads the whole structure, so a large file costs more than a small one. No mutable access is needed.

    rust
    use pdfluent::PdfDocument;
    
    let doc = PdfDocument::open("archive_candidate.pdf")?;
  2. Choose the level to validate against

    The profile you pass is the profile the report is about. Validating an A1b document against A2b tells you nothing about the claim it makes.

    rust
    use pdfluent::PdfAProfile;
    
    let profile = PdfAProfile::A2b;
  3. Run the validator

    validate_pdfa returns a PdfAValidationReport. The Err case means the document could not be read at all, not that it failed validation -- a non-conformant document is a successful run with findings in it.

    rust
    let report = doc.validate_pdfa(profile)?;
  4. Read the findings

    is_compliant is true when nothing of Severity::Error was found. Warnings are reported as well and do not block conformance, so count the two apart if your pipeline treats them differently.

    rust
    use pdfluent::compliance::Severity;
    
    let errors = report
        .violations
        .iter()
        .filter(|violation| violation.severity == Severity::Error)
        .count();
    
    println!("{} error(s) out of {} finding(s)", errors, report.violations.len());
    println!("compliant: {}", report.is_compliant());
  5. Fail the build on a violation

    In a pipeline, turn a non-conformant document into a non-zero exit code so the run stops instead of archiving it.

    rust
    if !report.is_compliant() {
        eprintln!("{} finding(s)", report.violations.len());
        std::process::exit(1);
    }
  • Every finding carries `rule`, the identifier from the ISO 19005 clause it comes from, so a report can be looked up rather than guessed at.
  • A missing font embedding is the most common PDF/A-1b failure by a wide margin.
  • The validator reads structure and metadata. It does not re-render pages, so it cannot tell you that a page renders differently in another viewer.
  • `report.profile` records which profile the report was made for. Keep it with the report if you store findings; a list of violations without its level is unreadable later.

Find out which PDF/A level a document actually meets

A PDF can claim a conformance level in its metadata and still fail the rules for it. Validate against each profile in turn and let the answer come from the validator rather than from the claim.

rust
use pdfluent::{PdfAProfile, PdfDocument};

fn main() -> pdfluent::Result<()> {
    let doc = PdfDocument::open("archive.pdf")?;

    for profile in [PdfAProfile::A1b, PdfAProfile::A2b, PdfAProfile::A3b] {
        let report = doc.validate_pdfa(profile)?;
        println!("{:?}: {}", profile, report.is_compliant());
    }
    Ok(())
}
  1. Open the document

    Read-only access is enough; nothing here writes.

    rust
    use pdfluent::PdfDocument;
    
    let doc = PdfDocument::open("archive.pdf")?;
  2. List the levels, strictest first

    PDF/A-1b is the narrowest of the three and PDF/A-3b the widest, so this order lets the first pass be the answer.

    rust
    use pdfluent::PdfAProfile;
    
    let levels = [PdfAProfile::A1b, PdfAProfile::A2b, PdfAProfile::A3b];
  3. Take the strictest level that passes

    Stop at the first profile the document conforms to. None means it meets none of the three, which is the useful answer when a document claims otherwise.

    rust
    let mut strictest = None;
    for profile in levels {
        if doc.validate_pdfa(profile)?.is_compliant() {
            strictest = Some(profile);
            break;
        }
    }
    println!("{:?}", strictest);
  4. Print what stands in the way

    When a level fails, the report says why. The rule identifier is the clause of ISO 19005 that was broken, so it can be looked up.

    rust
    let report = doc.validate_pdfa(PdfAProfile::A1b)?;
    for violation in &report.violations {
        println!(
            "[{}] {} ({:?})",
            violation.rule, violation.message, violation.severity
        );
    }
  5. Keep the level with the report

    A stored list of findings is unreadable without the profile it was measured against, and the report carries it.

    rust
    println!("checked against {:?}", report.profile);
  • Metadata is a claim, not a verdict. A document with a pdfaid block saying PDF/A-1 can still break the rules of PDF/A-1, which is exactly the case this section is for.
  • Transparency is forbidden in PDF/A-1 and allowed from PDF/A-2 on, so a document that fails only A1b is usually a transparency case.
  • PDFluent validates the b (visual) levels: A1b, A2b, A3b. The a levels, which additionally require tagged structure and reading order, are not among the profiles you can pass.
  • Validating against all three costs three passes over the document. Validate against the single level your policy asks for when you already know which one that is.

Repair a PDF that fails PDF/A validation

There is no separate repair call in PDFluent 1.0. Conversion is the repair: `convert_to_pdfa` embeds fonts, writes the output intent and strips what PDF/A forbids, and validating before and after tells you how much of the gap it closed.

rust
use pdfluent::{PdfAProfile, PdfDocument};

fn main() -> pdfluent::Result<()> {
    let doc = PdfDocument::open("noncompliant.pdf")?;

    let before = doc.validate_pdfa(PdfAProfile::A2b)?;
    println!("before: {} finding(s)", before.violations.len());

    // Conversion is the repair; there is no fix-in-place call.
    let repaired = doc.convert_to_pdfa(PdfAProfile::A2b)?;

    let after = repaired.validate_pdfa(PdfAProfile::A2b)?;
    println!("after: {} finding(s)", after.violations.len());

    if after.is_compliant() {
        repaired.save("repaired_a2b.pdf")?;
    }
    Ok(())
}
  1. Measure before you repair

    Validate first, so that afterwards you can say what changed rather than hope something did.

    rust
    use pdfluent::{PdfAProfile, PdfDocument};
    
    let doc = PdfDocument::open("noncompliant.pdf")?;
    let before = doc.validate_pdfa(PdfAProfile::A2b)?;
  2. Convert to the level you need

    The conversion pipeline is what repairs the document: fonts are embedded, colour is normalised with an output intent, forbidden constructs are removed, and the identification metadata is written.

    rust
    let repaired = doc.convert_to_pdfa(PdfAProfile::A2b)?;
  3. Measure again

    The difference between the two counts is the part of the problem that conversion could solve.

    rust
    let after = repaired.validate_pdfa(PdfAProfile::A2b)?;
    println!(
        "{} -> {} finding(s)",
        before.violations.len(),
        after.violations.len()
    );
  4. Look at what is left

    Whatever survives conversion needs a decision from a person: a font that may not be embedded has to be replaced, and encrypted content has to be unlocked at the source.

    rust
    for violation in &after.violations {
        println!("[{}] {}", violation.rule, violation.message);
    }
  5. Only archive a document that passes

    Writing a file that still fails validation puts the problem in the archive instead of in the pipeline.

    rust
    if after.is_compliant() {
        repaired.save("repaired_a2b.pdf")?;
    } else {
        eprintln!("still not conformant -- not writing an archive copy");
    }
  • Converting to PDF/A-1b flattens transparency, which can change how a page looks. When the source uses transparency, PDF/A-2b keeps the artwork intact.
  • A font whose licence forbids embedding stays a violation after conversion. The document has to be re-made with a font that may be embedded.
  • Conversion always produces a second document. Nothing is repaired in place, so the file you started from stays available for comparison.
  • Best effort includes structural repair: a broken cross-reference table or page tree is rebuilt first where that can be done safely.