Private/check-pptx-accessibility.ps1
|
<# .SYNOPSIS Runs Microsoft Office Accessibility Checker rules against a .pptx/.pptm file using the Open XML SDK. .DESCRIPTION Mirrors the rule subset the built-in PowerPoint Accessibility Checker surfaces, walking presentation XML directly via the Open XML SDK. No Office install required. Rule coverage: ERROR: MissingAltText, MissingSlideTitle, MissingTableHeaders, DocumentProtected WARNING: DuplicateSlideTitle, MergedTableCells, LowContrast, NonDescriptiveLinkText Notes: - LowContrast: best-effort. Only flags runs whose foreground text color and the owning-shape (or slide-background) fill are both explicit RGB. Theme/scheme references, gradient/non-solid fills, and any inherited colors are skipped. - NonDescriptiveLinkText: run-level hyperlinks only (a:hlinkClick on a run). Fires when the visible text is empty, equals the URL, or matches a known generic blacklist. .PARAMETER FilePath Path to a .pptx or .pptm file. .PARAMETER Format 'text' (default) emits a single PASS/FAIL line. 'detailed' emits every issue (one tab-separated line) followed by the PASS/FAIL summary line. .PARAMETER Fix When set, after running the checks the script writes a sibling file named <basename>.fixed.pptx (or .pptm) with deterministic structural remediations applied for the rules MissingTableHeaders and LowContrast. The original file is never modified. .OUTPUTS Exit codes: 0 no errors found (warnings do not fail) 1 one or more accessibility errors found (includes IRM/password-protected) 2 tool error (file not found, unsupported format, SDK load failure, file locked) With -Fix, the exit code reflects the FIXED file when fixes were applied, otherwise the original file. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $FilePath, [ValidateSet('text','detailed')] [string] $Format = 'text', [switch] $Fix ) $ErrorActionPreference = 'Stop' # --- Pinned OOXML namespaces ------------------------------------------------- $NS = @{ p = 'http://schemas.openxmlformats.org/presentationml/2006/main' a = 'http://schemas.openxmlformats.org/drawingml/2006/main' r = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' adec = 'http://schemas.microsoft.com/office/drawing/2017/decorative' } # Generic phrases that fail NonDescriptiveLinkText. Compared case-insensitively # against the run's visible text after trimming whitespace and trailing # punctuation. $NonDescriptiveLinkPhrases = @( 'click here', 'click', 'here', 'more', 'read more', 'link', 'this link', 'this' ) #-------------------------------------------------------------------- # Pre-flight: file existence + extension #-------------------------------------------------------------------- if (-not (Test-Path -LiteralPath $FilePath)) { [Console]::Error.WriteLine("File not found: $FilePath") exit 2 } $ext = [IO.Path]::GetExtension($FilePath).ToLowerInvariant() if ($ext -ne '.pptx' -and $ext -ne '.pptm') { [Console]::Error.WriteLine("Unsupported file type for PowerPoint checker: $ext (expected .pptx or .pptm)") exit 2 } $FilePath = (Resolve-Path -LiteralPath $FilePath).ProviderPath #-------------------------------------------------------------------- # Locate and load Open XML SDK #-------------------------------------------------------------------- $sdkCandidates = @() if ($env:OPENXML_SDK_PATH) { $sdkCandidates += $env:OPENXML_SDK_PATH } $sdkCandidates += (Join-Path $PSScriptRoot 'lib\DocumentFormat.OpenXml.dll') $sdkPath = $null foreach ($candidate in $sdkCandidates) { if ($candidate -and (Test-Path -LiteralPath $candidate)) { $sdkPath = $candidate break } } if (-not $sdkPath) { [Console]::Error.WriteLine("Open XML SDK not found. Run scripts/setup-accessibility-checker.ps1 first.") exit 2 } try { Add-Type -Path $sdkPath } catch { [Console]::Error.WriteLine("Failed to load Open XML SDK from '$sdkPath': $($_.Exception.Message)") exit 2 } #-------------------------------------------------------------------- # Helpers #-------------------------------------------------------------------- function New-Issue { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'New-Issue is a pure record factory.' )] param( [Parameter(Mandatory)][string] $Severity, [Parameter(Mandatory)][string] $RuleName, [Parameter(Mandatory)][string] $Description ) [pscustomobject]@{ Severity = $Severity RuleName = $RuleName Description = $Description } } function Get-PartXDocument { param($Part) if ($null -eq $Part) { return $null } try { $stream = $Part.GetStream([IO.FileMode]::Open, [IO.FileAccess]::Read) try { return [System.Xml.Linq.XDocument]::Load($stream) } finally { $stream.Dispose() } } catch { return $null } } function Get-XAttr { param([System.Xml.Linq.XElement] $Element, [string] $Namespace, [string] $LocalName) if ($null -eq $Element) { return $null } $attr = $Element.Attribute([System.Xml.Linq.XName]::Get($LocalName, $Namespace)) if ($attr) { return $attr.Value } $attr = $Element.Attribute([System.Xml.Linq.XName]::Get($LocalName)) if ($attr) { return $attr.Value } return $null } function Get-ChildElement { param( [System.Xml.Linq.XElement] $Parent, [string] $Namespace, [string] $LocalName, [switch] $All ) if ($All) { if ($null -eq $Parent) { return @() } return ,@($Parent.Elements([System.Xml.Linq.XName]::Get($LocalName, $Namespace))) } if ($null -eq $Parent) { return $null } return $Parent.Element([System.Xml.Linq.XName]::Get($LocalName, $Namespace)) } function Get-Descendant { param([System.Xml.Linq.XContainer] $Root, [string] $Namespace, [string] $LocalName) if ($null -eq $Root) { return @() } return $Root.Descendants([System.Xml.Linq.XName]::Get($LocalName, $Namespace)) } #-------------------------------------------------------------------- # Slide ordering: walk presentation.SlideIdList, resolve each entry to a # SlidePart (skip notes/handouts that share the relationship space). #-------------------------------------------------------------------- function Get-OrderedSlideList { param($PresentationPart) $result = New-Object 'System.Collections.Generic.List[object]' $presDoc = Get-PartXDocument -Part $PresentationPart if ($null -eq $presDoc -or $null -eq $presDoc.Root) { return $result } $sldIdLst = Get-ChildElement -Parent $presDoc.Root -Namespace $NS.p -LocalName 'sldIdLst' if ($null -eq $sldIdLst) { return $result } $i = 0 foreach ($sldId in (Get-ChildElement -Parent $sldIdLst -Namespace $NS.p -LocalName 'sldId' -All)) { $i++ $rid = Get-XAttr -Element $sldId -Namespace $NS.r -LocalName 'id' if ([string]::IsNullOrEmpty($rid)) { continue } $part = $null try { $part = $PresentationPart.GetPartById($rid) } catch { Write-Verbose "GetPartById failed for $rid" } if ($part -is [DocumentFormat.OpenXml.Packaging.SlidePart]) { $result.Add([pscustomobject]@{ Number = $i SlidePart = $part }) | Out-Null } } return $result } #-------------------------------------------------------------------- # Common shape-walking helpers #-------------------------------------------------------------------- # Concatenate every a:t descendant of an element. Used for placeholder text, # table cell text, and run text alike. function Get-XmlText { param([System.Xml.Linq.XElement] $Element) if ($null -eq $Element) { return '' } $sb = New-Object System.Text.StringBuilder foreach ($t in (Get-Descendant -Root $Element -Namespace $NS.a -LocalName 't')) { [void] $sb.Append($t.Value) } return $sb.ToString() } # Walk a spTree, yielding every "leaf" shape we want to evaluate. Group shapes # (p:grpSp) are recursed into; their containers themselves are not yielded. # Returns an array of XElement. function Get-LeafShape { param([System.Xml.Linq.XElement] $SpTree) if ($null -eq $SpTree) { return @() } $shapes = New-Object 'System.Collections.Generic.List[object]' foreach ($child in $SpTree.Elements()) { if ($child.Name.NamespaceName -ne $NS.p) { continue } switch ($child.Name.LocalName) { 'sp' { $shapes.Add($child) | Out-Null } 'pic' { $shapes.Add($child) | Out-Null } 'graphicFrame' { $shapes.Add($child) | Out-Null } 'cxnSp' { $shapes.Add($child) | Out-Null } 'grpSp' { foreach ($nested in (Get-LeafShape -SpTree $child)) { $shapes.Add($nested) | Out-Null } } } } return $shapes } # Locate the cNvPr inside a shape's non-visual properties container. Returns # $null if the container is missing. function Get-ShapeCNvPr { param([System.Xml.Linq.XElement] $Shape) if ($null -eq $Shape) { return $null } $local = $Shape.Name.LocalName $nvName = switch ($local) { 'sp' { 'nvSpPr' } 'pic' { 'nvPicPr' } 'graphicFrame' { 'nvGraphicFramePr' } 'cxnSp' { 'nvCxnSpPr' } default { $null } } if (-not $nvName) { return $null } $nv = Get-ChildElement -Parent $Shape -Namespace $NS.p -LocalName $nvName if ($null -eq $nv) { return $null } return Get-ChildElement -Parent $nv -Namespace $NS.p -LocalName 'cNvPr' } #-------------------------------------------------------------------- # Rule: MissingAltText #-------------------------------------------------------------------- # Pass if any of: # - @descr non-empty # - @title non-empty # - @decorative in {1,true} (older direct attribute) # - <a:extLst><a:ext><adec:decorative val="1"/> (Office 2019/365 modern marker) function Test-PptxAltTextAttribute { param([System.Xml.Linq.XElement] $CNvPr) if ($null -eq $CNvPr) { return $false } if (-not [string]::IsNullOrWhiteSpace((Get-XAttr -Element $CNvPr -Namespace $null -LocalName 'descr'))) { return $true } if (-not [string]::IsNullOrWhiteSpace((Get-XAttr -Element $CNvPr -Namespace $null -LocalName 'title'))) { return $true } $decorative = Get-XAttr -Element $CNvPr -Namespace $null -LocalName 'decorative' if ($decorative -eq '1' -or $decorative -eq 'true') { return $true } return (Test-DecorativeExtension -CNvPr $CNvPr) } # Office 2019+ stores the "Mark as decorative" flag in an extension list: # <a:extLst> # <a:ext uri="{C183D7F6-B498-43B3-948B-1728B52AA6E4}"> # <adec:decorative xmlns:adec="..." val="1"/> # </a:ext> # </a:extLst> # Walk it and treat val in {1,true} as decorative. function Test-DecorativeExtension { param([System.Xml.Linq.XElement] $CNvPr) if ($null -eq $CNvPr) { return $false } foreach ($dec in (Get-Descendant -Root $CNvPr -Namespace $NS.adec -LocalName 'decorative')) { $val = Get-XAttr -Element $dec -Namespace $null -LocalName 'val' if ($val -eq '1' -or $val -eq 'true') { return $true } } return $false } # Decide whether a shape requires alt text. PowerPoint's checker exempts: # - placeholder shapes (title/body etc.) whose visual content is inherited # from the layout/master and whose role is to hold text; # - graphicFrames containing a table — tables are text content, screen # readers consume the cells directly, so a redundant description is not # required. # Pictures, charts/SmartArt graphicFrames, group children (already recursed), # connectors, and non-placeholder p:sp shapes all require alt text. function Test-ShapeNeedsAltText { param([System.Xml.Linq.XElement] $Shape) if ($null -eq $Shape) { return $false } $local = $Shape.Name.LocalName if ($local -eq 'sp') { $nvSpPr = Get-ChildElement -Parent $Shape -Namespace $NS.p -LocalName 'nvSpPr' $nvPr = Get-ChildElement -Parent $nvSpPr -Namespace $NS.p -LocalName 'nvPr' $ph = Get-ChildElement -Parent $nvPr -Namespace $NS.p -LocalName 'ph' if ($ph) { return $false } return $true } if ($local -eq 'graphicFrame') { # Tables are accessible content; charts and SmartArt are not. $tbl = Get-Descendant -Root $Shape -Namespace $NS.a -LocalName 'tbl' | Select-Object -First 1 if ($tbl) { return $false } return $true } return $true # pic, cxnSp } function Test-PptxAltText { param( [System.Xml.Linq.XElement] $SpTree, [int] $SlideNumber, [System.Collections.Generic.List[object]] $Issues ) if ($null -eq $SpTree) { return } foreach ($shape in (Get-LeafShape -SpTree $SpTree)) { if (-not (Test-ShapeNeedsAltText $shape)) { continue } $cNvPr = Get-ShapeCNvPr -Shape $shape if (-not (Test-PptxAltTextAttribute $cNvPr)) { $name = if ($cNvPr) { Get-XAttr -Element $cNvPr -Namespace $null -LocalName 'name' } else { $null } if ([string]::IsNullOrWhiteSpace($name)) { $name = '(unnamed)' } [void] $Issues.Add((New-Issue -Severity 'ERROR' -RuleName 'MissingAltText' ` -Description "Image/object `"$name`" on slide $SlideNumber has no alt text")) } } } #-------------------------------------------------------------------- # Rule: MissingSlideTitle #-------------------------------------------------------------------- # Returns the slide's displayed title text, or $null if neither the slide nor # its layout defines a title placeholder. # # Resolution order: # 1. Walk the slide spTree for a p:sp with p:ph type ∈ {title, ctrTitle}. # If found, return its txBody text (may be the empty string if the slide # author left the placeholder empty). # 2. If the slide has no title-typed placeholder, consult the slide's layout # (slidePart.SlideLayoutPart). When the layout defines a title placeholder # the slide *inherits* it; its rendered text is whatever the slide's # matching <p:ph> shape contains, or empty if the slide doesn't override # the placeholder. Either way the slide should have a title — we return # the empty string to surface MissingSlideTitle as "empty title". # 3. If neither slide nor layout defines a title-typed placeholder, return # $null and the caller reports "no title placeholder". function Get-SlideTitleText { param([System.Xml.Linq.XElement] $SpTree, $SlidePart) if ($null -eq $SpTree) { return $null } foreach ($shape in (Get-LeafShape -SpTree $SpTree)) { if ($shape.Name.LocalName -ne 'sp') { continue } $nvSpPr = Get-ChildElement -Parent $shape -Namespace $NS.p -LocalName 'nvSpPr' $nvPr = Get-ChildElement -Parent $nvSpPr -Namespace $NS.p -LocalName 'nvPr' $ph = Get-ChildElement -Parent $nvPr -Namespace $NS.p -LocalName 'ph' if ($null -eq $ph) { continue } $type = Get-XAttr -Element $ph -Namespace $null -LocalName 'type' if ($type -ne 'title' -and $type -ne 'ctrTitle') { continue } $txBody = Get-ChildElement -Parent $shape -Namespace $NS.p -LocalName 'txBody' return (Get-XmlText -Element $txBody) } # Layout fallback: check whether the slide's layout declares a title-typed # placeholder. If so, the slide inherits a title shape but didn't override # it — surface as an empty title rather than "no title placeholder". if ($null -ne $SlidePart -and $null -ne $SlidePart.SlideLayoutPart) { $layoutDoc = Get-PartXDocument -Part $SlidePart.SlideLayoutPart if ($null -ne $layoutDoc -and $null -ne $layoutDoc.Root) { $layoutCSld = Get-ChildElement -Parent $layoutDoc.Root -Namespace $NS.p -LocalName 'cSld' $layoutSpTree = if ($layoutCSld) { Get-ChildElement -Parent $layoutCSld -Namespace $NS.p -LocalName 'spTree' } else { $null } foreach ($lShape in (Get-LeafShape -SpTree $layoutSpTree)) { if ($lShape.Name.LocalName -ne 'sp') { continue } $lNvSp = Get-ChildElement -Parent $lShape -Namespace $NS.p -LocalName 'nvSpPr' $lNvPr = Get-ChildElement -Parent $lNvSp -Namespace $NS.p -LocalName 'nvPr' $lPh = Get-ChildElement -Parent $lNvPr -Namespace $NS.p -LocalName 'ph' if ($null -eq $lPh) { continue } $lType = Get-XAttr -Element $lPh -Namespace $null -LocalName 'type' if ($lType -eq 'title' -or $lType -eq 'ctrTitle') { return '' } } } } return $null } function Test-SlideTitle { param( [System.Xml.Linq.XElement] $SpTree, $SlidePart, [int] $SlideNumber, [System.Collections.Generic.List[object]] $Issues ) $title = Get-SlideTitleText -SpTree $SpTree -SlidePart $SlidePart if ($null -eq $title) { [void] $Issues.Add((New-Issue -Severity 'ERROR' -RuleName 'MissingSlideTitle' ` -Description "Slide $SlideNumber has no title placeholder")) return } if ([string]::IsNullOrWhiteSpace($title)) { [void] $Issues.Add((New-Issue -Severity 'ERROR' -RuleName 'MissingSlideTitle' ` -Description "Slide $SlideNumber has an empty title")) } } #-------------------------------------------------------------------- # Rule: MissingTableHeaders + MergedTableCells #-------------------------------------------------------------------- # Locate every a:tbl on the slide. Tables sit inside p:graphicFrame shapes # (a:graphic/a:graphicData/a:tbl), so descendants is the simplest walk. function Get-SlideTable { param([System.Xml.Linq.XElement] $SpTree) if ($null -eq $SpTree) { return @() } return Get-Descendant -Root $SpTree -Namespace $NS.a -LocalName 'tbl' } function Test-TableHeader { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Function evaluates the MissingTableHeaders rule.')] param( [System.Xml.Linq.XElement] $SpTree, [int] $SlideNumber, [System.Collections.Generic.List[object]] $Issues ) $tables = @(Get-SlideTable -SpTree $SpTree) if ($tables.Count -eq 0) { return } $idx = 0 foreach ($tbl in $tables) { $idx++ $tblPr = Get-ChildElement -Parent $tbl -Namespace $NS.a -LocalName 'tblPr' $firstRow = if ($tblPr) { Get-XAttr -Element $tblPr -Namespace $null -LocalName 'firstRow' } else { $null } if ($firstRow -ne '1' -and $firstRow -ne 'true') { [void] $Issues.Add((New-Issue -Severity 'ERROR' -RuleName 'MissingTableHeaders' ` -Description "Table $idx on slide $SlideNumber has no header row")) } } } function Test-TableMergedCell { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Function evaluates the MergedTableCells rule.')] param( [System.Xml.Linq.XElement] $SpTree, [int] $SlideNumber, [System.Collections.Generic.List[object]] $Issues ) $tables = @(Get-SlideTable -SpTree $SpTree) if ($tables.Count -eq 0) { return } $idx = 0 foreach ($tbl in $tables) { $idx++ $hasMerge = $false foreach ($tc in (Get-Descendant -Root $tbl -Namespace $NS.a -LocalName 'tc')) { $gridSpan = Get-XAttr -Element $tc -Namespace $null -LocalName 'gridSpan' $rowSpan = Get-XAttr -Element $tc -Namespace $null -LocalName 'rowSpan' $hMerge = Get-XAttr -Element $tc -Namespace $null -LocalName 'hMerge' $vMerge = Get-XAttr -Element $tc -Namespace $null -LocalName 'vMerge' $gsInt = 0 $rsInt = 0 if ([int]::TryParse($gridSpan, [ref] $gsInt) -and $gsInt -gt 1) { $hasMerge = $true; break } if ([int]::TryParse($rowSpan, [ref] $rsInt) -and $rsInt -gt 1) { $hasMerge = $true; break } if ($hMerge -eq '1' -or $hMerge -eq 'true') { $hasMerge = $true; break } if ($vMerge -eq '1' -or $vMerge -eq 'true') { $hasMerge = $true; break } } if ($hasMerge) { [void] $Issues.Add((New-Issue -Severity 'WARNING' -RuleName 'MergedTableCells' ` -Description "Table $idx on slide $SlideNumber contains merged cells")) } } } #-------------------------------------------------------------------- # Rule: NonDescriptiveLinkText #-------------------------------------------------------------------- # Strip leading/trailing whitespace and trailing punctuation so "Click here." # matches the same blacklist as "click here". function Get-NormalizedLinkText { param([string] $Text) if ($null -eq $Text) { return '' } $stripped = $Text.Trim() $stripped = $stripped -replace '[.!?,:;\s]+$', '' return $stripped.ToLowerInvariant() } function Test-LinkText { param( $SlidePart, [System.Xml.Linq.XElement] $SpTree, [int] $SlideNumber, [System.Collections.Generic.List[object]] $Issues ) if ($null -eq $SpTree) { return } # Build relationship-id -> URL map up front; iterating the runs more than # once and looking up by Id each time avoids round-tripping the SDK # collection per-run. $relMap = @{} if ($SlidePart -and $SlidePart.HyperlinkRelationships) { foreach ($rel in $SlidePart.HyperlinkRelationships) { $relMap[$rel.Id] = $rel.Uri.ToString() } } foreach ($run in (Get-Descendant -Root $SpTree -Namespace $NS.a -LocalName 'r')) { $rPr = Get-ChildElement -Parent $run -Namespace $NS.a -LocalName 'rPr' if ($null -eq $rPr) { continue } $hlink = Get-ChildElement -Parent $rPr -Namespace $NS.a -LocalName 'hlinkClick' if ($null -eq $hlink) { continue } $rid = Get-XAttr -Element $hlink -Namespace $NS.r -LocalName 'id' $url = $null if ($rid -and $relMap.ContainsKey($rid)) { $url = $relMap[$rid] } $tEl = Get-ChildElement -Parent $run -Namespace $NS.a -LocalName 't' $rawText = if ($tEl) { $tEl.Value } else { '' } $normalized = Get-NormalizedLinkText -Text $rawText $bad = $false if ([string]::IsNullOrWhiteSpace($rawText)) { $bad = $true } elseif ($url -and ($rawText.Trim().ToLowerInvariant() -eq $url.ToLowerInvariant())) { $bad = $true } else { foreach ($phrase in $NonDescriptiveLinkPhrases) { if ($normalized -eq $phrase) { $bad = $true; break } } } if ($bad) { $preview = if ([string]::IsNullOrEmpty($rawText)) { '(empty)' } elseif ($rawText.Length -gt 30) { $rawText.Substring(0, 30) + '...' } else { $rawText } [void] $Issues.Add((New-Issue -Severity 'WARNING' -RuleName 'NonDescriptiveLinkText' ` -Description "Hyperlink text `"$preview`" on slide $SlideNumber is not descriptive")) } } } #-------------------------------------------------------------------- # Rule: LowContrast #-------------------------------------------------------------------- function Get-RelativeLuminance { param([Parameter(Mandatory)] [string] $Hex) $r = [Convert]::ToInt32($Hex.Substring(0, 2), 16) / 255.0 $g = [Convert]::ToInt32($Hex.Substring(2, 2), 16) / 255.0 $b = [Convert]::ToInt32($Hex.Substring(4, 2), 16) / 255.0 $rL = if ($r -le 0.03928) { $r / 12.92 } else { [Math]::Pow(($r + 0.055) / 1.055, 2.4) } $gL = if ($g -le 0.03928) { $g / 12.92 } else { [Math]::Pow(($g + 0.055) / 1.055, 2.4) } $bL = if ($b -le 0.03928) { $b / 12.92 } else { [Math]::Pow(($b + 0.055) / 1.055, 2.4) } return 0.2126 * $rL + 0.7152 * $gL + 0.0722 * $bL } function Get-ContrastRatio { param( [Parameter(Mandatory)] [string] $ForegroundHex, [Parameter(Mandatory)] [string] $BackgroundHex ) $fgL = Get-RelativeLuminance -Hex $ForegroundHex $bgL = Get-RelativeLuminance -Hex $BackgroundHex $lighter = [Math]::Max($fgL, $bgL) $darker = [Math]::Min($fgL, $bgL) return ($lighter + 0.05) / ($darker + 0.05) } # Resolve a fillable element's explicit srgb value, or $null. Skips # scheme/system colors and non-solid fill types. function Get-ExplicitSolidFillHex { param([System.Xml.Linq.XElement] $Container) if ($null -eq $Container) { return $null } $solidFill = Get-ChildElement -Parent $Container -Namespace $NS.a -LocalName 'solidFill' if ($null -eq $solidFill) { return $null } $srgb = Get-ChildElement -Parent $solidFill -Namespace $NS.a -LocalName 'srgbClr' if ($null -eq $srgb) { return $null } $val = Get-XAttr -Element $srgb -Namespace $null -LocalName 'val' if ($val -and $val -match '^[0-9A-Fa-f]{6}$') { return $val } return $null } # Slide-background fill: p:cSld/p:bg/p:bgPr/a:solidFill/a:srgbClr. function Get-SlideBackgroundHex { param([System.Xml.Linq.XElement] $CSld) if ($null -eq $CSld) { return $null } $bg = Get-ChildElement -Parent $CSld -Namespace $NS.p -LocalName 'bg' if ($null -eq $bg) { return $null } $bgPr = Get-ChildElement -Parent $bg -Namespace $NS.p -LocalName 'bgPr' return Get-ExplicitSolidFillHex -Container $bgPr } function Test-LowContrast { param( [System.Xml.Linq.XElement] $CSld, [System.Xml.Linq.XElement] $SpTree, [int] $SlideNumber, [System.Collections.Generic.List[object]] $Issues ) if ($null -eq $SpTree) { return } $slideBg = Get-SlideBackgroundHex -CSld $CSld foreach ($shape in (Get-LeafShape -SpTree $SpTree)) { if ($shape.Name.LocalName -ne 'sp') { continue } $spPr = Get-ChildElement -Parent $shape -Namespace $NS.p -LocalName 'spPr' $shapeFill = Get-ExplicitSolidFillHex -Container $spPr $bg = if ($shapeFill) { $shapeFill } else { $slideBg } if (-not $bg) { continue } $txBody = Get-ChildElement -Parent $shape -Namespace $NS.p -LocalName 'txBody' if ($null -eq $txBody) { continue } foreach ($run in (Get-Descendant -Root $txBody -Namespace $NS.a -LocalName 'r')) { $rPr = Get-ChildElement -Parent $run -Namespace $NS.a -LocalName 'rPr' $fg = Get-ExplicitSolidFillHex -Container $rPr if (-not $fg) { continue } $ratio = Get-ContrastRatio -ForegroundHex $fg -BackgroundHex $bg if ($ratio -lt 4.5) { $tEl = Get-ChildElement -Parent $run -Namespace $NS.a -LocalName 't' $preview = if ($tEl) { $tEl.Value } else { '(empty run)' } if ([string]::IsNullOrEmpty($preview)) { $preview = '(empty run)' } elseif ($preview.Length -gt 30) { $preview = $preview.Substring(0, 30) + '...' } [void] $Issues.Add((New-Issue -Severity 'WARNING' -RuleName 'LowContrast' ` -Description ("Text `"{0}`" on slide {1} has contrast {2:N2}:1 (#{3} on #{4}); WCAG requires 4.5:1" -f $preview, $SlideNumber, $ratio, $fg.ToUpper(), $bg.ToUpper()))) } } } } #-------------------------------------------------------------------- # Autofix helpers #-------------------------------------------------------------------- function ConvertTo-Hsl { param([Parameter(Mandatory)] [string] $Hex) $r = [Convert]::ToInt32($Hex.Substring(0, 2), 16) / 255.0 $g = [Convert]::ToInt32($Hex.Substring(2, 2), 16) / 255.0 $b = [Convert]::ToInt32($Hex.Substring(4, 2), 16) / 255.0 $max = [Math]::Max([Math]::Max($r, $g), $b) $min = [Math]::Min([Math]::Min($r, $g), $b) $L = ($max + $min) / 2.0 if ($max -eq $min) { return [pscustomobject]@{ H = 0.0; S = 0.0; L = $L } } $d = $max - $min $S = if ($L -gt 0.5) { $d / (2.0 - $max - $min) } else { $d / ($max + $min) } $H = 0.0 if ($max -eq $r) { $H = (($g - $b) / $d) + ($(if ($g -lt $b) { 6.0 } else { 0.0 })) } elseif ($max -eq $g) { $H = (($b - $r) / $d) + 2.0 } else { $H = (($r - $g) / $d) + 4.0 } return [pscustomobject]@{ H = ($H / 6.0); S = $S; L = $L } } function Get-HueChannel { param([double] $p, [double] $q, [double] $t) if ($t -lt 0) { $t += 1 } if ($t -gt 1) { $t -= 1 } if ($t -lt (1.0 / 6.0)) { return $p + ($q - $p) * 6.0 * $t } if ($t -lt 0.5) { return $q } if ($t -lt (2.0 / 3.0)) { return $p + ($q - $p) * ((2.0 / 3.0) - $t) * 6.0 } return $p } function ConvertFrom-Hsl { param([Parameter(Mandatory)] [double] $H, [Parameter(Mandatory)] [double] $S, [Parameter(Mandatory)] [double] $L) if ($S -eq 0) { $r = $L; $g = $L; $b = $L } else { $q = if ($L -lt 0.5) { $L * (1.0 + $S) } else { $L + $S - ($L * $S) } $p = (2.0 * $L) - $q $r = Get-HueChannel $p $q ($H + (1.0 / 3.0)) $g = Get-HueChannel $p $q $H $b = Get-HueChannel $p $q ($H - (1.0 / 3.0)) } $rByte = [int][Math]::Round($r * 255) $gByte = [int][Math]::Round($g * 255) $bByte = [int][Math]::Round($b * 255) return ('{0:X2}{1:X2}{2:X2}' -f $rByte, $gByte, $bByte) } function Get-NearestPassingForeground { param( [Parameter(Mandatory)] [string] $ForegroundHex, [Parameter(Mandatory)] [string] $BackgroundHex, [double] $Threshold = 4.5 ) if ((Get-ContrastRatio -ForegroundHex $ForegroundHex -BackgroundHex $BackgroundHex) -ge $Threshold) { return $ForegroundHex } $hsl = ConvertTo-Hsl -Hex $ForegroundHex $step = 0.005 $bestDark = $null; $deltaDark = [double]::PositiveInfinity for ($L = $hsl.L - $step; $L -ge 0; $L -= $step) { $cand = ConvertFrom-Hsl -H $hsl.H -S $hsl.S -L $L if ((Get-ContrastRatio -ForegroundHex $cand -BackgroundHex $BackgroundHex) -ge $Threshold) { $bestDark = $cand; $deltaDark = $hsl.L - $L; break } } $bestLight = $null; $deltaLight = [double]::PositiveInfinity for ($L = $hsl.L + $step; $L -le 1; $L += $step) { $cand = ConvertFrom-Hsl -H $hsl.H -S $hsl.S -L $L if ((Get-ContrastRatio -ForegroundHex $cand -BackgroundHex $BackgroundHex) -ge $Threshold) { $bestLight = $cand; $deltaLight = $L - $hsl.L; break } } if ($bestDark -and $deltaDark -le $deltaLight) { return $bestDark } if ($bestLight) { return $bestLight } $blackR = Get-ContrastRatio -ForegroundHex '000000' -BackgroundHex $BackgroundHex $whiteR = Get-ContrastRatio -ForegroundHex 'FFFFFF' -BackgroundHex $BackgroundHex if ($blackR -ge $whiteR) { return '000000' } else { return 'FFFFFF' } } # Set a:tblPr/@firstRow="1" on every a:tbl missing it. Counts the tables # fixed across the slide. function Repair-PptxTableHeader { param([System.Xml.Linq.XElement] $SpTree) if ($null -eq $SpTree) { return 0 } $fixed = 0 foreach ($tbl in (Get-Descendant -Root $SpTree -Namespace $NS.a -LocalName 'tbl')) { $tblPr = Get-ChildElement -Parent $tbl -Namespace $NS.a -LocalName 'tblPr' if (-not $tblPr) { # a:tblPr must precede a:tblGrid per the schema. $tblPr = New-Object System.Xml.Linq.XElement -ArgumentList ([System.Xml.Linq.XName]::Get('tblPr', $NS.a)) $tbl.AddFirst($tblPr) } $firstRow = Get-XAttr -Element $tblPr -Namespace $null -LocalName 'firstRow' if ($firstRow -eq '1' -or $firstRow -eq 'true') { continue } $tblPr.SetAttributeValue([System.Xml.Linq.XName]::Get('firstRow'), '1') $fixed++ } return $fixed } # For each leaf shape in the slide, walk its runs and rewrite the foreground # color of any run whose contrast against the resolved background falls below # 4.5:1. Background resolution mirrors the rule: shape solid fill, falling # back to slide background. Returns count of runs rewritten. function Repair-PptxLowContrast { param( [System.Xml.Linq.XElement] $CSld, [System.Xml.Linq.XElement] $SpTree ) if ($null -eq $SpTree) { return 0 } $fixed = 0 $slideBg = Get-SlideBackgroundHex -CSld $CSld foreach ($shape in (Get-LeafShape -SpTree $SpTree)) { if ($shape.Name.LocalName -ne 'sp') { continue } $spPr = Get-ChildElement -Parent $shape -Namespace $NS.p -LocalName 'spPr' $shapeFill = Get-ExplicitSolidFillHex -Container $spPr $bg = if ($shapeFill) { $shapeFill } else { $slideBg } if (-not $bg) { continue } $txBody = Get-ChildElement -Parent $shape -Namespace $NS.p -LocalName 'txBody' if ($null -eq $txBody) { continue } foreach ($run in (Get-Descendant -Root $txBody -Namespace $NS.a -LocalName 'r')) { $rPr = Get-ChildElement -Parent $run -Namespace $NS.a -LocalName 'rPr' if ($null -eq $rPr) { continue } $solidFill = Get-ChildElement -Parent $rPr -Namespace $NS.a -LocalName 'solidFill' if ($null -eq $solidFill) { continue } $srgb = Get-ChildElement -Parent $solidFill -Namespace $NS.a -LocalName 'srgbClr' if ($null -eq $srgb) { continue } $fgVal = Get-XAttr -Element $srgb -Namespace $null -LocalName 'val' if (-not $fgVal -or $fgVal -notmatch '^[0-9A-Fa-f]{6}$') { continue } if ((Get-ContrastRatio -ForegroundHex $fgVal -BackgroundHex $bg) -ge 4.5) { continue } $newFg = Get-NearestPassingForeground -ForegroundHex $fgVal -BackgroundHex $bg -Threshold 4.5 if (-not $newFg -or $newFg -eq $fgVal.ToUpper()) { continue } $srgb.SetAttributeValue([System.Xml.Linq.XName]::Get('val'), $newFg) $fixed++ } } return $fixed } function Save-PartXDocument { param($Part, [System.Xml.Linq.XDocument] $Document) $stream = $Part.GetStream([IO.FileMode]::Create, [IO.FileAccess]::Write) try { $Document.Save($stream) } finally { $stream.Dispose() } } #-------------------------------------------------------------------- # Main: open document and run all rules #-------------------------------------------------------------------- $issues = New-Object 'System.Collections.Generic.List[object]' $doc = $null try { try { $doc = [DocumentFormat.OpenXml.Packaging.PresentationDocument]::Open($FilePath, $false) } catch [System.IO.IOException] { [Console]::Error.WriteLine("Could not open file (is it locked by another process?): $($_.Exception.Message)") exit 2 } catch { # IRM/password failures surface as FileFormatException, InvalidDataException, # or a generic OpenXmlPackageException wrapping one of those. Sniff the chain. $protected = $false $cur = $_.Exception while ($cur) { if ($cur -is [System.IO.FileFormatException] -or $cur -is [System.IO.InvalidDataException]) { $protected = $true break } $cur = $cur.InnerException } if (-not $protected) { [Console]::Error.WriteLine("Failed to open presentation: $($_.Exception.Message)") exit 2 } [void] $issues.Add((New-Issue -Severity 'ERROR' -RuleName 'DocumentProtected' ` -Description 'Presentation is IRM- or password-protected')) $doc = $null } if ($null -ne $doc) { $presPart = $doc.PresentationPart if ($null -eq $presPart) { [Console]::Error.WriteLine("Presentation part missing — file may be corrupt.") exit 2 } $orderedSlides = Get-OrderedSlideList -PresentationPart $presPart $titleSeen = @{} foreach ($entry in $orderedSlides) { $slidePart = $entry.SlidePart $slideNum = $entry.Number $slideDoc = Get-PartXDocument -Part $slidePart if ($null -eq $slideDoc -or $null -eq $slideDoc.Root) { continue } $cSld = Get-ChildElement -Parent $slideDoc.Root -Namespace $NS.p -LocalName 'cSld' $spTree = if ($cSld) { Get-ChildElement -Parent $cSld -Namespace $NS.p -LocalName 'spTree' } else { $null } Test-PptxAltText -SpTree $spTree -SlideNumber $slideNum -Issues $issues Test-SlideTitle -SpTree $spTree -SlidePart $slidePart -SlideNumber $slideNum -Issues $issues Test-TableHeader -SpTree $spTree -SlideNumber $slideNum -Issues $issues Test-TableMergedCell -SpTree $spTree -SlideNumber $slideNum -Issues $issues Test-LinkText -SlidePart $slidePart -SpTree $spTree -SlideNumber $slideNum -Issues $issues Test-LowContrast -CSld $cSld -SpTree $spTree -SlideNumber $slideNum -Issues $issues # Track titles for DuplicateSlideTitle (skip empty/missing titles — # those already fired MissingSlideTitle). $titleText = Get-SlideTitleText -SpTree $spTree -SlidePart $slidePart if (-not [string]::IsNullOrWhiteSpace($titleText)) { $key = $titleText.Trim().ToLowerInvariant() if (-not $titleSeen.ContainsKey($key)) { $titleSeen[$key] = @() } $titleSeen[$key] += $slideNum } } foreach ($key in $titleSeen.Keys) { $slidesWithTitle = $titleSeen[$key] if ($slidesWithTitle.Count -gt 1) { $list = ($slidesWithTitle | Sort-Object) -join ', ' [void] $issues.Add((New-Issue -Severity 'WARNING' -RuleName 'DuplicateSlideTitle' ` -Description "Slides $list share the title `"$key`"")) } } } } finally { if ($null -ne $doc) { try { $doc.Dispose() } catch { Write-Verbose "Dispose failed: $($_.Exception.Message)" } } } #-------------------------------------------------------------------- # Output + exit #-------------------------------------------------------------------- $errorIssues = @($issues | Where-Object { $_.Severity -eq 'ERROR' }) $errorCount = $errorIssues.Count $exitCode = if ($errorCount -gt 0) { 1 } else { 0 } if ($errorCount -gt 0) { $errorRules = ($errorIssues | Select-Object -ExpandProperty RuleName -Unique) -join ', ' $summary = "FAIL $FilePath`: $errorRules" } else { $summary = "PASS $FilePath" } if ($Format -eq 'detailed') { $severityRank = @{ 'ERROR' = 0; 'WARNING' = 1; 'TIP' = 2 } $sorted = $issues | Sort-Object ` @{ Expression = { $severityRank[$_.Severity] } }, ` @{ Expression = { $_.RuleName } } foreach ($issue in $sorted) { Write-Output ("{0}`t{1}`t{2}" -f $issue.Severity, $issue.RuleName, $issue.Description) } Write-Output $summary } else { Write-Output $summary } #-------------------------------------------------------------------- # Autofix #-------------------------------------------------------------------- if (-not $Fix) { exit $exitCode } $fixableRuleNames = @('MissingTableHeaders', 'LowContrast') $hasFixable = $false foreach ($i in $issues) { if ($fixableRuleNames -contains $i.RuleName) { $hasFixable = $true; break } } if (-not $hasFixable) { exit $exitCode } $fixedPath = [IO.Path]::ChangeExtension($FilePath, $null).TrimEnd('.') + '.fixed' + $ext try { Copy-Item -LiteralPath $FilePath -Destination $fixedPath -Force } catch { [Console]::Error.WriteLine("Failed to create fixed copy at '$fixedPath': $($_.Exception.Message)") exit 2 } $fixCounts = @{ MissingTableHeaders = 0; LowContrast = 0 } $fixDoc = $null try { try { $fixDoc = [DocumentFormat.OpenXml.Packaging.PresentationDocument]::Open($fixedPath, $true) } catch { [Console]::Error.WriteLine("Failed to open fixed copy '$fixedPath' for editing: $($_.Exception.Message)") exit 2 } $presPartFix = $fixDoc.PresentationPart if ($null -ne $presPartFix) { foreach ($entry in (Get-OrderedSlideList -PresentationPart $presPartFix)) { $slidePart = $entry.SlidePart $slideDoc = Get-PartXDocument -Part $slidePart if ($null -eq $slideDoc -or $null -eq $slideDoc.Root) { continue } $cSld = Get-ChildElement -Parent $slideDoc.Root -Namespace $NS.p -LocalName 'cSld' $spTree = if ($cSld) { Get-ChildElement -Parent $cSld -Namespace $NS.p -LocalName 'spTree' } else { $null } if ($null -eq $spTree) { continue } $tCount = Repair-PptxTableHeader -SpTree $spTree $cCount = Repair-PptxLowContrast -CSld $cSld -SpTree $spTree $fixCounts.MissingTableHeaders += $tCount $fixCounts.LowContrast += $cCount if (($tCount + $cCount) -gt 0) { Save-PartXDocument -Part $slidePart -Document $slideDoc } } } } finally { if ($null -ne $fixDoc) { try { $fixDoc.Dispose() } catch { Write-Verbose "Dispose failed: $($_.Exception.Message)" } } } $fixSummaryParts = @() foreach ($k in $fixableRuleNames) { if ($fixCounts[$k] -gt 0) { $fixSummaryParts += ('{0} ({1})' -f $k, $fixCounts[$k]) } } if ($fixSummaryParts.Count -eq 0) { Remove-Item -LiteralPath $fixedPath -Force -ErrorAction SilentlyContinue exit $exitCode } Write-Output ("FIXED {0}: {1}" -f $fixedPath, ($fixSummaryParts -join ', ')) & $PSCommandPath -FilePath $fixedPath -Format $Format exit $LASTEXITCODE |