From 1c199990885174a179fd9a71cdd3991d1211442a Mon Sep 17 00:00:00 2001 From: Joe Galluccio Date: Tue, 7 Apr 2026 10:01:28 -0400 Subject: [PATCH 1/3] new module and configs --- composer.json | 1 + composer.lock | 103 ++++++++++++++++++- conf/drupal/config/core.extension.yml | 1 + conf/drupal/config/mass_utility.settings.yml | 6 +- conf/drupal/config/pdfa11y.help.yml | 3 + conf/drupal/config/pdfa11y.settings.yml | 11 ++ 6 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 conf/drupal/config/pdfa11y.help.yml create mode 100644 conf/drupal/config/pdfa11y.settings.yml diff --git a/composer.json b/composer.json index e5835c891e..c18ec21885 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", diff --git a/composer.lock b/composer.lock index 50cba7a8a8..bbbed48f63 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": "6210d5324ba762bdc4e9a2b52bf782bb", + "content-hash": "d13402aa7533f72698f95a5fcf039695", "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..940f6213ae --- /dev/null +++ b/conf/drupal/config/pdfa11y.settings.yml @@ -0,0 +1,11 @@ +_core: + default_config_hash: bpMUG7_m9cybmlkiNQxtOxmYmgx2eMtHSSQF2ZeT53Q +enabled_checks: + - pdf_version + - tagged_pdf + - document_title + - document_language +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).' From 218e262524fdf20d629831f61e8bbbc4e0eb78d5 Mon Sep 17 00:00:00 2001 From: Arthur Baghdasaryan Date: Tue, 14 Apr 2026 10:28:19 +0400 Subject: [PATCH 2/3] New plugins --- composer.json | 3 + composer.lock | 2 +- conf/drupal/config/pdfa11y.settings.yml | 2 + ...ure-alt-and-heading-structure-checks.patch | 285 ++++++++++++++++++ 4 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 patches/pdfa11y/add-figure-alt-and-heading-structure-checks.patch diff --git a/composer.json b/composer.json index 7f864dd52f..277181aa43 100644 --- a/composer.json +++ b/composer.json @@ -499,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 36483667bf..8fa8e0308b 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": "961f67b35f19e67a9f2152ba98a61821", + "content-hash": "f492663629b049fee9085d8c47edd6b2", "packages": [ { "name": "akamai-open/edgegrid-auth", diff --git a/conf/drupal/config/pdfa11y.settings.yml b/conf/drupal/config/pdfa11y.settings.yml index 940f6213ae..317146dff6 100644 --- a/conf/drupal/config/pdfa11y.settings.yml +++ b/conf/drupal/config/pdfa11y.settings.yml @@ -5,6 +5,8 @@ enabled_checks: - tagged_pdf - document_title - document_language + - figure_alt_text + - heading_structure min_pdf_version: '1.4' check_on_upload: true block_failed_uploads: false 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); ++ } ++ } ++ } ++ } ++ ++} From a2b33da6977483bc9952b756d173d22c286c477e Mon Sep 17 00:00:00 2001 From: Arthur Baghdasaryan Date: Tue, 14 Apr 2026 10:29:19 +0400 Subject: [PATCH 3/3] New plugins --- changelogs/DP-46152.yml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 changelogs/DP-46152.yml 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