reportgen
Back to all posts

PDF Accessibility and WCAG Compliance: Making Your Documents Work for Everyone ♿

June 13, 2025

Your beautifully designed PDF looks perfect. But 15% of your users can't read it.

Why? Because you didn't think about accessibility.

  • Screen readers can't parse the content
  • Keyboard users can't navigate
  • Visually impaired users can't read the text
  • Color-blind users can't distinguish important information

One lawsuit later, you're learning about ADA compliance the hard way.

This guide shows you how to create accessible, WCAG-compliant PDFs from day one - so everyone can use your documents.


Why PDF Accessibility Matters

The Legal Reality

You can get sued for inaccessible PDFs.

  • ADA Title III (US): Requires digital accessibility
  • Section 508 (US Government): Mandates accessible documents
  • European Accessibility Act: EU-wide requirements
  • AODA (Ontario, Canada): Accessibility compliance required

Recent lawsuits:

  • Domino's Pizza: $4,000 per inaccessible document
  • Harvard & MIT: Class action for inaccessible course materials
  • Banks, retailers, universities: Hundreds of cases annually

The Moral Reality

1 billion people worldwide have disabilities.

When your PDFs aren't accessible:

  • Blind users can't access invoices
  • Dyslexic users struggle to read reports
  • Motor-impaired users can't fill out forms
  • Color-blind users miss critical information

Accessibility isn't optional. It's essential.


Understanding WCAG 2.1 for PDFs

The Four Principles (POUR)

1. Perceivable - Information must be presentable in ways users can perceive 2. Operable - Interface components must be operable 3. Understandable - Information must be understandable 4. Robust - Content must work with assistive technologies

Three Conformance Levels

  • Level A - Minimum (you'll get sued if you don't meet this)
  • Level AA - Recommended (industry standard)
  • Level AAA - Enhanced (gold standard)

Target: WCAG 2.1 Level AA for all PDFs


Making PDFs Accessible: The Technical Implementation

1. Document Structure and Semantics

The Problem: PDFs without structure are just images to screen readers.

<!-- ❌ No semantic structure -->
<p style="font-size: 24px; font-weight: bold;">Invoice</p>
<p style="font-size: 18px; font-weight: bold;">Customer Information</p>
<p>John Doe</p>

The Solution: Use proper HTML5 semantic tags.

<!-- ✅ Proper semantic structure -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Invoice #12345 - Acme Corp</title>
</head>
<body>
  <main>
    <header>
      <h1>Invoice #12345</h1>
    </header>
 
    <section aria-labelledby="customer-heading">
      <h2 id="customer-heading">Customer Information</h2>
      <dl>
        <dt>Name:</dt>
        <dd>John Doe</dd>
        <dt>Email:</dt>
        <dd>[email protected]</dd>
      </dl>
    </section>
 
    <section aria-labelledby="items-heading">
      <h2 id="items-heading">Items</h2>
      <table>
        <caption>Invoice line items</caption>
        <thead>
          <tr>
            <th scope="col">Description</th>
            <th scope="col">Quantity</th>
            <th scope="col">Price</th>
          </tr>
        </thead>
        <tbody>
          <tr>
            <td>Consulting Services</td>
            <td>10 hours</td>
            <td>$1,500</td>
          </tr>
        </tbody>
      </table>
    </section>
  </main>
</body>
</html>

2. Heading Hierarchy

The Problem: Broken heading structure confuses screen readers.

<!-- ❌ Broken hierarchy -->
<h1>Invoice</h1>
<h3>Customer Details</h3>  <!-- Skipped h2 -->
<h2>Line Items</h2>         <!-- Wrong order -->

The Solution: Logical heading order (h1 → h2 → h3).

<!-- ✅ Proper hierarchy -->
<h1>Invoice #12345</h1>
  <h2>Customer Information</h2>
    <h3>Billing Address</h3>
    <h3>Shipping Address</h3>
  <h2>Line Items</h2>
  <h2>Payment Information</h2>

3. Alternative Text for Images

The Problem: Images without alt text are invisible to screen readers.

<!-- ❌ No alt text -->
<img src="logo.png">
<img src="chart.png">

The Solution: Descriptive alt text for all images.

<!-- ✅ Descriptive alt text -->
<img src="logo.png" alt="Acme Corporation logo">
<img src="revenue-chart.png" alt="Bar chart showing monthly revenue growth from January to June 2025. Revenue increased from $10,000 in January to $25,000 in June.">
 
<!-- For decorative images -->
<img src="decorative-border.png" alt="" role="presentation">

Alt text guidelines:

  • Informative images: Describe the content/purpose
  • Complex images (charts): Provide detailed description
  • Decorative images: Use alt="" and role="presentation"
  • Logos: Include company name
  • Avoid: "image of...", "picture of..."

4. Accessible Tables

The Problem: Tables without headers are incomprehensible to screen readers.

<!-- ❌ No table structure -->
<table>
  <tr>
    <td>Description</td>
    <td>Price</td>
  </tr>
  <tr>
    <td>Widget</td>
    <td>$10</td>
  </tr>
</table>

The Solution: Proper table markup with headers and scope.

<!-- ✅ Accessible table -->
<table>
  <caption>Invoice Line Items - Total: $1,500</caption>
  <thead>
    <tr>
      <th scope="col">Item Description</th>
      <th scope="col">Quantity</th>
      <th scope="col">Unit Price</th>
      <th scope="col">Total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Consulting Services</th>
      <td>10 hours</td>
      <td>$150/hour</td>
      <td>$1,500</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th scope="row" colspan="3">Grand Total</th>
      <td>$1,500</td>
    </tr>
  </tfoot>
</table>

For complex tables:

<table>
  <caption>Quarterly Sales by Region</caption>
  <thead>
    <tr>
      <th id="region" scope="col">Region</th>
      <th id="q1" scope="col">Q1 2025</th>
      <th id="q2" scope="col">Q2 2025</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th id="north" scope="row">North</th>
      <td headers="north q1">$50,000</td>
      <td headers="north q2">$65,000</td>
    </tr>
  </tbody>
</table>

5. Color and Contrast

The Problem: Information conveyed only by color is inaccessible.

<!-- ❌ Color-only indication -->
<style>
  .status-paid { color: green; }
  .status-overdue { color: red; }
</style>
 
<p class="status-paid">Paid</p>
<p class="status-overdue">Overdue</p>
<!-- Color-blind users can't distinguish -->

The Solution: Use text, icons, or patterns in addition to color.

<!-- ✅ Multiple indicators -->
<style>
  .status-badge {
    padding: 4px 12px;
    border-radius: 12px;
    font-weight: 600;
  }
 
  .status-paid {
    background: #dcfce7;
    color: #166534;
  }
 
  .status-paid::before {
    content: "✓ ";
  }
 
  .status-overdue {
    background: #fee2e2;
    color: #991b1b;
  }
 
  .status-overdue::before {
    content: "! ";
  }
</style>
 
<span class="status-badge status-paid">Paid</span>
<span class="status-badge status-overdue">Overdue</span>
<!-- Text + icon + color = accessible -->

Contrast Requirements (WCAG AA):

  • Normal text: 4.5:1 minimum
  • Large text (18px+): 3:1 minimum
  • UI components: 3:1 minimum
<!-- ❌ Insufficient contrast -->
<p style="color: #999; background: #fff;">Low contrast text</p>
<!-- Contrast ratio: 2.85:1 (FAIL) -->
 
<!-- ✅ Sufficient contrast -->
<p style="color: #333; background: #fff;">High contrast text</p>
<!-- Contrast ratio: 12.63:1 (PASS) -->

6. Language Declaration

The Problem: Screen readers don't know what language to use.

<!-- ❌ No language specified -->
<!DOCTYPE html>
<html>
<body>
  <p>Invoice for services rendered...</p>
</body>
</html>

The Solution: Declare document and section languages.

<!-- ✅ Language declared -->
<!DOCTYPE html>
<html lang="en">
<head>
  <title>Invoice #12345</title>
</head>
<body>
  <p>Invoice for services rendered...</p>
 
  <!-- Multi-language content -->
  <p lang="es">Factura para servicios prestados...</p>
  <p lang="fr">Facture pour services rendus...</p>
</body>
</html>

7. Logical Reading Order

The Problem: Visual layout doesn't match reading order.

<!-- ❌ Visual order ≠ DOM order -->
<div style="display: grid;">
  <div style="grid-column: 2;">Second visually, first in DOM</div>
  <div style="grid-column: 1;">First visually, second in DOM</div>
</div>
<!-- Screen reader reads in wrong order -->

The Solution: DOM order matches visual/logical order.

<!-- ✅ Correct reading order -->
<div style="display: grid;">
  <div style="grid-column: 1; order: 1;">First</div>
  <div style="grid-column: 2; order: 2;">Second</div>
</div>

8. Form Accessibility

The Problem: Forms without labels are unusable.

<!-- ❌ No labels -->
<input type="text" placeholder="Name">
<input type="email" placeholder="Email">

The Solution: Proper labels and instructions.

<!-- ✅ Accessible form -->
<form>
  <div>
    <label for="customer-name">Customer Name *</label>
    <input
      type="text"
      id="customer-name"
      name="name"
      required
      aria-required="true"
      aria-describedby="name-help"
    >
    <span id="name-help" class="help-text">Enter full legal name</span>
  </div>
 
  <div>
    <label for="customer-email">Email Address *</label>
    <input
      type="email"
      id="customer-email"
      name="email"
      required
      aria-required="true"
      aria-invalid="false"
    >
  </div>
 
  <fieldset>
    <legend>Payment Method</legend>
    <label>
      <input type="radio" name="payment" value="card" checked>
      Credit Card
    </label>
    <label>
      <input type="radio" name="payment" value="bank">
      Bank Transfer
    </label>
  </fieldset>
</form>

Creating Tagged PDFs

What Are PDF Tags?

PDF tags provide structure for screen readers - like HTML tags for PDFs.

Generate Tagged PDFs with pdf-lib

import { PDFDocument, PDFName, PDFDict } from 'pdf-lib';
 
async function createTaggedPDF(html) {
  // Generate basic PDF from HTML
  const pdfBytes = await generatePDF(html);
 
  // Load and tag PDF
  const pdfDoc = await PDFDocument.load(pdfBytes);
 
  // Mark as tagged
  const markInfo = pdfDoc.context.obj({
    Marked: true
  });
 
  pdfDoc.catalog.set(PDFName.of('MarkInfo'), markInfo);
 
  // Set document language
  pdfDoc.catalog.set(PDFName.of('Lang'), PDFName.of('en-US'));
 
  // Set title
  pdfDoc.setTitle('Invoice #12345');
  pdfDoc.setSubject('Invoice Document');
  pdfDoc.setAuthor('Acme Corporation');
 
  return await pdfDoc.save();
}

Testing PDF Accessibility

Automated Testing

import { PDFDocument } from 'pdf-lib';
import axe from 'axe-core';
 
async function testPDFAccessibility(pdfBytes) {
  const pdfDoc = await PDFDocument.load(pdfBytes);
 
  const issues = [];
 
  // Check if PDF is tagged
  const markInfo = pdfDoc.catalog.get(PDFName.of('MarkInfo'));
  if (!markInfo || !markInfo.get(PDFName.of('Marked'))) {
    issues.push('PDF is not tagged');
  }
 
  // Check if language is set
  const lang = pdfDoc.catalog.get(PDFName.of('Lang'));
  if (!lang) {
    issues.push('Document language not specified');
  }
 
  // Check if title is set
  const title = pdfDoc.getTitle();
  if (!title || title.trim() === '') {
    issues.push('Document title not set');
  }
 
  return {
    passes: issues.length === 0,
    issues: issues
  };
}

Manual Testing with Screen Readers

Test with actual screen readers:

  1. NVDA (Windows, free)

    • Download from nvaccess.org
    • Open PDF in Adobe Reader
    • Navigate with arrow keys
    • Verify headings work (H key)
    • Verify tables announce properly
  2. JAWS (Windows, paid)

    • Industry standard screen reader
    • Test in Adobe Acrobat Pro
  3. VoiceOver (macOS, built-in)

    • Enable: System Preferences → Accessibility
    • Open PDF in Preview
    • Navigate with VO + arrow keys
  4. Narrator (Windows, built-in)

    • Enable: Win + Ctrl + Enter

Testing checklist:

  • Can navigate by headings
  • Tables announce row/column headers
  • Images have alt text
  • Links are descriptive
  • Form fields have labels
  • Reading order is logical
  • No information lost when using screen reader

Accessibility Checklist

Structure:

  • Proper HTML5 semantic elements
  • Logical heading hierarchy (h1 → h2 → h3)
  • Document language declared
  • Reading order is logical

Images:

  • All images have alt text
  • Decorative images have empty alt (alt="")
  • Complex images have detailed descriptions

Tables:

  • Tables have <caption>
  • Header cells use <th> with scope
  • Complex tables use headers attribute

Color and Contrast:

  • 4.5:1 contrast for normal text
  • 3:1 contrast for large text
  • Information not conveyed by color alone

Forms:

  • All inputs have associated <label>
  • Required fields indicated (not just color)
  • Error messages are descriptive

PDF-Specific:

  • PDF is tagged
  • Document title is set
  • Metadata includes author, subject
  • Bookmarks for long documents

Complete Accessible Invoice Example

async function generateAccessibleInvoice(invoiceData) {
  const html = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Invoice ${invoiceData.number} - ${invoiceData.company}</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      line-height: 1.6;
      color: #1a1a1a;
      background: #ffffff;
    }
 
    .visually-hidden {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    }
 
    h1, h2, h3 {
      margin-top: 1em;
      margin-bottom: 0.5em;
    }
 
    table {
      width: 100%;
      border-collapse: collapse;
      margin: 1em 0;
    }
 
    th, td {
      padding: 0.75em;
      text-align: left;
      border-bottom: 1px solid #ddd;
    }
 
    th {
      background: #f0f0f0;
      font-weight: bold;
    }
 
    .status-badge {
      padding: 4px 12px;
      border-radius: 12px;
      font-weight: 600;
    }
 
    .status-paid {
      background: #dcfce7;
      color: #166534;
    }
 
    .status-paid::before {
      content: "✓ ";
    }
  </style>
</head>
<body>
  <header role="banner">
    <img src="${invoiceData.logo_url}" alt="${invoiceData.company} logo" width="150">
    <h1>Invoice ${invoiceData.number}</h1>
  </header>
 
  <main role="main">
    <section aria-labelledby="invoice-details">
      <h2 id="invoice-details">Invoice Details</h2>
      <dl>
        <dt>Invoice Number:</dt>
        <dd>${invoiceData.number}</dd>
        <dt>Date:</dt>
        <dd><time datetime="${invoiceData.date}">${invoiceData.formatted_date}</time></dd>
        <dt>Status:</dt>
        <dd><span class="status-badge status-paid">Paid</span></dd>
      </dl>
    </section>
 
    <section aria-labelledby="customer-info">
      <h2 id="customer-info">Customer Information</h2>
      <address>
        <strong>${invoiceData.customer.name}</strong><br>
        ${invoiceData.customer.address}<br>
        ${invoiceData.customer.city}, ${invoiceData.customer.state} ${invoiceData.customer.zip}
      </address>
    </section>
 
    <section aria-labelledby="line-items">
      <h2 id="line-items">Line Items</h2>
      <table>
        <caption>Detailed breakdown of services and costs</caption>
        <thead>
          <tr>
            <th scope="col">Description</th>
            <th scope="col">Quantity</th>
            <th scope="col">Unit Price</th>
            <th scope="col">Total</th>
          </tr>
        </thead>
        <tbody>
          ${invoiceData.items.map(item => `
          <tr>
            <td>${item.description}</td>
            <td>${item.quantity}</td>
            <td>$${item.price.toFixed(2)}</td>
            <td>$${(item.quantity * item.price).toFixed(2)}</td>
          </tr>
          `).join('')}
        </tbody>
        <tfoot>
          <tr>
            <th scope="row" colspan="3">Total Amount Due</th>
            <td><strong>$${invoiceData.total.toFixed(2)}</strong></td>
          </tr>
        </tfoot>
      </table>
    </section>
  </main>
 
  <footer role="contentinfo">
    <p>
      <strong>${invoiceData.company}</strong><br>
      ${invoiceData.company_address}<br>
      Phone: <a href="tel:${invoiceData.phone}">${invoiceData.phone}</a><br>
      Email: <a href="mailto:${invoiceData.email}">${invoiceData.email}</a>
    </p>
  </footer>
</body>
</html>
  `;
 
  // Generate tagged PDF
  const pdfBytes = await generatePDF(html);
  const taggedPDF = await createTaggedPDF(pdfBytes);
 
  return taggedPDF;
}

Resources and Tools

Testing Tools

  • PAC 3 (PDF Accessibility Checker) - Free, Windows
  • Adobe Acrobat Pro - Built-in accessibility checker
  • WAVE - Web accessibility evaluation tool
  • axe DevTools - Browser extension

Screen Readers

  • NVDA - nvaccess.org (Free, Windows)
  • JAWS - freedomscientific.com (Paid, Windows)
  • VoiceOver - Built into macOS/iOS
  • TalkBack - Built into Android

Standards

  • WCAG 2.1 - w3.org/WAI/WCAG21/quickref/
  • PDF/UA - ISO 14289 (PDF Universal Accessibility)
  • Section 508 - section508.gov

Final Thoughts

Accessibility isn't a feature. It's a requirement.

Every PDF you generate should be accessible from day one. It's not harder - it just requires thinking about structure, semantics, and users.

Build accessibility in, and you'll:

  • ✅ Avoid lawsuits
  • ✅ Reach more users
  • ✅ Improve SEO
  • ✅ Build better products

Everyone wins when documents are accessible.

Ready to generate accessible PDFs? Try reportgen.io - built with WCAG compliance in mind.