Private/check-docx-accessibility.ps1
|
<# .SYNOPSIS Runs Microsoft Word-style accessibility rules against a .docx/.docm file using the Open XML SDK. .DESCRIPTION Inspects the document body, every header part, and every footer part for common accessibility issues (missing alt text, missing table headers, document protection, missing content control titles, merged cells, heading-order skips, floating objects, repeated blank characters, lack of headings, low contrast). Emits a single PASS/FAIL line in 'text' mode, or a sorted list of issues followed by the PASS/FAIL line in 'detailed' mode. .PARAMETER FilePath Path to a .docx or .docm file. .PARAMETER Format 'text' (default) emits only PASS/FAIL. 'detailed' emits one tab-separated issue per line plus the PASS/FAIL summary. .PARAMETER Fix When set, after running the checks the script writes a sibling file named <basename>.fixed.docx with deterministic structural remediations applied for the rules MissingTableHeaders, RepeatedBlanks, and LowContrast. The original file is never modified. After fixing, the checker is re-run on the fixed file and its PASS/FAIL is appended. .OUTPUTS Exit codes: 0 no errors found (warnings/tips do not fail) 1 one or more accessibility errors found (includes IRM/password-protected files) 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 = @{ w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' wp = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing' pic = 'http://schemas.openxmlformats.org/drawingml/2006/picture' wps = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape' wpg = 'http://schemas.microsoft.com/office/word/2010/wordprocessingGroup' a = 'http://schemas.openxmlformats.org/drawingml/2006/main' } # --- Issue record helper ------------------------------------------------------ function New-Issue { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'New-Issue is a pure record factory — it constructs a [pscustomobject] and has no side effects. The verb New triggers this rule, but the function does not change any system state.' )] param( [ValidateSet('ERROR','WARNING','TIP')] [string] $Severity, [string] $RuleName, [string] $Description ) [pscustomobject]@{ Severity = $Severity RuleName = $RuleName Description = $Description } } # --- Pre-flight: file existence and 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 '.docx' -and $ext -ne '.docm') { [Console]::Error.WriteLine("Unsupported format: expected .docx or .docm, got $ext") exit 2 } # --- Locate and load the Open XML SDK ---------------------------------------- $sdkPath = $null if ($env:OPENXML_SDK_PATH -and (Test-Path -LiteralPath $env:OPENXML_SDK_PATH)) { $sdkPath = $env:OPENXML_SDK_PATH } else { $candidate = Join-Path $PSScriptRoot 'lib\DocumentFormat.OpenXml.dll' if (Test-Path -LiteralPath $candidate) { $sdkPath = $candidate } } 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 -ErrorAction Stop } catch { [Console]::Error.WriteLine("Failed to load Open XML SDK from '$sdkPath': $($_.Exception.Message)") exit 2 } # --- XML helpers -------------------------------------------------------------- function Get-PartXDocument { param($Part) if (-not $Part) { return $null } $stream = $Part.GetStream([IO.FileMode]::Open, [IO.FileAccess]::Read) try { $reader = [Xml.XmlReader]::Create($stream) try { return [Xml.Linq.XDocument]::Load($reader) } finally { $reader.Dispose() } } finally { $stream.Dispose() } } function Get-XAttr { param([Xml.Linq.XElement] $Element, [string] $Namespace, [string] $LocalName) if (-not $Element) { return $null } $attr = $Element.Attribute([Xml.Linq.XName]::Get($LocalName, $Namespace)) if ($attr) { return $attr.Value } # Try no-namespace fallback (some attributes are unqualified). $attr = $Element.Attribute([Xml.Linq.XName]::Get($LocalName)) if ($attr) { return $attr.Value } return $null } function Get-Descendant { param([Xml.Linq.XContainer] $Root, [string] $Namespace, [string] $LocalName) if (-not $Root) { return @() } return $Root.Descendants([Xml.Linq.XName]::Get($LocalName, $Namespace)) } function Get-ChildElement { param([Xml.Linq.XElement] $Parent, [string] $Namespace, [string] $LocalName) if (-not $Parent) { return $null } return $Parent.Element([Xml.Linq.XName]::Get($LocalName, $Namespace)) } # --- Rule: MissingAltText ----------------------------------------------------- # Walk every wp:anchor and wp:inline drawing wrapper. Pass if: # - wp:docPr/@descr non-empty, OR # - wp:docPr/@title non-empty, OR # - wp:docPr/@decorative="1" # Then recurse into wpg:wgp groups: every child shape's per-shape cNvPr # (pic:cNvPr / wps:cNvPr / a:graphicFrame//a:cNvPr / nested wpg) gets the # same check. function Test-AltText { param([Xml.Linq.XContainer] $Root, [ref] $Issues) if (-not $Root) { return } $wpName = [Xml.Linq.XName]::Get('anchor', $NS.wp) $inlineName = [Xml.Linq.XName]::Get('inline', $NS.wp) $wrappers = @() $wrappers += $Root.Descendants($wpName) $wrappers += $Root.Descendants($inlineName) foreach ($wrapper in $wrappers) { $docPr = Get-ChildElement -Parent $wrapper -Namespace $NS.wp -LocalName 'docPr' $name = if ($docPr) { Get-XAttr -Element $docPr -Namespace $null -LocalName 'name' } else { $null } if ([string]::IsNullOrEmpty($name)) { $name = '(unnamed)' } if (-not (Test-AltTextAttribute $docPr)) { $Issues.Value += New-Issue -Severity ERROR -RuleName 'MissingAltText' ` -Description "Image/object `"$name`" has no alt text" } # Recurse into group shapes. $groups = Get-Descendant -Root $wrapper -Namespace $NS.wpg -LocalName 'wgp' foreach ($grp in $groups) { Test-GroupAltText -Group $grp -Issues $Issues } } } # Pass if any of: @descr non-empty, @title non-empty, @decorative="1". # Applies uniformly to wp:docPr (drawing wrapper) and per-shape *:cNvPr. function Test-AltTextAttribute { param([Xml.Linq.XElement] $Element) if (-not $Element) { return $false } if (-not [string]::IsNullOrEmpty((Get-XAttr -Element $Element -Namespace $null -LocalName 'descr'))) { return $true } if (-not [string]::IsNullOrEmpty((Get-XAttr -Element $Element -Namespace $null -LocalName 'title'))) { return $true } if ((Get-XAttr -Element $Element -Namespace $null -LocalName 'decorative') -eq '1') { return $true } return $false } function Test-GroupAltText { param([Xml.Linq.XElement] $Group, [ref] $Issues) if (-not $Group) { return } # Direct child shapes of the group. foreach ($child in $Group.Elements()) { $ln = $child.Name.LocalName $nsName = $child.Name.NamespaceName switch ($ln) { 'pic' { if ($nsName -eq $NS.pic) { $nvPicPr = Get-ChildElement -Parent $child -Namespace $NS.pic -LocalName 'nvPicPr' $cNvPr = Get-ChildElement -Parent $nvPicPr -Namespace $NS.pic -LocalName 'cNvPr' $name = if ($cNvPr) { Get-XAttr -Element $cNvPr -Namespace $null -LocalName 'name' } else { '(unnamed)' } if ([string]::IsNullOrEmpty($name)) { $name = '(unnamed)' } if (-not (Test-AltTextAttribute $cNvPr)) { $Issues.Value += New-Issue -Severity ERROR -RuleName 'MissingAltText' ` -Description "Image/object `"$name`" has no alt text" } } } 'wsp' { if ($nsName -eq $NS.wps) { $nvSpPr = Get-ChildElement -Parent $child -Namespace $NS.wps -LocalName 'nvSpPr' $cNvPr = Get-ChildElement -Parent $nvSpPr -Namespace $NS.wps -LocalName 'cNvPr' $name = if ($cNvPr) { Get-XAttr -Element $cNvPr -Namespace $null -LocalName 'name' } else { '(unnamed)' } if ([string]::IsNullOrEmpty($name)) { $name = '(unnamed)' } if (-not (Test-AltTextAttribute $cNvPr)) { $Issues.Value += New-Issue -Severity ERROR -RuleName 'MissingAltText' ` -Description "Image/object `"$name`" has no alt text" } } } 'graphicFrame' { if ($nsName -eq $NS.a -or $nsName -eq $NS.wpg) { # Look for a cNvPr descendant in any namespace. $cNvPr = $child.Descendants() | Where-Object { $_.Name.LocalName -eq 'cNvPr' } | Select-Object -First 1 $name = if ($cNvPr) { Get-XAttr -Element $cNvPr -Namespace $null -LocalName 'name' } else { '(unnamed)' } if ([string]::IsNullOrEmpty($name)) { $name = '(unnamed)' } if (-not (Test-AltTextAttribute $cNvPr)) { $Issues.Value += New-Issue -Severity ERROR -RuleName 'MissingAltText' ` -Description "Image/object `"$name`" has no alt text" } } } 'wgp' { if ($nsName -eq $NS.wpg) { Test-GroupAltText -Group $child -Issues $Issues } } } } } # --- Rule: MissingTableHeaders ----------------------------------------------- # Pass only if the first w:tr carries w:trPr/w:tblHeader (presence; default # value="1"). w:tblLook is table-style metadata that drives visual first-row # formatting; it does not expose header semantics to assistive technology, so # it must NOT satisfy this rule. # # Layout tables — tables used purely for visual arrangement (e.g. photo grids) # rather than tabular data — are exempt. A table with w:tblPr/w:tblDescription # is treated as declaring its purpose to assistive tech; forcing tblHeader on # the first row would mislabel arrangement cells as data headers, so we use # the presence of tblDescription as the OOXML signal of layout intent. function Test-TableHeader { param([Xml.Linq.XContainer] $Root, [int] $StartIndex, [ref] $Issues) if (-not $Root) { return $StartIndex } $tables = Get-Descendant -Root $Root -Namespace $NS.w -LocalName 'tbl' $idx = $StartIndex foreach ($tbl in $tables) { $idx++ $rows = $tbl.Elements([Xml.Linq.XName]::Get('tr', $NS.w)) if (-not $rows -or $rows.Count -eq 0) { continue } $tblPr = Get-ChildElement -Parent $tbl -Namespace $NS.w -LocalName 'tblPr' if ($tblPr) { $tblDescription = Get-ChildElement -Parent $tblPr -Namespace $NS.w -LocalName 'tblDescription' if ($tblDescription) { # Layout table — skip the header check. continue } } $firstRow = $rows | Select-Object -First 1 $hasHeader = $false $trPr = Get-ChildElement -Parent $firstRow -Namespace $NS.w -LocalName 'trPr' if ($trPr) { $tblHeader = Get-ChildElement -Parent $trPr -Namespace $NS.w -LocalName 'tblHeader' if ($tblHeader) { $val = Get-XAttr -Element $tblHeader -Namespace $NS.w -LocalName 'val' # Default is "1" (true) if attr absent. if ([string]::IsNullOrEmpty($val) -or $val -eq '1' -or $val -eq 'true' -or $val -eq 'on') { $hasHeader = $true } } } if (-not $hasHeader) { $Issues.Value += New-Issue -Severity ERROR -RuleName 'MissingTableHeaders' ` -Description "Table $idx does not have header information" } } return $idx } # --- Rule: MissingContentControlTitle ---------------------------------------- function Test-ContentControlTitle { param([Xml.Linq.XContainer] $Root, [int] $StartIndex, [ref] $Issues) if (-not $Root) { return $StartIndex } $sdts = Get-Descendant -Root $Root -Namespace $NS.w -LocalName 'sdt' $idx = $StartIndex foreach ($sdt in $sdts) { $idx++ $sdtPr = Get-ChildElement -Parent $sdt -Namespace $NS.w -LocalName 'sdtPr' $alias = Get-ChildElement -Parent $sdtPr -Namespace $NS.w -LocalName 'alias' $val = if ($alias) { Get-XAttr -Element $alias -Namespace $NS.w -LocalName 'val' } else { $null } if ([string]::IsNullOrEmpty($val)) { $Issues.Value += New-Issue -Severity ERROR -RuleName 'MissingContentControlTitle' ` -Description "Content control at position $idx has no title" } } return $idx } # --- Rule: MergedTableCells --------------------------------------------------- function Test-MergedCell { param([Xml.Linq.XContainer] $Root, [int] $StartIndex, [ref] $Issues) if (-not $Root) { return $StartIndex } $tables = Get-Descendant -Root $Root -Namespace $NS.w -LocalName 'tbl' $idx = $StartIndex foreach ($tbl in $tables) { $idx++ $cells = Get-Descendant -Root $tbl -Namespace $NS.w -LocalName 'tc' if (-not $cells -or $cells.Count -eq 0) { continue } $hasMerge = $false foreach ($tc in $cells) { $tcPr = Get-ChildElement -Parent $tc -Namespace $NS.w -LocalName 'tcPr' if (-not $tcPr) { continue } $vMerge = Get-ChildElement -Parent $tcPr -Namespace $NS.w -LocalName 'vMerge' if ($vMerge) { # Continuation of vertical merge if no val or val != "restart". $vmVal = Get-XAttr -Element $vMerge -Namespace $NS.w -LocalName 'val' if ([string]::IsNullOrEmpty($vmVal) -or $vmVal -ne 'restart') { $hasMerge = $true break } } $gridSpan = Get-ChildElement -Parent $tcPr -Namespace $NS.w -LocalName 'gridSpan' if ($gridSpan) { $gsVal = Get-XAttr -Element $gridSpan -Namespace $NS.w -LocalName 'val' $gsInt = 0 if ([int]::TryParse($gsVal, [ref] $gsInt)) { if ($gsInt -gt 1) { $hasMerge = $true break } } } } if (-not $hasMerge) { # Detect nested table: any descendant w:tbl whose parent chain includes this $tbl through a w:tc. $nested = Get-Descendant -Root $tbl -Namespace $NS.w -LocalName 'tbl' foreach ($nt in $nested) { if ($nt -ne $tbl) { $hasMerge = $true break } } } if ($hasMerge) { $Issues.Value += New-Issue -Severity WARNING -RuleName 'MergedTableCells' ` -Description "Table $idx contains merged or nested cells" } } return $idx } # --- Rule: HeadingOrderSkip --------------------------------------------------- # Walks paragraphs in document order, tracks heading levels from # w:pPr/w:pStyle/@w:val matching ^Heading([1-9])$. Warn if level > prev + 1. function Test-HeadingOrder { param([Xml.Linq.XContainer] $Root, [ref] $Issues) if (-not $Root) { return } $paras = Get-Descendant -Root $Root -Namespace $NS.w -LocalName 'p' $prevLevel = 0 foreach ($p in $paras) { $pPr = Get-ChildElement -Parent $p -Namespace $NS.w -LocalName 'pPr' $pStyle = Get-ChildElement -Parent $pPr -Namespace $NS.w -LocalName 'pStyle' $val = if ($pStyle) { Get-XAttr -Element $pStyle -Namespace $NS.w -LocalName 'val' } else { $null } if ([string]::IsNullOrEmpty($val)) { continue } if ($val -match '^Heading([1-9])$') { $level = [int] $Matches[1] if ($prevLevel -gt 0 -and $level -gt ($prevLevel + 1)) { $Issues.Value += New-Issue -Severity WARNING -RuleName 'HeadingOrderSkip' ` -Description "Heading level skipped from $prevLevel to $level" } $prevLevel = $level } } } # --- Rule: FloatingObject ----------------------------------------------------- function Test-FloatingObject { param([Xml.Linq.XContainer] $Root, [ref] $Issues) if (-not $Root) { return } $anchors = Get-Descendant -Root $Root -Namespace $NS.wp -LocalName 'anchor' foreach ($a in $anchors) { $docPr = Get-ChildElement -Parent $a -Namespace $NS.wp -LocalName 'docPr' $name = if ($docPr) { Get-XAttr -Element $docPr -Namespace $null -LocalName 'name' } else { $null } if ([string]::IsNullOrEmpty($name)) { $name = '(unnamed)' } $Issues.Value += New-Issue -Severity WARNING -RuleName 'FloatingObject' ` -Description "Object `"$name`" is floating (not inline with text)" } } # --- Rule: RepeatedBlanks ----------------------------------------------------- # Concat w:t per paragraph; flag runs of 3+ space (U+0020) or NBSP (U+00A0). # Tabs are NOT flagged (per spec). function Test-RepeatedBlank { param([Xml.Linq.XContainer] $Root, [ref] $Issues) if (-not $Root) { return } $paras = Get-Descendant -Root $Root -Namespace $NS.w -LocalName 'p' foreach ($p in $paras) { $texts = Get-Descendant -Root $p -Namespace $NS.w -LocalName 't' if (-not $texts -or $texts.Count -eq 0) { continue } $sb = New-Object System.Text.StringBuilder foreach ($t in $texts) { [void] $sb.Append($t.Value) } $combined = $sb.ToString() if ($combined -match '[ ]{3,}') { $Issues.Value += New-Issue -Severity WARNING -RuleName 'RepeatedBlanks' ` -Description 'Paragraph contains repeated blank characters' } } } # --- Rule: NoHeadingStyles (TIP) --------------------------------------------- function Test-NoHeadingStyle { param([Xml.Linq.XContainer[]] $Roots, [ref] $Issues) $found = $false foreach ($root in $Roots) { if (-not $root) { continue } $pStyles = Get-Descendant -Root $root -Namespace $NS.w -LocalName 'pStyle' foreach ($ps in $pStyles) { $val = Get-XAttr -Element $ps -Namespace $NS.w -LocalName 'val' if (-not [string]::IsNullOrEmpty($val) -and $val -match '^Heading[1-9]$') { $found = $true break } } if ($found) { break } } if (-not $found) { $Issues.Value += New-Issue -Severity TIP -RuleName 'NoHeadingStyles' ` -Description 'Document contains no heading-style paragraphs' } } # --- Contrast helpers -------------------------------------------------------- # WCAG 2.x relative luminance and contrast ratio. # https://www.w3.org/TR/WCAG21/#dfn-relative-luminance 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) } # Returns the explicit 6-digit hex value of a w:shd element's fill, or $null # if the shading uses a theme reference, "auto", or no fill. function Get-ExplicitShadingFill { param([Xml.Linq.XElement] $ShadingParent) if (-not $ShadingParent) { return $null } $shd = Get-ChildElement -Parent $ShadingParent -Namespace $NS.w -LocalName 'shd' if (-not $shd) { return $null } if (-not [string]::IsNullOrEmpty((Get-XAttr -Element $shd -Namespace $NS.w -LocalName 'themeFill'))) { return $null } $fill = Get-XAttr -Element $shd -Namespace $NS.w -LocalName 'fill' if ([string]::IsNullOrEmpty($fill) -or $fill -eq 'auto') { return $null } if ($fill -match '^[0-9A-Fa-f]{6}$') { return $fill } return $null } # --- Rule: LowContrast (WARNING) --------------------------------------------- # Best-effort contrast check. Only flags runs where BOTH the foreground (run # color) and background (run shading or paragraph shading) are explicit 6-digit # hex values. Skips: "auto" colors, themeColor / themeFill references, runs # with no explicit color, paragraphs with no explicit shading. False positives # from style/theme inheritance are avoided by refusing to guess. # # Threshold follows WCAG 2.x: # 3.0:1 for large text (18pt+, or 14pt+ bold) # 4.5:1 otherwise # Font size in OOXML is in half-points: 22 -> 11pt (default), 36 -> 18pt, # 28 -> 14pt. function Test-LowContrast { param([Xml.Linq.XContainer] $Root, [ref] $Issues) if (-not $Root) { return } $runs = Get-Descendant -Root $Root -Namespace $NS.w -LocalName 'r' foreach ($run in $runs) { $rPr = Get-ChildElement -Parent $run -Namespace $NS.w -LocalName 'rPr' if (-not $rPr) { continue } $colorEl = Get-ChildElement -Parent $rPr -Namespace $NS.w -LocalName 'color' if (-not $colorEl) { continue } if (-not [string]::IsNullOrEmpty((Get-XAttr -Element $colorEl -Namespace $NS.w -LocalName 'themeColor'))) { continue } $fgVal = Get-XAttr -Element $colorEl -Namespace $NS.w -LocalName 'val' if ([string]::IsNullOrEmpty($fgVal) -or $fgVal -eq 'auto') { continue } if ($fgVal -notmatch '^[0-9A-Fa-f]{6}$') { continue } $bgVal = Get-ExplicitShadingFill -ShadingParent $rPr if (-not $bgVal) { $p = $run.Parent while ($p -and $p.Name.LocalName -ne 'p') { $p = $p.Parent } if ($p) { $pPr = Get-ChildElement -Parent $p -Namespace $NS.w -LocalName 'pPr' if ($pPr) { $bgVal = Get-ExplicitShadingFill -ShadingParent $pPr } } } if (-not $bgVal) { continue } $halfPts = 22 $sz = Get-ChildElement -Parent $rPr -Namespace $NS.w -LocalName 'sz' if ($sz) { $szVal = Get-XAttr -Element $sz -Namespace $NS.w -LocalName 'val' $parsed = 0 if ([int]::TryParse($szVal, [ref] $parsed)) { $halfPts = $parsed } } $isBold = ($null -ne (Get-ChildElement -Parent $rPr -Namespace $NS.w -LocalName 'b')) $isLarge = ($halfPts -ge 36) -or ($isBold -and $halfPts -ge 28) $threshold = if ($isLarge) { 3.0 } else { 4.5 } $ratio = Get-ContrastRatio -ForegroundHex $fgVal -BackgroundHex $bgVal if ($ratio -lt $threshold) { $sb = New-Object System.Text.StringBuilder foreach ($t in (Get-Descendant -Root $run -Namespace $NS.w -LocalName 't')) { [void] $sb.Append($t.Value) } $preview = $sb.ToString() if ([string]::IsNullOrEmpty($preview)) { $preview = '(empty run)' } elseif ($preview.Length -gt 30) { $preview = $preview.Substring(0, 30) + '...' } $Issues.Value += New-Issue -Severity WARNING -RuleName 'LowContrast' ` -Description ("Text `"{0}`" has contrast {1:N2}:1 (#{2} on #{3}); WCAG requires {4}:1" -f $preview, $ratio, $fgVal.ToUpper(), $bgVal.ToUpper(), $threshold) } } } # --- Autofix helpers --------------------------------------------------------- # Scope: only the rules whose remediation is a deterministic structural OOXML # edit. Anything that requires authoring (alt text, content control title, # heading promotion, descriptive link text), is destructive (unmerge cells, # remove protection), or changes layout (anchor -> inline) is intentionally # out of scope. # # All fix functions mutate the supplied XDocument in place and return the # count of items they fixed. Saving the part is the caller's job. # HSL <-> sRGB conversion. Used by Get-NearestPassingForeground to find the # perceptually-closest foreground color (preserving hue and saturation) that # meets a given WCAG contrast ratio against a fixed background. CIELCh would # be more uniform but HSL is good enough for "nudge until it passes" and keeps # the math (and the runtime cost per low-contrast run) small. 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) } # Find the smallest |L - L_orig| at which the foreground passes contrast. # Search both directions (darkening, lightening) and pick the closer side. # Step 0.005 = 200 candidates per direction worst-case; far smaller in practice. 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 } # Mid-tone bg with no passing color along the lightness axis: fall back to # whichever pole has more contrast headroom. $blackR = Get-ContrastRatio -ForegroundHex '000000' -BackgroundHex $BackgroundHex $whiteR = Get-ContrastRatio -ForegroundHex 'FFFFFF' -BackgroundHex $BackgroundHex if ($blackR -ge $whiteR) { return '000000' } else { return 'FFFFFF' } } function Repair-WordTableHeader { param([Xml.Linq.XContainer] $Root) if (-not $Root) { return 0 } $fixed = 0 $tables = Get-Descendant -Root $Root -Namespace $NS.w -LocalName 'tbl' foreach ($tbl in $tables) { $rows = $tbl.Elements([Xml.Linq.XName]::Get('tr', $NS.w)) if (-not $rows -or $rows.Count -eq 0) { continue } $tblPr = Get-ChildElement -Parent $tbl -Namespace $NS.w -LocalName 'tblPr' if ($tblPr) { # Layout-table opt-out: skip tables that declare themselves as # layout via tblDescription, matching the rule. if (Get-ChildElement -Parent $tblPr -Namespace $NS.w -LocalName 'tblDescription') { continue } } $firstRow = $rows | Select-Object -First 1 $trPr = Get-ChildElement -Parent $firstRow -Namespace $NS.w -LocalName 'trPr' if ($trPr -and (Get-ChildElement -Parent $trPr -Namespace $NS.w -LocalName 'tblHeader')) { continue } if (-not $trPr) { $trPr = New-Object Xml.Linq.XElement -ArgumentList ([Xml.Linq.XName]::Get('trPr', $NS.w)) # w:trPr must be the first child of w:tr per the schema. $firstRow.AddFirst($trPr) } $tblHeader = New-Object Xml.Linq.XElement -ArgumentList ([Xml.Linq.XName]::Get('tblHeader', $NS.w)) $trPr.Add($tblHeader) $fixed++ } return $fixed } # Replace runs of 3+ space (U+0020) or NBSP (U+00A0) with a single tab character # inside each w:t element. Cross-w:t blank runs are not handled (the typical # case is "user typed N spaces in one run"); they would require splitting and # re-stitching runs, which we deliberately avoid. xml:space="preserve" must be # set on any modified w:t so the tab survives serialization. function Repair-WordRepeatedBlank { param([Xml.Linq.XContainer] $Root) if (-not $Root) { return 0 } $fixed = 0 $texts = Get-Descendant -Root $Root -Namespace $NS.w -LocalName 't' foreach ($t in $texts) { $orig = $t.Value if ($orig -notmatch '[ ]{3,}') { continue } $new = [regex]::Replace($orig, "[ ]{3,}", "`t") if ($new -eq $orig) { continue } $t.Value = $new $xmlNs = 'http://www.w3.org/XML/1998/namespace' $existing = $t.Attribute([Xml.Linq.XName]::Get('space', $xmlNs)) if (-not $existing) { $t.SetAttributeValue([Xml.Linq.XName]::Get('space', $xmlNs), 'preserve') } $fixed++ } return $fixed } function Repair-WordLowContrast { param([Xml.Linq.XContainer] $Root) if (-not $Root) { return 0 } $fixed = 0 $runs = Get-Descendant -Root $Root -Namespace $NS.w -LocalName 'r' foreach ($run in $runs) { $rPr = Get-ChildElement -Parent $run -Namespace $NS.w -LocalName 'rPr' if (-not $rPr) { continue } $colorEl = Get-ChildElement -Parent $rPr -Namespace $NS.w -LocalName 'color' if (-not $colorEl) { continue } if (-not [string]::IsNullOrEmpty((Get-XAttr -Element $colorEl -Namespace $NS.w -LocalName 'themeColor'))) { continue } $fgVal = Get-XAttr -Element $colorEl -Namespace $NS.w -LocalName 'val' if ([string]::IsNullOrEmpty($fgVal) -or $fgVal -eq 'auto') { continue } if ($fgVal -notmatch '^[0-9A-Fa-f]{6}$') { continue } $bgVal = Get-ExplicitShadingFill -ShadingParent $rPr if (-not $bgVal) { $p = $run.Parent while ($p -and $p.Name.LocalName -ne 'p') { $p = $p.Parent } if ($p) { $pPr = Get-ChildElement -Parent $p -Namespace $NS.w -LocalName 'pPr' if ($pPr) { $bgVal = Get-ExplicitShadingFill -ShadingParent $pPr } } } if (-not $bgVal) { continue } $halfPts = 22 $sz = Get-ChildElement -Parent $rPr -Namespace $NS.w -LocalName 'sz' if ($sz) { $szVal = Get-XAttr -Element $sz -Namespace $NS.w -LocalName 'val' $parsed = 0 if ([int]::TryParse($szVal, [ref] $parsed)) { $halfPts = $parsed } } $isBold = ($null -ne (Get-ChildElement -Parent $rPr -Namespace $NS.w -LocalName 'b')) $isLarge = ($halfPts -ge 36) -or ($isBold -and $halfPts -ge 28) $threshold = if ($isLarge) { 3.0 } else { 4.5 } if ((Get-ContrastRatio -ForegroundHex $fgVal -BackgroundHex $bgVal) -ge $threshold) { continue } $newFg = Get-NearestPassingForeground -ForegroundHex $fgVal -BackgroundHex $bgVal -Threshold $threshold if ($newFg -and $newFg -ne $fgVal.ToUpper()) { $colorEl.SetAttributeValue([Xml.Linq.XName]::Get('val', $NS.w), $newFg) $fixed++ } } return $fixed } function Save-PartXDocument { param($Part, [Xml.Linq.XDocument] $Document) $stream = $Part.GetStream([IO.FileMode]::Create, [IO.FileAccess]::Write) try { $Document.Save($stream) } finally { $stream.Dispose() } } # --- Open document and run rules --------------------------------------------- $issues = @() $doc = $null try { try { $doc = [DocumentFormat.OpenXml.Packaging.WordprocessingDocument]::Open($FilePath, $false) } catch [System.IO.IOException] { [Console]::Error.WriteLine("File is locked or unreadable: $($_.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 document: $($_.Exception.Message)") exit 2 } $issues += New-Issue -Severity ERROR -RuleName 'DocumentProtected' ` -Description 'Document is IRM- or password-protected' $doc = $null } if ($doc) { $main = $doc.MainDocumentPart if (-not $main) { [Console]::Error.WriteLine("Document has no main part.") exit 2 } $bodyDoc = Get-PartXDocument $main $bodyRoot = if ($bodyDoc) { $bodyDoc.Root } else { $null } $headerRoots = @() foreach ($hp in $main.HeaderParts) { $hd = Get-PartXDocument $hp if ($hd -and $hd.Root) { $headerRoots += $hd.Root } } $footerRoots = @() foreach ($fp in $main.FooterParts) { $fd = Get-PartXDocument $fp if ($fd -and $fd.Root) { $footerRoots += $fd.Root } } $allRoots = @() if ($bodyRoot) { $allRoots += $bodyRoot } $allRoots += $headerRoots $allRoots += $footerRoots $issuesRef = [ref] $issues # Alt text + floating + repeated blanks + heading order + content control title # apply across body, headers, footers. foreach ($root in $allRoots) { Test-AltText -Root $root -Issues $issuesRef Test-FloatingObject -Root $root -Issues $issuesRef Test-RepeatedBlank -Root $root -Issues $issuesRef Test-HeadingOrder -Root $root -Issues $issuesRef } # Tables and content controls: count indices continuously across roots. $tableIdx = 0 $mergeIdx = 0 $sdtIdx = 0 foreach ($root in $allRoots) { $tableIdx = Test-TableHeader -Root $root -StartIndex $tableIdx -Issues $issuesRef $mergeIdx = Test-MergedCell -Root $root -StartIndex $mergeIdx -Issues $issuesRef $sdtIdx = Test-ContentControlTitle -Root $root -StartIndex $sdtIdx -Issues $issuesRef } # TIP rule (always-on; only emitted in detailed mode) Test-NoHeadingStyle -Roots $allRoots -Issues $issuesRef # WARNING: best-effort contrast check (skips theme/auto colors) foreach ($root in $allRoots) { Test-LowContrast -Root $root -Issues $issuesRef } $issues = $issuesRef.Value } } finally { if ($doc) { try { $doc.Dispose() } catch { Write-Verbose "Dispose failed: $($_.Exception.Message)" } } } # --- Decide pass/fail --------------------------------------------------------- $hasError = $false foreach ($i in $issues) { if ($i.Severity -eq 'ERROR') { $hasError = $true; break } } # --- Emit output -------------------------------------------------------------- if ($Format -eq 'detailed') { $severityRank = @{ 'ERROR' = 0; 'WARNING' = 1; 'TIP' = 2 } $emit = $issues | Sort-Object ` @{ Expression = { $severityRank[$_.Severity] } }, ` @{ Expression = { $_.RuleName } } foreach ($i in $emit) { # In detailed mode every issue prints; TIPs only appear in detailed mode # (which is what we're in), and ERROR/WARNING always print. Write-Output ("{0}`t{1}`t{2}" -f $i.Severity, $i.RuleName, $i.Description) } } if ($hasError) { $errorRules = ($issues | Where-Object { $_.Severity -eq 'ERROR' } | Select-Object -ExpandProperty RuleName -Unique) -join ', ' Write-Output "FAIL $FilePath`: $errorRules" $originalExit = 1 } else { Write-Output "PASS $FilePath" $originalExit = 0 } # --- Autofix ------------------------------------------------------------------ # Applies only the deterministic structural remediations; everything else # requires authoring or layout judgment and is intentionally skipped. The # original is never touched: we copy to <basename>.fixed.docx and edit the copy. if (-not $Fix) { exit $originalExit } $fixableRuleNames = @('MissingTableHeaders', 'RepeatedBlanks', 'LowContrast') $hasFixable = $false foreach ($i in $issues) { if ($fixableRuleNames -contains $i.RuleName) { $hasFixable = $true; break } } if (-not $hasFixable) { exit $originalExit } $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; RepeatedBlanks = 0; LowContrast = 0 } $fixDoc = $null try { try { $fixDoc = [DocumentFormat.OpenXml.Packaging.WordprocessingDocument]::Open($fixedPath, $true) } catch { [Console]::Error.WriteLine("Failed to open fixed copy '$fixedPath' for editing: $($_.Exception.Message)") exit 2 } $main = $fixDoc.MainDocumentPart $partsToFix = @() if ($main) { $partsToFix += $main } foreach ($hp in $main.HeaderParts) { $partsToFix += $hp } foreach ($fp in $main.FooterParts) { $partsToFix += $fp } foreach ($part in $partsToFix) { $xdoc = Get-PartXDocument $part if (-not $xdoc -or -not $xdoc.Root) { continue } $fixCounts.MissingTableHeaders += (Repair-WordTableHeader -Root $xdoc.Root) $fixCounts.RepeatedBlanks += (Repair-WordRepeatedBlank -Root $xdoc.Root) $fixCounts.LowContrast += (Repair-WordLowContrast -Root $xdoc.Root) Save-PartXDocument -Part $part -Document $xdoc } } finally { if ($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) { # Issues were reported but the autofix paths matched none of the actual # OOXML state (e.g., theme-driven contrast, cross-w:t blank runs). Drop # the empty .fixed file rather than leaving a misleading artifact. Remove-Item -LiteralPath $fixedPath -Force -ErrorAction SilentlyContinue exit $originalExit } Write-Output ("FIXED {0}: {1}" -f $fixedPath, ($fixSummaryParts -join ', ')) # Re-run on the fixed file so the user sees its PASS/FAIL (and any residual # issues in detailed mode). Recursion is bounded: -Fix is intentionally not # forwarded, so this terminates. & $PSCommandPath -FilePath $fixedPath -Format $Format exit $LASTEXITCODE |