WCAG Validation Rules β Complete Reference
This document describes every accessibility rule checked by both validation layers in the Accessible PDF Converter pipeline: the custom regex-based validator and the axe-core browser-based auditor.
Architecture Overview
The pipeline runs two complementary validation layers on every converted HTML file:
| Layer | Engine | Speed | Rules | Auto-Fix | Runs When |
|---|---|---|---|---|---|
| Custom Validator | Regex on HTML strings | < 50ms | 20 rules | 12 auto-fixable | Before saving HTML β fixes issues upstream |
| axe-core Auditor | Browser-based (Puppeteer) | 3β8s | ~90+ rules | 16 auto-fixable | After saving β comprehensive audit with fix loop |
The custom validator runs first as a fast pre-pass. It fixes common structural issues so that axe-core sees cleaner HTML and reports fewer violations. axe-core then performs a thorough audit using a real DOM, computed styles, and layout information.
Source files:
- Custom validator:
workers/api/src/services/wcag-validator.ts - Color utilities:
workers/api/src/utils/color.ts - axe-core auditor:
workers/api/src/services/axe-validator.ts - axe-core fixer:
workers/api/src/services/axe-fixer.ts
Part 1: Custom Validator Rules (20 rules)
The custom validator operates entirely on HTML strings using regex pattern matching. It has no DOM, no computed styles, and no layout engine. Its value is speed and upstream fixing β issues are corrected before the HTML is persisted.
Document Structure Rules
| # | Rule ID | WCAG SC | Level | Auto-Fix | Description |
|---|---|---|---|---|---|
| 1 | document-title | 2.4.2 | A | Yes | Document must have a <title> element |
| 2 | html-has-lang | 3.1.1 | A | Yes | <html> element must have a lang attribute |
| 3 | document-lang-valid | 3.1.1 | A | No | lang attribute value must be a valid BCP 47 code (e.g., en, fr-CA) |
| 4 | meta-viewport | 1.4.4 | AA | No | Viewport meta tag must be present |
| 5 | landmark-one-main | Best Practice | A | Yes | Document should have one <main> landmark |
| 6 | skip-link | 2.4.1 | A | Yes | Document should have a skip-to-content link |
Auto-fix details:
document-titleβ Inserts<title>Converted Document</title>into<head>.html-has-langβ Addslang="en"to the<html>element.landmark-one-mainβ Wraps body content in<main role="main">.skip-linkβ Inserts<a href="#main-content" class="skip-link">Skip to main content</a>after<body>.
Text and Naming Rules
| # | Rule ID | WCAG SC | Level | Auto-Fix | Description |
|---|---|---|---|---|---|
| 7 | image-alt | 1.1.1 | A | Yes | <img> elements must have an alt attribute |
| 8 | link-name | 2.4.4 | A | Yes | Links with href must have discernible text or aria-label |
| 9 | button-name | 4.1.2 | A | Yes | Buttons must have discernible text or aria-label |
| 10 | label | 1.3.1 | A | Yes | Form inputs (except hidden/submit/button/reset/image) must have associated labels |
| 11 | empty-heading | 1.3.1 | A | Yes | <h1> through <h6> elements must not be empty |
Auto-fix details:
image-altβ Addsalt="Image - description needed"to images missingalt.link-nameβ Addsaria-label="Link - description needed"to empty links.button-nameβ Addsaria-label="Button - description needed"to empty buttons.labelβ Addsaria-label="Input field - label needed"to unlabeled inputs.empty-headingβ Removes the empty heading element entirely.
Heading and Landmark Rules
| # | Rule ID | WCAG SC | Level | Auto-Fix | Description |
|---|---|---|---|---|---|
| 12 | heading-order | 1.3.1 | AA | No | Heading levels should increase sequentially (no jumps from h1 to h3) |
Table Rules
| # | Rule ID | WCAG SC | Level | Auto-Fix | Description |
|---|---|---|---|---|---|
| 13 | table-has-header | 1.3.1 | A | Yes | <table> elements must contain at least one <th> |
| 14 | empty-table-header | 1.3.1 | A | No | <th> elements must not be empty |
| 15 | scope-attr-valid | 1.3.1 | A | Yes | scope attribute must be row, col, rowgroup, or colgroup |
Auto-fix details:
table-has-headerβ Promotes<td>cells in the first<tr>to<th scope="col">.scope-attr-validβ Removes invalidscopeattributes.
List Rules
| # | Rule ID | WCAG SC | Level | Auto-Fix | Description |
|---|---|---|---|---|---|
| 16 | list-structure | 1.3.1 | A | No | <ul> and <ol> must only contain <li> as direct children; orphan <li> outside lists is flagged |
| 17 | definition-list | 1.3.1 | A | No | <dl> must only contain <dt>, <dd>, or <div> as direct children |
ID Uniqueness
| # | Rule ID | WCAG SC | Level | Auto-Fix | Description |
|---|---|---|---|---|---|
| 18 | duplicate-id | 4.1.1 | A | Yes | Element id attributes must be unique within the document |
Auto-fix details:
duplicate-idβ Appends-2,-3, etc. suffix to second and subsequent occurrences of the sameid.
Color Contrast
| # | Rule ID | WCAG SC | Level | Auto-Fix | Description |
|---|---|---|---|---|---|
| 19β20 | color-contrast | 1.4.3 / 1.4.6 | AA / AAA | No | Text must have sufficient contrast ratio against its background |
The color-contrast rule performs real WCAG luminance calculations using workers/api/src/utils/color.ts:
Known injected color pairs checked:
| Element | Foreground | Background | Ratio | AA (4.5:1) | AAA (7:1) |
|---|---|---|---|---|---|
| Body text | #1a1a1a | #ffffff | 17.4:1 | Pass | Pass |
| Figcaption | #555555 | #ffffff | 7.5:1 | Pass | Pass |
| Secondary text | #4a4a68 | #ffffff | 7.0:1 | Pass | Pass |
| Heading text | #1a1a2e | #ffffff | 16.0:1 | Pass | Pass |
| Source header | #495057 | #f8f9fa | 7.0:1 | Pass | Pass |
Inline style checking: Elements with style attributes containing both color and background-color (or background) are parsed and checked.
Scope limitation: Does not resolve CSS cascade, var(), inherit, currentColor, or external stylesheets β those remain axe-coreβs job.
Part 2: axe-core Auditor Rules (~90+ rules)
axe-core v4.10.2 runs in a Puppeteer browser instance with full DOM, computed styles, and layout information. It is configured with the following tag filters:
wcag2a, wcag2aa, wcag2aaa, wcag21a, wcag21aa, wcag21aaa, wcag22a, wcag22aa, wcag22aaa, best-practiceThis covers WCAG 2.0 through 2.2 at all levels (A, AA, AAA) plus best practices β approximately 90β100 unique rules.
WCAG 2.0/2.1/2.2 Level A Rules
These rules test the most fundamental accessibility requirements.
| Rule ID | Description | WCAG SC |
|---|---|---|
area-alt | Image map <area> elements must have alternate text | 1.1.1 |
aria-allowed-attr | ARIA attributes must be appropriate for the elementβs role | 4.1.2 |
aria-braille-equivalent | aria-braillelabel and aria-brailleroledescription must have non-braille equivalent | 4.1.2 |
aria-command-name | ARIA buttons, links, and menuitems must have accessible names | 4.1.2 |
aria-conditional-attr | ARIA attributes must be used correctly for the current state | 4.1.2 |
aria-deprecated-role | Deprecated ARIA roles must not be used | 4.1.2 |
aria-hidden-body | aria-hidden="true" must not be present on <body> | 4.1.2 |
aria-hidden-focus | aria-hidden elements must not contain focusable elements | 4.1.2 |
aria-input-field-name | ARIA input fields must have accessible names | 4.1.2 |
aria-meter-name | ARIA meter elements must have accessible names | 1.1.1 |
aria-progressbar-name | ARIA progressbar elements must have accessible names | 1.1.1 |
aria-prohibited-attr | ARIA attributes must not be prohibited for the elementβs role | 4.1.2 |
aria-required-attr | Required ARIA attributes must be present | 4.1.2 |
aria-required-children | ARIA roles must contain required child roles | 4.1.2 |
aria-required-parent | ARIA roles must be contained by required parent roles | 4.1.2 |
aria-roles | ARIA role values must be valid | 4.1.2 |
aria-toggle-field-name | ARIA toggle fields must have accessible names | 4.1.2 |
aria-tooltip-name | ARIA tooltip elements must have accessible names | 4.1.2 |
aria-valid-attr-value | ARIA attribute values must be valid | 4.1.2 |
aria-valid-attr | ARIA attribute names must be valid | 4.1.2 |
blink | <blink> elements must not be used | 2.2.2 |
button-name | Buttons must have discernible text | 4.1.2 |
bypass | Pages must have a mechanism to bypass repeated content | 2.4.1 |
definition-list | <dl> elements must be structured correctly | 1.3.1 |
dlitem | <dt> and <dd> must be contained by a <dl> | 1.3.1 |
document-title | Documents must have a <title> element | 2.4.2 |
duplicate-id-aria | IDs used in ARIA and labels must be unique | 4.1.1 |
form-field-multiple-labels | Form fields must not have multiple labels | 1.3.1 |
frame-focusable-content | Frames with focusable content must not have tabindex="-1" | 2.1.1 |
frame-title-unique | <iframe> and <frame> elements must have unique titles | 4.1.2 |
frame-title | Frames must have accessible names | 4.1.2 |
html-has-lang | <html> element must have a lang attribute | 3.1.1 |
html-lang-valid | <html> element lang attribute must be valid | 3.1.1 |
html-xml-lang-mismatch | lang and xml:lang must match | 3.1.1 |
image-alt | Images must have alternate text | 1.1.1 |
input-button-name | Input buttons must have discernible text | 4.1.2 |
input-image-alt | <input type="image"> must have alternate text | 1.1.1 |
label | Form elements must have labels | 1.3.1 |
link-in-text-block | Links must be distinguishable without relying on color | 1.4.1 |
link-name | Links must have discernible text | 2.4.4 |
list | <ul> and <ol> must only contain <li>, <script>, or <template> | 1.3.1 |
listitem | <li> elements must be contained within <ul> or <ol> | 1.3.1 |
marquee | <marquee> elements must not be used | 2.2.2 |
meta-refresh | Timed meta refresh must not be used | 2.2.1 |
nested-interactive | Interactive controls must not be nested | 4.1.2 |
no-autoplay-audio | Audio must not autoplay for more than 3 seconds | 1.4.2 |
object-alt | <object> elements must have alternate text | 1.1.1 |
role-img-alt | Elements with role="img" must have alternate text | 1.1.1 |
scrollable-region-focusable | Scrollable regions must be keyboard accessible | 2.1.1 |
select-name | <select> elements must have accessible names | 1.3.1 |
server-side-image-map | Server-side image maps must not be used | 2.1.1 |
summary-name | <summary> elements must have discernible text | 4.1.2 |
svg-img-alt | SVG elements with role="img" must have alternate text | 1.1.1 |
td-headers-attr | headers attribute values must refer to cells in the same table | 1.3.1 |
th-has-data-cells | Table headers must be associated with data cells | 1.3.1 |
video-caption | <video> elements must have captions | 1.2.2 |
WCAG 2.0/2.1/2.2 Level AA Rules
| Rule ID | Description | WCAG SC |
|---|---|---|
color-contrast | Text must have sufficient color contrast (4.5:1 normal, 3:1 large) | 1.4.3 |
valid-lang | lang attribute values must be valid | 3.1.2 |
meta-viewport | Viewport must not disable text scaling | 1.4.4 |
autocomplete-valid | autocomplete attribute values must be valid | 1.3.5 |
avoid-inline-spacing | Inline text spacing must be adjustable | 1.4.12 |
target-size | Touch targets must be at least 24x24 CSS pixels | 2.5.8 |
WCAG 2.0/2.1/2.2 Level AAA Rules
| Rule ID | Description | WCAG SC |
|---|---|---|
color-contrast-enhanced | Text must have enhanced color contrast (7:1 normal, 4.5:1 large) | 1.4.6 |
identical-links-same-purpose | Links with identical text must serve the same purpose | 2.4.9 |
meta-refresh-no-exceptions | Timed meta refresh must not be used (no exceptions) | 2.2.1 |
Best Practice Rules
These are not formal WCAG requirements but are widely recommended for accessibility.
| Rule ID | Description |
|---|---|
accesskeys | accesskey attribute values must be unique |
aria-allowed-role | ARIA roles must be appropriate for the element |
aria-dialog-name | ARIA dialog and alertdialog must have accessible names |
aria-text | role="text" must be used correctly |
aria-treeitem-name | ARIA treeitem elements must have accessible names |
empty-heading | Headings must not be empty |
empty-table-header | Table header cells must not be empty |
heading-order | Heading levels should increase sequentially |
image-redundant-alt | Image alt text must not duplicate surrounding text |
label-title-only | Form elements should not use title as only label |
landmark-banner-is-top-level | Banner landmark must be at top level |
landmark-complementary-is-top-level | Complementary landmark must be at top level |
landmark-contentinfo-is-top-level | Contentinfo landmark must be at top level |
landmark-main-is-top-level | Main landmark must be at top level |
landmark-no-duplicate-banner | Document must not have more than one banner landmark |
landmark-no-duplicate-contentinfo | Document must not have more than one contentinfo landmark |
landmark-no-duplicate-main | Document must not have more than one main landmark |
landmark-one-main | Document must have one main landmark |
landmark-unique | Landmarks must have unique labels |
meta-viewport-large | Viewport should allow significant zoom |
page-has-heading-one | Page should contain a level-one heading |
presentation-role-conflict | Elements with conflicting ARIA on presentational role |
region | All page content must be contained within landmarks |
scope-attr-valid | scope attribute values must be valid |
skip-link | Skip links must have valid targets |
tabindex | Elements should not have tabindex greater than 0 |
table-duplicate-name | Table <caption> and summary must not be identical |
Part 3: axe-core Auto-Fix Engine (16 fixable violations)
The axe-core fix engine (axe-fixer.ts) runs a fix-then-re-audit loop up to 3 iterations with revert-on-regression safety.
| Rule ID | Fix Strategy |
|---|---|
region | Wraps orphan body content in <section role="region" aria-label="Content"> |
color-contrast | Forces color: #1a1a1a !important; background-color: #ffffff !important on affected elements |
heading-order | Remaps heading levels to sequential order (e.g., h1βh3 becomes h1βh2) |
image-alt | Adds alt="Image" to images missing alt text |
svg-img-alt | Adds aria-label="Mathematical expression" to SVGs with role="img" |
link-name | Adds aria-label="Link" to empty links |
button-name | Adds aria-label="Button" to empty buttons |
label | Adds aria-label="Input field" to unlabeled inputs |
document-title | Adds <title>Document</title> to <head> |
html-has-lang | Adds lang="en" to <html> |
landmark-one-main | Wraps body content in <main role="main"> |
list | Wraps orphan <li> elements in <ul> |
listitem | Same as list β wraps orphan <li> elements in <ul> |
landmark-unique | Adds unique aria-label attributes to duplicate landmarks |
meta-viewport | Adds <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
scrollable-region-focusable | Adds tabindex="0" to scrollable regions |
Fix loop behavior:
- Apply deterministic regex fixes for current violations
- Re-audit with axe-core (1.5s delay between attempts to avoid rate limits)
- If violation count increases β revert and stop (regression safety)
- If violation count reaches 0 β stop (all fixed)
- If no remaining fixable violations β stop
- Repeat up to 3 times
Part 4: Rule Overlap Between Layers
Many rules are checked by both layers. The custom validator catches them first (and fixes them), so axe-core typically sees the already-fixed HTML.
| Rule | Custom Validator | axe-core | Notes |
|---|---|---|---|
document-title | Check + fix | Check + fix | Custom fixes first |
html-has-lang | Check + fix | Check + fix | Custom fixes first |
image-alt | Check + fix | Check + fix | Custom fixes first |
link-name | Check + fix | Check + fix | Custom fixes first |
button-name | Check + fix | Check + fix | Custom fixes first |
label | Check + fix | Check + fix | Custom fixes first |
heading-order | Warn only | Check + fix | axe-core can remap levels |
landmark-one-main | Warn + fix | Check + fix | Custom fixes first |
skip-link | Warn + fix | Check (target valid) | Different scope |
meta-viewport | Check only | Check + fix | axe-core also checks zoom |
color-contrast | Check (inline only) | Full computed check | axe-core handles cascade |
empty-heading | Check + fix | Check (best practice) | Custom fixes first |
empty-table-header | Warn only | Check (best practice) | Needs content knowledge |
scope-attr-valid | Check + fix | Check (best practice) | Custom fixes first |
list-structure / list | Warn only | Check + fix | axe-core can wrap orphans |
definition-list | Warn only | Check | Both detect-only in custom |
duplicate-id | Check + fix | duplicate-id-aria | Slightly different scope |
table-has-header | Check + fix | th-has-data-cells | Complementary checks |
Rules unique to axe-core (not in custom validator):
All ARIA validation rules, frame/iframe rules, video/audio rules, target-size, autocomplete-valid, avoid-inline-spacing, color-contrast-enhanced, region, scrollable-region-focusable, landmark-unique, nested-interactive, and many more.
Rules unique to custom validator:
document-lang-validβ validates the lang attribute formattable-has-headerβ specifically checks for<th>presence (axe checks header-to-data-cell associations differently)
Part 5: What Cannot Be Checked Without a Browser
The following WCAG success criteria require runtime behavior, computed styles, or layout information that neither regex nor static analysis can provide. These are exclusively axe-coreβs domain:
| Category | Example Rules | WCAG SC | Why Browser Required |
|---|---|---|---|
| Focus management | focus-order-semantics, tabindex | 2.4.3 | Requires sequential focus navigation |
| Target size | target-size | 2.5.8 | Needs computed bounding box |
| Reflow | meta-viewport-large | 1.4.10 | Requires rendering at different widths |
| Text spacing | avoid-inline-spacing | 1.4.12 | Needs computed styles after cascade |
| Computed contrast | CSS variables, currentColor, gradients | 1.4.3 / 1.4.6 | Needs resolved computed styles |
| Visible labels | label-content-name-mismatch | 2.5.3 | Needs computed accessible name vs. visible text |
| Scrollable regions | scrollable-region-focusable | 2.1.1 | Needs overflow detection |
Summary
| Metric | Custom Validator | axe-core |
|---|---|---|
| Total rules | 20 | ~90+ |
| Auto-fixable | 12 | 16 |
| Execution time | < 50ms | 3β8s |
| Requires browser | No | Yes |
| WCAG coverage | A + some AA | A through AAA + best practices |
| Fix strategy | Regex replace | Regex replace + re-audit loop |
| Regression safety | Iterates until stable | Reverts if violations increase |
Combined, the two layers provide comprehensive WCAG 2.2 AAA coverage with aggressive upstream auto-fixing.