diff --git a/changelogs/DP-46152.yml b/changelogs/DP-46152.yml new file mode 100644 index 0000000000..03bfa27381 --- /dev/null +++ b/changelogs/DP-46152.yml @@ -0,0 +1,41 @@ +# +# Write your changelog entry here. Every pull request must have a changelog yml file. +# +# Change types: +# ############################################################################# +# You can use one of the following types: +# - Added: For new features. +# - Changed: For changes to existing functionality. +# - Deprecated: For soon-to-be removed features. +# - Removed: For removed features. +# - Fixed: For any bug fixes. +# - Security: In case of vulnerabilities. +# +# Format +# ############################################################################# +# The format is crucial. Please follow the examples below. For reference, the requirements are: +# - All 3 parts are required and you must include "Type", "description" and "issue". +# - "Type" must be left aligned and followed by a colon. +# - "description" must be indented with 2 spaces followed by a colon +# - "issue" must be indented with 4 spaces followed by a colon. +# - "issue" is for the Jira ticket number only e.g. DP-1234 +# - No extra spaces, indents, or blank lines are allowed. +# +# Example: +# ############################################################################# +# Fixed: +# - description: Fixes scrolling on edit pages in Safari. +# issue: DP-13314 +# +# You may add more than 1 description & issue for each type using the following format: +# Changed: +# - description: Automating the release branch. +# issue: DP-10166 +# - description: Second change item that needs a description. +# issue: DP-19875 +# - description: Third change item that needs a description along with an issue. +# issue: DP-19843 +# +Added: + - description: pdfa11y module for accessibility checks. + issue: DP-46152 diff --git a/composer.json b/composer.json index 308e3ea3b8..c81a5c8124 100644 --- a/composer.json +++ b/composer.json @@ -249,6 +249,7 @@ "drupal/paragraphs": "^1.10", "drupal/pathauto": "^1.13", "drupal/pathologic": "^2", + "drupal/pdfa11y": "^1.0", "drupal/phpstorm_metadata": "^1.0@alpha", "drupal/prepopulate": "^2.0", "drupal/private_files_download_permission": "^3", @@ -498,6 +499,9 @@ "drupal/pathologic": { "Dynamic routes like image styles break under pathologic (https://www.drupal.org/project/pathologic/issues/2718473)": "https://www.drupal.org/files/issues/2020-01-29/2718473-dynamic-image-style-lookup-20.patch" }, + "drupal/pdfa11y": { + "Add figure ALT text and heading structure accessibility checks": "patches/pdfa11y/add-figure-alt-and-heading-structure-checks.patch" + }, "drupal/password_policy": { "Can't edit user profile because password policy validates even when password unchanged": "https://www.drupal.org/files/issues/2020-03-19/password_policy-empty-password-skip-validation-2971079-37.patch" }, diff --git a/composer.lock b/composer.lock index 12ddb30da9..bfe3539de8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1f4ed688d02b82a8256a0bc070a63651", + "content-hash": "6971df033b55d88ffe109955ffc6055a", "packages": [ { "name": "akamai-open/edgegrid-auth", @@ -9049,6 +9049,56 @@ "source": "https://git.drupalcode.org/project/pathologic" } }, + { + "name": "drupal/pdfa11y", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/pdfa11y.git", + "reference": "1.0.4" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/pdfa11y-1.0.4.zip", + "reference": "1.0.4", + "shasum": "6b45d16b96f1ff4a4f5d3ff3e31957468add5be5" + }, + "require": { + "drupal/core": "^10.2 || ^11", + "php": ">=8.1", + "smalot/pdfparser": "^2.0" + }, + "require-dev": { + "drush/drush": "14.x-dev" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "1.0.4", + "datestamp": "1773961767", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "joshuami", + "homepage": "https://www.drupal.org/user/434354" + } + ], + "description": "Checks uploaded PDF files for accessibility compliance.", + "homepage": "https://www.drupal.org/project/pdfa11y", + "support": { + "source": "https://git.drupalcode.org/project/pdfa11y", + "issues": "https://www.drupal.org/project/issues/pdfa11y" + } + }, { "name": "drupal/phpstorm_metadata", "version": "1.0.0-alpha4", @@ -16747,6 +16797,57 @@ }, "time": "2020-12-15T21:32:01+00:00" }, + { + "name": "smalot/pdfparser", + "version": "v2.12.4", + "source": { + "type": "git", + "url": "https://github.com/smalot/pdfparser.git", + "reference": "028d7cc0ceff323bc001d763caa2bbdf611866c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/smalot/pdfparser/zipball/028d7cc0ceff323bc001d763caa2bbdf611866c4", + "reference": "028d7cc0ceff323bc001d763caa2bbdf611866c4", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-zlib": "*", + "php": ">=7.1", + "symfony/polyfill-mbstring": "^1.18" + }, + "type": "library", + "autoload": { + "psr-0": { + "Smalot\\PdfParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0" + ], + "authors": [ + { + "name": "Sebastien MALOT", + "email": "sebastien@malot.fr" + } + ], + "description": "Pdf parser library. Can read and extract information from pdf file.", + "homepage": "https://www.pdfparser.org", + "keywords": [ + "extract", + "parse", + "parser", + "pdf", + "text" + ], + "support": { + "issues": "https://github.com/smalot/pdfparser/issues", + "source": "https://github.com/smalot/pdfparser/tree/v2.12.4" + }, + "time": "2026-03-10T15:39:47+00:00" + }, { "name": "symfony/cache", "version": "v7.4.5", diff --git a/conf/drupal/config/core.extension.yml b/conf/drupal/config/core.extension.yml index ea3738276b..69e780fd90 100644 --- a/conf/drupal/config/core.extension.yml +++ b/conf/drupal/config/core.extension.yml @@ -195,6 +195,7 @@ module: path: 0 path_alias: 0 pathologic: 0 + pdfa11y: 0 pfdp: 0 phpass: 0 prepopulate: 0 diff --git a/conf/drupal/config/mass_utility.settings.yml b/conf/drupal/config/mass_utility.settings.yml index 0f60a85d54..a7d2900578 100644 --- a/conf/drupal/config/mass_utility.settings.yml +++ b/conf/drupal/config/mass_utility.settings.yml @@ -1,8 +1,8 @@ allowed_urls: "https://www.youtube.com/\r\nhttps://docs.digital.mass.gov\r\nhttps://public.dep.state.ma.us/\r\nhttps://calendar.google.com/\r\nhttps://dashboards.digital.mass.gov/\r\nhttps://docs.google.com/\r\nhttps://drive.google.com/\r\nhttps://fusiontables.googleusercontent.com/\r\nhttps://libraryh3lp.com/\r\nhttps://mass-eoeea.maps.arcgis.com/\r\nhttps://massgov.formstack.com/forms/sample\r\nhttps://massgov.github.io\r\nhttps://public.tableau.com/\r\nhttps://www.google.com/\r\nhttps://www.massdot.state.ma.us/\r\nhttps://www.massmarinefisheries.net/\r\nhttps://www.youtube.com/\r\nhttps://youtu.be/\r\nhttps://memamaps.maps.arcgis.com/\r\nhttps://maps.google.com/\r\nhttps://licensing.reg.state.ma.us/\r\nhttps://hwy.massdot.state.ma.us/\r\nhttps://dphanalytics.hhs.mass.gov/\r\nhttps://code.highcharts.com/\r\nhttps://eoeea.maps.arcgis.com/\r\nhttps://eeaonline.eea.state.ma.us/\r\nhttps://gis.massdot.state.ma.us/\r\nhttps://dotfeeds.state.ma.us/\r\nhttps://massgis.maps.arcgis.com/\r\nhttps://recollect.net/\r\nhttp://massdot.maps.arcgis.com/\r\nhttps://massdot.maps.arcgis.com/\r\nhttps://calculator.digital.mass.gov/\r\nhttps://api.recollect.net/\r\nhttps://www.eia.gov/beta/states/iframe\r\nhttps://mdphgis.maps.arcgis.com/\r\nhttps://app.powerbigov.us/\r\nhttps://calc.a4we.org/\r\nhttps://w.soundcloud.com/\r\nhttps://www.google.com/maps\r\nhttps://nedews.nrcc.cornell.edu/\r\nhttps://flo.uri.sh/\r\nhttps://app.smartsheet.com/\r\nhttps://experience.arcgis.com/\r\nhttps://hedfuel.azurewebsites.net/\r\nhttps://dhcd-production-public.s3.amazonaws.com/\r\nhttps://cloud.samsara.com/o/8600/fleet/viewer/\r\nhttps://hwywebqa.massdot.state.ma.us\r\nhttps://player.vimeo.com/video/\r\nhttps://massgov.formstack.com/forms/" forms_allowed_hostnames: - - '/^mass-forms\.ddev\.site$/' - - '/^forms\.mass\.local$/' - - '/^forms\.mass\.gov$/' + - /^mass-forms\.ddev\.site$/ + - /^forms\.mass\.local$/ + - /^forms\.mass\.gov$/ - '/^[a-zA-Z0-9\-]+-mass-forms\.pantheonsite\.io$/' - '/^[a-zA-Z0-9\-]+\.forms\.mass\.gov$/' header_mixed_urls: "\r\n" diff --git a/conf/drupal/config/pdfa11y.help.yml b/conf/drupal/config/pdfa11y.help.yml new file mode 100644 index 0000000000..76f275891b --- /dev/null +++ b/conf/drupal/config/pdfa11y.help.yml @@ -0,0 +1,3 @@ +_core: + default_config_hash: qG3sK-CpYFfcTOo9g2Ji9tI73GvTfO-LFblSox2wI2I +help_content: "

Creating Accessible PDFs

\r\n\r\n

Accessible PDFs ensure that everyone, including people who use screen readers, magnification software, or other assistive technologies, can read and navigate your content. When PDFs lack proper structure, tags, or alternative text, they can be completely unusable for people with disabilities. Making PDFs accessible is not only a best practice — it's required.

\r\n

See the PDF Document Accessibility Testing Checklist.\r\n" diff --git a/conf/drupal/config/pdfa11y.settings.yml b/conf/drupal/config/pdfa11y.settings.yml new file mode 100644 index 0000000000..317146dff6 --- /dev/null +++ b/conf/drupal/config/pdfa11y.settings.yml @@ -0,0 +1,13 @@ +_core: + default_config_hash: bpMUG7_m9cybmlkiNQxtOxmYmgx2eMtHSSQF2ZeT53Q +enabled_checks: + - pdf_version + - tagged_pdf + - document_title + - document_language + - figure_alt_text + - heading_structure +min_pdf_version: '1.4' +check_on_upload: true +block_failed_uploads: false +editor_instructions: 'For guidance on creating accessible PDF documents, see the PDF Document Accessibility Testing Checklist (opens in new tab).' diff --git a/patches/pdfa11y/add-figure-alt-and-heading-structure-checks.patch b/patches/pdfa11y/add-figure-alt-and-heading-structure-checks.patch new file mode 100644 index 0000000000..d70a9e85f7 --- /dev/null +++ b/patches/pdfa11y/add-figure-alt-and-heading-structure-checks.patch @@ -0,0 +1,285 @@ +diff --git a/src/Plugin/AccessibilityCheck/FigureAltTextCheck.php b/src/Plugin/AccessibilityCheck/FigureAltTextCheck.php +new file mode 100644 +index 0000000..84b728a +--- /dev/null ++++ b/src/Plugin/AccessibilityCheck/FigureAltTextCheck.php +@@ -0,0 +1,146 @@ ++getObjectsByType('Catalog'); ++ if (empty($catalogs)) { ++ return $this->fail( ++ (string) $this->t('Could not locate the document catalog to inspect figure ALT text.'), ++ AccessibilityCheckResult::SEVERITY_ERROR, ++ ); ++ } ++ ++ $catalog = reset($catalogs); ++ $structTreeRoot = $catalog->get('StructTreeRoot'); ++ if ($structTreeRoot instanceof ElementMissing || $structTreeRoot === NULL) { ++ return $this->notApplicable( ++ (string) $this->t('No structure tree found. Figure ALT text can only be validated for tagged PDFs.'), ++ ); ++ } ++ ++ $figuresWithoutAlt = 0; ++ $totalFigures = 0; ++ $this->inspectNode($structTreeRoot, $totalFigures, $figuresWithoutAlt); ++ ++ if ($totalFigures === 0) { ++ return $this->notApplicable( ++ (string) $this->t('No Figure elements were found in the structure tree.'), ++ ); ++ } ++ ++ if ($figuresWithoutAlt === 0) { ++ return $this->pass( ++ (string) $this->t('All @count Figure elements include ALT text.', ['@count' => (string) $totalFigures]), ++ ); ++ } ++ ++ return $this->fail( ++ (string) $this->t('@missing of @total Figure elements are missing ALT text.', [ ++ '@missing' => (string) $figuresWithoutAlt, ++ '@total' => (string) $totalFigures, ++ ]), ++ ); ++ } ++ ++ /** ++ * Recursively inspects structure tree nodes for Figure elements. ++ * ++ * @param mixed $node ++ * Current node. ++ * @param int $totalFigures ++ * Total Figure count. ++ * @param int $figuresWithoutAlt ++ * Figure count missing ALT text. ++ */ ++ protected function inspectNode(mixed $node, int &$totalFigures, int &$figuresWithoutAlt): void { ++ if (is_array($node)) { ++ foreach ($node as $child) { ++ $this->inspectNode($child, $totalFigures, $figuresWithoutAlt); ++ } ++ return; ++ } ++ ++ if (!is_object($node)) { ++ return; ++ } ++ ++ if (method_exists($node, 'get')) { ++ $s = $node->get('S'); ++ if ((string) $s === 'Figure') { ++ $totalFigures++; ++ if (!$this->hasAltText($node)) { ++ $figuresWithoutAlt++; ++ } ++ } ++ ++ $children = $node->get('K'); ++ if (!($children instanceof ElementMissing) && $children !== NULL) { ++ $this->inspectNode($children, $totalFigures, $figuresWithoutAlt); ++ } ++ } ++ ++ if (method_exists($node, 'getDetails')) { ++ $details = $node->getDetails(FALSE); ++ if (is_array($details) && isset($details['K'])) { ++ $this->inspectNode($details['K'], $totalFigures, $figuresWithoutAlt); ++ } ++ } ++ } ++ ++ /** ++ * Checks whether a structure element contains non-empty ALT text. ++ * ++ * @param object $node ++ * Structure element object. ++ * ++ * @return bool ++ * TRUE when ALT text exists and is non-empty. ++ */ ++ protected function hasAltText(object $node): bool { ++ if (method_exists($node, 'get')) { ++ $alt = $node->get('Alt'); ++ if ($alt instanceof ElementString) { ++ return trim((string) $alt) !== ''; ++ } ++ if (!($alt instanceof ElementMissing) && trim((string) $alt) !== '') { ++ return TRUE; ++ } ++ } ++ ++ if (method_exists($node, 'getDetails')) { ++ $details = $node->getDetails(FALSE); ++ if (is_array($details) && isset($details['Alt']) && trim((string) $details['Alt']) !== '') { ++ return TRUE; ++ } ++ } ++ ++ return FALSE; ++ } ++ ++} +diff --git a/src/Plugin/AccessibilityCheck/HeadingStructureCheck.php b/src/Plugin/AccessibilityCheck/HeadingStructureCheck.php +new file mode 100644 +index 0000000..799678c +--- /dev/null ++++ b/src/Plugin/AccessibilityCheck/HeadingStructureCheck.php +@@ -0,0 +1,127 @@ ++getObjectsByType('Catalog'); ++ if (empty($catalogs)) { ++ return $this->fail( ++ (string) $this->t('Could not locate the document catalog to inspect heading structure.'), ++ AccessibilityCheckResult::SEVERITY_ERROR, ++ ); ++ } ++ ++ $catalog = reset($catalogs); ++ $structTreeRoot = $catalog->get('StructTreeRoot'); ++ if ($structTreeRoot instanceof ElementMissing || $structTreeRoot === NULL) { ++ return $this->notApplicable( ++ (string) $this->t('No structure tree found. Heading structure can only be validated for tagged PDFs.'), ++ ); ++ } ++ ++ $headings = []; ++ $this->collectHeadings($structTreeRoot, $headings); ++ ++ if (empty($headings)) { ++ return $this->notApplicable( ++ (string) $this->t('No heading elements (H1-H6) were found in the structure tree.'), ++ ); ++ } ++ ++ $firstHeading = $headings[0]; ++ if ($firstHeading !== 1) { ++ return $this->fail( ++ (string) $this->t('First heading should be H1, but found H@level.', ['@level' => (string) $firstHeading]), ++ ); ++ } ++ ++ $skips = []; ++ for ($i = 1; $i < count($headings); $i++) { ++ $previous = $headings[$i - 1]; ++ $current = $headings[$i]; ++ if ($current > ($previous + 1)) { ++ $skips[] = 'H' . $previous . ' -> H' . $current; ++ } ++ } ++ ++ if (!empty($skips)) { ++ return $this->fail( ++ (string) $this->t('Skipped heading levels detected: @skips.', ['@skips' => implode(', ', $skips)]), ++ ); ++ } ++ ++ return $this->pass( ++ (string) $this->t('Heading structure passed: first heading is H1 and no skipped downward levels were found.'), ++ ); ++ } ++ ++ /** ++ * Recursively collects heading levels from structure tree. ++ * ++ * @param mixed $node ++ * Current node. ++ * @param int[] $headings ++ * Collected heading levels in discovered order. ++ */ ++ protected function collectHeadings(mixed $node, array &$headings): void { ++ if (is_array($node)) { ++ foreach ($node as $child) { ++ $this->collectHeadings($child, $headings); ++ } ++ return; ++ } ++ ++ if (!is_object($node)) { ++ return; ++ } ++ ++ if (method_exists($node, 'get')) { ++ $structureType = (string) $node->get('S'); ++ if (preg_match('/^H([1-6])$/', $structureType, $matches)) { ++ $headings[] = (int) $matches[1]; ++ } ++ ++ $children = $node->get('K'); ++ if (!($children instanceof ElementMissing) && $children !== NULL) { ++ $this->collectHeadings($children, $headings); ++ } ++ } ++ ++ if (method_exists($node, 'getDetails')) { ++ $details = $node->getDetails(FALSE); ++ if (is_array($details)) { ++ if (isset($details['S']) && is_string($details['S']) && preg_match('/^H([1-6])$/', $details['S'], $matches)) { ++ $headings[] = (int) $matches[1]; ++ } ++ if (isset($details['K'])) { ++ $this->collectHeadings($details['K'], $headings); ++ } ++ } ++ } ++ } ++ ++}