Private/check-xlsx-accessibility.ps1

<#
.SYNOPSIS
    Runs Microsoft Office Accessibility Checker rules against a .xlsx/.xlsm
    file using the Open XML SDK.

.DESCRIPTION
    Implements the Excel rule subset from Microsoft's published Accessibility
    Checker rules by traversing the underlying OOXML directly with the
    Open XML SDK. No Office installation is required.

    Rule coverage:
      ERROR: MissingAltText, MissingTableHeaders, DocumentProtected,
               RedOnlyNegativeFormatting
      WARNING: MergedCells, DefaultSheetTabName, LowContrast
      TIP: DefaultTableName

    Notes:
      - RedOnlyNegativeFormatting: best-effort detection of "[Red]" numFmt
        codes that lack a complementary minus/parens/secondary color.
      - LowContrast: best-effort. Only flags cells whose font color and
        cell fill are both explicit RGB values; theme references, indexed
        palette colors, "auto", non-solid fills, and conditional formatting
        are skipped.

.PARAMETER FilePath
    Path to a .xlsx or .xlsm 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.xlsx (or .xlsm) with deterministic structural remediations
    applied for the rules MissingTableHeaders, RedOnlyNegativeFormatting, and
    LowContrast. The original file is never modified.

.OUTPUTS
    Exit codes:
      0 no errors found (warnings/tips 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'

# Localized default sheet-tab name patterns. Adding a locale is a one-line edit.
$DefaultSheetNamePatterns = @(
    '^(Sheet|Chart)\d+$',     # English
    '^Tabelle\d+$',            # German
    '^(Feuil|Feuille)\d+$',    # French
    '^Hoja\d+$',               # Spanish
    '^Foglio\d+$',             # Italian
    '^Planilha\d+$',           # Portuguese
    '^シート\d+$'              # Japanese
)

# OOXML namespaces
$NS = @{
    s   = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
    xdr = 'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing'
    r   = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
}

#--------------------------------------------------------------------
# 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 '.xlsx' -and $ext -ne '.xlsm') {
    [Console]::Error.WriteLine("Unsupported file type for Excel checker: $ext (expected .xlsx or .xlsm)")
    exit 2
}

# Resolve to absolute path so SDK doesn't rely on cwd.
$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 — 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(
        [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 }
    # Fall back to unqualified attribute (most xlsx attributes have no namespace).
    $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))
}

#--------------------------------------------------------------------
# Sheet-name lookup: relate WorksheetPart -> sheet display name
#--------------------------------------------------------------------

function Get-SheetNameMap {
    param($WorkbookPart)
    $map = @{}
    $wbDoc = Get-PartXDocument -Part $WorkbookPart
    if ($null -eq $wbDoc) { return $map }
    $sheetsEl = Get-ChildElement -Parent $wbDoc.Root -Namespace $NS.s -LocalName 'sheets'
    if ($null -eq $sheetsEl) { return $map }
    foreach ($sheet in (Get-ChildElement -Parent $sheetsEl -Namespace $NS.s -LocalName 'sheet' -All)) {
        $name = Get-XAttr -Element $sheet -Namespace $null -LocalName 'name'
        $rid  = Get-XAttr -Element $sheet -Namespace $NS.r -LocalName 'id'
        if ($rid) { $map[$rid] = $name }
    }
    return $map
}

function Get-WorksheetDisplayName {
    param($WorkbookPart, $WorksheetPart, $SheetMap)
    try {
        $rid = $WorkbookPart.GetIdOfPart($WorksheetPart)
        if ($SheetMap.ContainsKey($rid)) { return $SheetMap[$rid] }
    } catch {
        Write-Verbose "GetIdOfPart failed: $($_.Exception.Message)"
    }
    return '(unknown)'
}

#--------------------------------------------------------------------
# Rule: MissingAltText
#--------------------------------------------------------------------

# Map shape local-name -> non-visual-properties container local-name
$XdrNvContainerByShape = @{
    'pic'          = 'nvPicPr'
    'graphicFrame' = 'nvGraphicFramePr'
    'sp'           = 'nvSpPr'
    'grpSp'        = 'nvGrpSpPr'
}

# Pass if any of: @descr non-empty, @title non-empty, @decorative in {1,true}.
function Test-XdrAltTextAttribute {
    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'
    return ($decorative -eq '1' -or $decorative -eq 'true')
}

function Test-XdrShapeContainer {
    param([System.Xml.Linq.XElement] $Container, [string] $SheetName)
    $issues = @()
    foreach ($shapeLocalName in $XdrNvContainerByShape.Keys) {
        $nvContainerName = $XdrNvContainerByShape[$shapeLocalName]
        foreach ($shape in (Get-ChildElement -Parent $Container -Namespace $NS.xdr -LocalName $shapeLocalName -All)) {
            $nv = Get-ChildElement -Parent $shape -Namespace $NS.xdr -LocalName $nvContainerName
            $cNvPr = Get-ChildElement -Parent $nv -Namespace $NS.xdr -LocalName 'cNvPr'
            if ($cNvPr -and -not (Test-XdrAltTextAttribute $cNvPr)) {
                $name = Get-XAttr -Element $cNvPr -Namespace $null -LocalName 'name'
                if ([string]::IsNullOrWhiteSpace($name)) { $name = '(unnamed)' }
                $issues += New-Issue -Severity 'ERROR' -RuleName 'MissingAltText' `
                    -Description "Image/object `"$name`" on sheet `"$SheetName`" has no alt text"
            }
            if ($shapeLocalName -eq 'grpSp') {
                $issues += Test-XdrShapeContainer -Container $shape -SheetName $SheetName
            }
        }
    }
    return $issues
}

function Test-AltText {
    param($DrawingsPart, [string] $SheetName)
    $issues = @()
    $doc = Get-PartXDocument -Part $DrawingsPart
    if ($null -eq $doc -or $null -eq $doc.Root) { return $issues }
    foreach ($anchorType in @('twoCellAnchor', 'oneCellAnchor', 'absoluteAnchor')) {
        foreach ($anchor in (Get-ChildElement -Parent $doc.Root -Namespace $NS.xdr -LocalName $anchorType -All)) {
            $issues += Test-XdrShapeContainer -Container $anchor -SheetName $SheetName
        }
    }
    return $issues
}

#--------------------------------------------------------------------
# Rule: MissingTableHeaders + DefaultTableName
#--------------------------------------------------------------------

function Test-TableDefinition {
    param($TableDefinitionPart)

    $issues = @()
    $doc = Get-PartXDocument -Part $TableDefinitionPart
    if ($null -eq $doc -or $null -eq $doc.Root) { return $issues }

    $tableEl = $doc.Root
    $name        = Get-XAttr -Element $tableEl -Namespace $null -LocalName 'name'
    $displayName = Get-XAttr -Element $tableEl -Namespace $null -LocalName 'displayName'
    $effectiveName = if ([string]::IsNullOrWhiteSpace($name)) { $displayName } else { $name }
    if ([string]::IsNullOrWhiteSpace($effectiveName)) { $effectiveName = '(unnamed)' }

    # MissingTableHeaders: headerRowCount="0" => fail
    $hrc = Get-XAttr -Element $tableEl -Namespace $null -LocalName 'headerRowCount'
    if ($null -ne $hrc -and $hrc -eq '0') {
        $issues += (New-Issue -Severity 'ERROR' -RuleName 'MissingTableHeaders' `
            -Description "Table `"$effectiveName`" has no header row")
    }

    # DefaultTableName tip
    if ($effectiveName -match '^Table\d+$') {
        $issues += (New-Issue -Severity 'TIP' -RuleName 'DefaultTableName' `
            -Description "Table `"$effectiveName`" uses a default placeholder name")
    }

    return $issues
}

#--------------------------------------------------------------------
# Rule: MergedCells
#--------------------------------------------------------------------

function Test-MergedCell {
    param($WorksheetPart, [string] $SheetName)

    $issues = @()
    $doc = Get-PartXDocument -Part $WorksheetPart
    if ($null -eq $doc -or $null -eq $doc.Root) { return $issues }

    $mergeCellsEl = Get-ChildElement -Parent $doc.Root -Namespace $NS.s -LocalName 'mergeCells'
    if ($null -eq $mergeCellsEl) { return $issues }

    $mergeCells = Get-ChildElement -Parent $mergeCellsEl -Namespace $NS.s -LocalName 'mergeCell' -All
    if ($mergeCells.Count -gt 0) {
        $issues += (New-Issue -Severity 'WARNING' -RuleName 'MergedCells' `
            -Description "Sheet `"$SheetName`" contains merged cells")
    }
    return $issues
}

#--------------------------------------------------------------------
# Rule: DefaultSheetTabName
#--------------------------------------------------------------------

function Test-SheetTabName {
    param($WorkbookPart)

    $issues = @()
    $doc = Get-PartXDocument -Part $WorkbookPart
    if ($null -eq $doc -or $null -eq $doc.Root) { return $issues }

    $sheetsEl = Get-ChildElement -Parent $doc.Root -Namespace $NS.s -LocalName 'sheets'
    if ($null -eq $sheetsEl) { return $issues }

    foreach ($sheet in (Get-ChildElement -Parent $sheetsEl -Namespace $NS.s -LocalName 'sheet' -All)) {
        $name = Get-XAttr -Element $sheet -Namespace $null -LocalName 'name'

        if ([string]::IsNullOrWhiteSpace($name)) {
            $issues += (New-Issue -Severity 'WARNING' -RuleName 'DefaultSheetTabName' `
                -Description "Sheet tab `"$name`" uses a default placeholder name")
            continue
        }

        $matched = $false
        foreach ($pattern in $DefaultSheetNamePatterns) {
            if ($name -match $pattern) { $matched = $true; break }
        }
        if ($matched) {
            $issues += (New-Issue -Severity 'WARNING' -RuleName 'DefaultSheetTabName' `
                -Description "Sheet tab `"$name`" uses a default placeholder name")
        }
    }

    return $issues
}

#--------------------------------------------------------------------
# Rule: RedOnlyNegativeFormatting
#--------------------------------------------------------------------

# Collect the set of numFmtId values that are actually referenced by a cell on
# any worksheet. Reporting unused custom <numFmt> entries as ERROR makes the
# CLI fail accessible workbooks because of stale style metadata that no cell
# uses. We resolve cell `s` -> cellXfs index -> numFmtId and return the set.
function Get-UsedNumFmtId {
    param($WorkbookPart)

    $used = New-Object 'System.Collections.Generic.HashSet[string]'

    if ($null -eq $WorkbookPart -or $null -eq $WorkbookPart.WorkbookStylesPart) {
        return $used
    }

    $stylesDoc = Get-PartXDocument -Part $WorkbookPart.WorkbookStylesPart
    if ($null -eq $stylesDoc -or $null -eq $stylesDoc.Root) { return $used }

    # Build cellXf index -> numFmtId map.
    $cellXfsEl = Get-ChildElement -Parent $stylesDoc.Root -Namespace $NS.s -LocalName 'cellXfs'
    if ($null -eq $cellXfsEl) { return $used }

    $cellXfNumFmtIds = New-Object 'System.Collections.Generic.List[string]'
    foreach ($xf in (Get-ChildElement -Parent $cellXfsEl -Namespace $NS.s -LocalName 'xf' -All)) {
        $id = Get-XAttr -Element $xf -Namespace $null -LocalName 'numFmtId'
        if ([string]::IsNullOrEmpty($id)) { $id = '0' }
        $cellXfNumFmtIds.Add($id) | Out-Null
    }

    # Walk every worksheet's cells and record the numFmtId of each used style.
    $cName = [System.Xml.Linq.XName]::Get('c', $NS.s)
    foreach ($wsPart in $WorkbookPart.WorksheetParts) {
        $wsDoc = Get-PartXDocument -Part $wsPart
        if ($null -eq $wsDoc -or $null -eq $wsDoc.Root) { continue }

        foreach ($cell in $wsDoc.Root.Descendants($cName)) {
            $sAttr = $cell.Attribute([System.Xml.Linq.XName]::Get('s'))
            $sIdx = if ($sAttr) { $sAttr.Value } else { '0' }
            $parsed = 0
            if ([int]::TryParse($sIdx, [ref] $parsed) -and $parsed -ge 0 -and $parsed -lt $cellXfNumFmtIds.Count) {
                [void] $used.Add($cellXfNumFmtIds[$parsed])
            }
        }
    }

    return $used
}

function Test-RedOnlyNumberFormat {
    param(
        $WorkbookStylesPart,
        [System.Collections.Generic.HashSet[string]] $UsedNumFmtIds
    )

    $issues = @()
    if ($null -eq $WorkbookStylesPart) { return $issues }

    $doc = Get-PartXDocument -Part $WorkbookStylesPart
    if ($null -eq $doc -or $null -eq $doc.Root) { return $issues }

    $numFmtsEl = Get-ChildElement -Parent $doc.Root -Namespace $NS.s -LocalName 'numFmts'
    if ($null -eq $numFmtsEl) { return $issues }

    foreach ($numFmt in (Get-ChildElement -Parent $numFmtsEl -Namespace $NS.s -LocalName 'numFmt' -All)) {
        $numFmtId = Get-XAttr -Element $numFmt -Namespace $null -LocalName 'numFmtId'
        if ($UsedNumFmtIds -and -not [string]::IsNullOrEmpty($numFmtId) -and -not $UsedNumFmtIds.Contains($numFmtId)) {
            continue
        }
        $code = Get-XAttr -Element $numFmt -Namespace $null -LocalName 'formatCode'
        if ([string]::IsNullOrEmpty($code)) { continue }

        # Excel format sections: positive ; negative ; zero ; text
        # Section delimiter is ';' (not escaped in standard usage).
        $sections = $code -split ';'
        if ($sections.Count -lt 2) { continue }

        $negative = $sections[1]
        if ([string]::IsNullOrEmpty($negative)) { continue }

        # Check if [Red] appears in the negative section
        if ($negative -notmatch '(?i)\[Red\]') { continue }

        # Strip [Red] then check for any other color marker, parentheses, or leading minus
        $stripped = $negative -replace '(?i)\[Red\]', ''

        # Other color markers: any [Black], [Blue], [Cyan], [Green], [Magenta], [White], [Yellow], [Color N]
        $hasOtherColor = $stripped -match '(?i)\[(Black|Blue|Cyan|Green|Magenta|White|Yellow|Color\s*\d+)\]'

        # Parentheses around the negative format
        $hasParens = $stripped.Contains('(') -or $stripped.Contains(')')

        # Leading minus sign (allow leading whitespace) — strip remaining bracketed conditionals
        # (e.g. "[<0]") so we look at the actual format text.
        $strippedNoBrackets = $stripped -replace '\[[^\]]*\]', ''
        $hasMinus = $strippedNoBrackets.TrimStart() -match '^\s*-'

        if (-not $hasOtherColor -and -not $hasParens -and -not $hasMinus) {
            $issues += (New-Issue -Severity 'ERROR' -RuleName 'RedOnlyNegativeFormatting' `
                -Description "Number format `"$code`" uses red-only differentiation for negative values")
        }
    }

    return $issues
}

#--------------------------------------------------------------------
# Rule: LowContrast (WARNING) -- best-effort
#--------------------------------------------------------------------
#
# Walks every text-bearing cell on every worksheet, resolves its effective
# font color and fill color via the cellXf -> font/fill chain in styles.xml,
# and flags pairs whose WCAG 2.x contrast ratio falls below 4.5:1.
#
# Skipped (cannot be evaluated without rendering): "auto" colors, theme
# references, indexed-palette references, gradient/non-solid fills, and any
# cell whose font or fill resolves to a non-explicit value. Conditional
# formatting rules are also out of scope (they depend on cell values).
#
# Note: font-size-driven large-text relaxation (3.0:1 vs 4.5:1) is omitted
# because Excel cells inherit font size in ways the static check cannot
# resolve reliably; we use the stricter 4.5:1 universally.

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)
}

# Excel <color> elements may carry rgb=, theme=, indexed=, or auto=. Only
# explicit rgb values can be evaluated; everything else returns $null.
# rgb is stored as 8-hex ARGB; we strip the alpha channel.
function Get-ExcelExplicitColorHex {
    param([System.Xml.Linq.XElement] $ColorEl)
    if ($null -eq $ColorEl) { return $null }
    if (-not [string]::IsNullOrEmpty((Get-XAttr -Element $ColorEl -Namespace $null -LocalName 'theme'))) { return $null }
    if (-not [string]::IsNullOrEmpty((Get-XAttr -Element $ColorEl -Namespace $null -LocalName 'indexed'))) { return $null }
    $auto = Get-XAttr -Element $ColorEl -Namespace $null -LocalName 'auto'
    if ($auto -eq '1' -or $auto -eq 'true') { return $null }
    $rgb = Get-XAttr -Element $ColorEl -Namespace $null -LocalName 'rgb'
    if ([string]::IsNullOrEmpty($rgb)) { return $null }
    if ($rgb -match '^[0-9A-Fa-f]{8}$') { return $rgb.Substring(2) }
    if ($rgb -match '^[0-9A-Fa-f]{6}$') { return $rgb }
    return $null
}

function Test-LowContrast {
    param($WorkbookPart, $SheetMap)
    $issues = @()
    if ($null -eq $WorkbookPart -or $null -eq $WorkbookPart.WorkbookStylesPart) { return $issues }

    $stylesDoc = Get-PartXDocument -Part $WorkbookPart.WorkbookStylesPart
    if ($null -eq $stylesDoc -or $null -eq $stylesDoc.Root) { return $issues }

    # fonts[i] -> hex (or $null if not explicit)
    $fontColors = New-Object 'System.Collections.Generic.List[object]'
    $fontsEl = Get-ChildElement -Parent $stylesDoc.Root -Namespace $NS.s -LocalName 'fonts'
    if ($fontsEl) {
        foreach ($font in (Get-ChildElement -Parent $fontsEl -Namespace $NS.s -LocalName 'font' -All)) {
            $colorEl = Get-ChildElement -Parent $font -Namespace $NS.s -LocalName 'color'
            $fontColors.Add((Get-ExcelExplicitColorHex $colorEl)) | Out-Null
        }
    }

    # fills[i] -> hex (only solid patternFill with explicit fgColor); else $null
    $fillColors = New-Object 'System.Collections.Generic.List[object]'
    $fillsEl = Get-ChildElement -Parent $stylesDoc.Root -Namespace $NS.s -LocalName 'fills'
    if ($fillsEl) {
        foreach ($fill in (Get-ChildElement -Parent $fillsEl -Namespace $NS.s -LocalName 'fill' -All)) {
            $hex = $null
            $pf = Get-ChildElement -Parent $fill -Namespace $NS.s -LocalName 'patternFill'
            if ($pf) {
                $type = Get-XAttr -Element $pf -Namespace $null -LocalName 'patternType'
                if ($type -eq 'solid') {
                    $fgColorEl = Get-ChildElement -Parent $pf -Namespace $NS.s -LocalName 'fgColor'
                    $hex = Get-ExcelExplicitColorHex $fgColorEl
                }
            }
            $fillColors.Add($hex) | Out-Null
        }
    }

    # cellXfs[i] -> (FontId, FillId)
    $cellXfs = New-Object 'System.Collections.Generic.List[object]'
    $cellXfsEl = Get-ChildElement -Parent $stylesDoc.Root -Namespace $NS.s -LocalName 'cellXfs'
    if ($cellXfsEl) {
        foreach ($xf in (Get-ChildElement -Parent $cellXfsEl -Namespace $NS.s -LocalName 'xf' -All)) {
            $fontId = 0; [int]::TryParse((Get-XAttr -Element $xf -Namespace $null -LocalName 'fontId'), [ref] $fontId) | Out-Null
            $fillId = 0; [int]::TryParse((Get-XAttr -Element $xf -Namespace $null -LocalName 'fillId'), [ref] $fillId) | Out-Null
            $cellXfs.Add([pscustomobject]@{ FontId = $fontId; FillId = $fillId }) | Out-Null
        }
    }

    if ($cellXfs.Count -eq 0) { return $issues }

    $cName = [System.Xml.Linq.XName]::Get('c', $NS.s)
    foreach ($wsPart in $WorkbookPart.WorksheetParts) {
        $wsDoc = Get-PartXDocument -Part $wsPart
        if ($null -eq $wsDoc -or $null -eq $wsDoc.Root) { continue }
        $sheetName = Get-WorksheetDisplayName -WorkbookPart $WorkbookPart -WorksheetPart $wsPart -SheetMap $SheetMap

        foreach ($cell in $wsDoc.Root.Descendants($cName)) {
            $sAttr = $cell.Attribute([System.Xml.Linq.XName]::Get('s'))
            if ($null -eq $sAttr) { continue }

            # Skip cells with no displayable text. Empty cells render nothing,
            # so contrast is meaningless and would produce noise on large sheets
            # with widespread fill formatting.
            $hasContent = $false
            foreach ($child in $cell.Elements()) {
                $ln = $child.Name.LocalName
                if ($ln -eq 'v' -or $ln -eq 'f' -or $ln -eq 'is') { $hasContent = $true; break }
            }
            if (-not $hasContent) { continue }

            $sIdx = -1
            if (-not [int]::TryParse($sAttr.Value, [ref] $sIdx)) { continue }
            if ($sIdx -lt 0 -or $sIdx -ge $cellXfs.Count) { continue }
            $xf = $cellXfs[$sIdx]
            if ($xf.FontId -lt 0 -or $xf.FontId -ge $fontColors.Count) { continue }
            if ($xf.FillId -lt 0 -or $xf.FillId -ge $fillColors.Count) { continue }
            $fg = $fontColors[$xf.FontId]
            $bg = $fillColors[$xf.FillId]
            if ([string]::IsNullOrEmpty($fg) -or [string]::IsNullOrEmpty($bg)) { continue }

            $cellRef = Get-XAttr -Element $cell -Namespace $null -LocalName 'r'
            if ([string]::IsNullOrEmpty($cellRef)) { $cellRef = '(unknown)' }
            $ratio = Get-ContrastRatio -ForegroundHex $fg -BackgroundHex $bg
            if ($ratio -lt 4.5) {
                $issues += New-Issue -Severity 'WARNING' -RuleName 'LowContrast' `
                    -Description ("Cell {0} on sheet `"{1}`" has contrast {2:N2}:1 (#{3} on #{4}); WCAG requires 4.5:1" -f $cellRef, $sheetName, $ratio, $fg.ToUpper(), $bg.ToUpper())
            }
        }
    }

    return $issues
}

#--------------------------------------------------------------------
# Autofix helpers
#--------------------------------------------------------------------
# See check-docx-accessibility.ps1 for the full HSL/contrast notes — the
# helpers are duplicated rather than shared because each checker is meant
# to be runnable on its own.

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' }
}

# Removes headerRowCount="0" from a single TableDefinitionPart's table element.
# Excel defaults headerRowCount to 1 when the attribute is absent, so deleting
# it is the correct way to re-enable a header row. Returns 1 if it changed
# anything, else 0.
function Repair-XlsxTableHeader {
    param($TableDefinitionPart)
    $doc = Get-PartXDocument -Part $TableDefinitionPart
    if ($null -eq $doc -or $null -eq $doc.Root) { return 0 }
    $tableEl = $doc.Root
    $hrcAttr = $tableEl.Attribute([System.Xml.Linq.XName]::Get('headerRowCount'))
    if ($null -eq $hrcAttr -or $hrcAttr.Value -ne '0') { return 0 }
    $hrcAttr.Remove()
    Save-PartXDocument -Part $TableDefinitionPart -Document $doc
    return 1
}

# Rewrite [Red]-only negative format codes to add a leading minus to the
# negative section. We deliberately do not strip [Red] (the user picked that
# styling); we just augment the format with a sign so the value is also
# distinguishable for color-blind users. Returns the count of numFmt entries
# rewritten in this stylesheet.
function Repair-XlsxRedOnlyNumberFormat {
    param($WorkbookStylesPart,
          [System.Collections.Generic.HashSet[string]] $UsedNumFmtIds)
    if ($null -eq $WorkbookStylesPart) { return 0 }
    $doc = Get-PartXDocument -Part $WorkbookStylesPart
    if ($null -eq $doc -or $null -eq $doc.Root) { return 0 }
    $numFmtsEl = Get-ChildElement -Parent $doc.Root -Namespace $NS.s -LocalName 'numFmts'
    if ($null -eq $numFmtsEl) { return 0 }

    $fixed = 0
    foreach ($numFmt in (Get-ChildElement -Parent $numFmtsEl -Namespace $NS.s -LocalName 'numFmt' -All)) {
        $numFmtId = Get-XAttr -Element $numFmt -Namespace $null -LocalName 'numFmtId'
        if ($UsedNumFmtIds -and -not [string]::IsNullOrEmpty($numFmtId) -and -not $UsedNumFmtIds.Contains($numFmtId)) { continue }
        $code = Get-XAttr -Element $numFmt -Namespace $null -LocalName 'formatCode'
        if ([string]::IsNullOrEmpty($code)) { continue }
        $sections = $code -split ';'
        if ($sections.Count -lt 2) { continue }
        $negative = $sections[1]
        if ([string]::IsNullOrEmpty($negative)) { continue }
        if ($negative -notmatch '(?i)\[Red\]') { continue }
        $stripped = $negative -replace '(?i)\[Red\]', ''
        if ($stripped -match '(?i)\[(Black|Blue|Cyan|Green|Magenta|White|Yellow|Color\s*\d+)\]') { continue }
        if ($stripped.Contains('(') -or $stripped.Contains(')')) { continue }
        $strippedNoBrackets = $stripped -replace '\[[^\]]*\]', ''
        if ($strippedNoBrackets.TrimStart() -match '^\s*-') { continue }

        # Insert a literal '-' immediately after the [Red] marker (or any
        # other leading bracket-conditional). regex captures [Red] at start
        # of the section, and any preceding [<...>] conditionals.
        $newNegative = $negative -replace '(?i)^(\s*(?:\[[^\]]*\]\s*)*)\[Red\]', '${1}[Red]-'
        if ($newNegative -eq $negative) { continue }
        $sections[1] = $newNegative
        $newCode = $sections -join ';'
        $numFmt.SetAttributeValue([System.Xml.Linq.XName]::Get('formatCode'), $newCode)
        $fixed++
    }

    if ($fixed -gt 0) { Save-PartXDocument -Part $WorkbookStylesPart -Document $doc }
    return $fixed
}

# Walk every text cell, resolve the cellXf -> font/fill chain, and for each
# cell whose contrast falls below 4.5:1 create (or reuse) a font with the
# nearest passing color and rebind the cellXf to it. Cells whose color comes
# from theme/indexed/auto, or whose fill is non-solid, are skipped — same
# bar the rule uses.
function Repair-XlsxLowContrast {
    param($WorkbookPart)
    if ($null -eq $WorkbookPart -or $null -eq $WorkbookPart.WorkbookStylesPart) { return 0 }
    $stylesPart = $WorkbookPart.WorkbookStylesPart
    $stylesDoc  = Get-PartXDocument -Part $stylesPart
    if ($null -eq $stylesDoc -or $null -eq $stylesDoc.Root) { return 0 }

    $fontsEl   = Get-ChildElement -Parent $stylesDoc.Root -Namespace $NS.s -LocalName 'fonts'
    $fillsEl   = Get-ChildElement -Parent $stylesDoc.Root -Namespace $NS.s -LocalName 'fills'
    $cellXfsEl = Get-ChildElement -Parent $stylesDoc.Root -Namespace $NS.s -LocalName 'cellXfs'
    if ($null -eq $fontsEl -or $null -eq $cellXfsEl) { return 0 }

    # Get-ChildElement -All returns its array via the unary-comma trick so that
    # a single-result call still iterates correctly. Wrapping with @() here would
    # double-wrap (count collapses to 1), so we assign the bare result.
    $fontEls = Get-ChildElement -Parent $fontsEl -Namespace $NS.s -LocalName 'font' -All
    $fillEls = if ($fillsEl) { Get-ChildElement -Parent $fillsEl -Namespace $NS.s -LocalName 'fill' -All } else { @() }
    $xfEls   = Get-ChildElement -Parent $cellXfsEl -Namespace $NS.s -LocalName 'xf' -All

    # Resolve fill colors once.
    $fillHex = New-Object 'System.Collections.Generic.List[object]'
    foreach ($fill in $fillEls) {
        $hex = $null
        $pf = Get-ChildElement -Parent $fill -Namespace $NS.s -LocalName 'patternFill'
        if ($pf -and (Get-XAttr -Element $pf -Namespace $null -LocalName 'patternType') -eq 'solid') {
            $fgColorEl = Get-ChildElement -Parent $pf -Namespace $NS.s -LocalName 'fgColor'
            $hex = Get-ExcelExplicitColorHex $fgColorEl
        }
        $fillHex.Add($hex) | Out-Null
    }

    # Track desired color overrides: original FontId -> (hex(BG) -> new FontId).
    # Each unique (font, bg) pair gets one synthesised font; cellXfs that share
    # the pair share the new font.
    $newFontByPair = @{}
    $fixed = 0

    $cName = [System.Xml.Linq.XName]::Get('c', $NS.s)
    foreach ($wsPart in $WorkbookPart.WorksheetParts) {
        $wsDoc = Get-PartXDocument -Part $wsPart
        if ($null -eq $wsDoc -or $null -eq $wsDoc.Root) { continue }

        $wsChanged = $false
        foreach ($cell in $wsDoc.Root.Descendants($cName)) {
            $sAttr = $cell.Attribute([System.Xml.Linq.XName]::Get('s'))
            if ($null -eq $sAttr) { continue }
            $hasContent = $false
            foreach ($child in $cell.Elements()) {
                $ln = $child.Name.LocalName
                if ($ln -eq 'v' -or $ln -eq 'f' -or $ln -eq 'is') { $hasContent = $true; break }
            }
            if (-not $hasContent) { continue }
            $sIdx = -1
            if (-not [int]::TryParse($sAttr.Value, [ref] $sIdx)) { continue }
            if ($sIdx -lt 0 -or $sIdx -ge $xfEls.Count) { continue }

            $xf = $xfEls[$sIdx]
            $fontId = 0; [int]::TryParse((Get-XAttr -Element $xf -Namespace $null -LocalName 'fontId'), [ref] $fontId) | Out-Null
            $fillId = 0; [int]::TryParse((Get-XAttr -Element $xf -Namespace $null -LocalName 'fillId'), [ref] $fillId) | Out-Null
            if ($fontId -lt 0 -or $fontId -ge $fontEls.Count) { continue }
            if ($fillId -lt 0 -or $fillId -ge $fillHex.Count) { continue }
            $fontEl = $fontEls[$fontId]
            $fontColorEl = Get-ChildElement -Parent $fontEl -Namespace $NS.s -LocalName 'color'
            $fg = Get-ExcelExplicitColorHex $fontColorEl
            $bg = $fillHex[$fillId]
            if ([string]::IsNullOrEmpty($fg) -or [string]::IsNullOrEmpty($bg)) { continue }
            if ((Get-ContrastRatio -ForegroundHex $fg -BackgroundHex $bg) -ge 4.5) { continue }

            $newFg = Get-NearestPassingForeground -ForegroundHex $fg -BackgroundHex $bg -Threshold 4.5
            if (-not $newFg -or $newFg -eq $fg.ToUpper()) { continue }

            $pairKey = "{0}|{1}" -f $fontId, $bg.ToUpper()
            if (-not $newFontByPair.ContainsKey($pairKey)) {
                # Clone the original font, set the new color, append, and
                # record its new index.
                $cloned = New-Object System.Xml.Linq.XElement -ArgumentList $fontEl
                $clonedColor = Get-ChildElement -Parent $cloned -Namespace $NS.s -LocalName 'color'
                if ($clonedColor) {
                    foreach ($attrName in @('theme','indexed','auto','rgb','tint')) {
                        $a = $clonedColor.Attribute([System.Xml.Linq.XName]::Get($attrName))
                        if ($a) { $a.Remove() }
                    }
                    $clonedColor.SetAttributeValue([System.Xml.Linq.XName]::Get('rgb'), 'FF' + $newFg)
                } else {
                    $newColor = New-Object System.Xml.Linq.XElement -ArgumentList ([System.Xml.Linq.XName]::Get('color', $NS.s))
                    $newColor.SetAttributeValue([System.Xml.Linq.XName]::Get('rgb'), 'FF' + $newFg)
                    $cloned.Add($newColor)
                }
                $fontsEl.Add($cloned)
                $newFontByPair[$pairKey] = $fontEls.Count
                $fontEls += ,$cloned
            }

            # Clone the cellXf, point at the new font, append, and rebind the cell.
            $newFontId = $newFontByPair[$pairKey]
            $clonedXfKey = "$sIdx|$newFontId"
            $clonedXfIdx = $null
            if ($newFontByPair.ContainsKey("xf:$clonedXfKey")) {
                $clonedXfIdx = $newFontByPair["xf:$clonedXfKey"]
            } else {
                $clonedXf = New-Object System.Xml.Linq.XElement -ArgumentList $xf
                $clonedXf.SetAttributeValue([System.Xml.Linq.XName]::Get('fontId'), $newFontId)
                $clonedXf.SetAttributeValue([System.Xml.Linq.XName]::Get('applyFont'), '1')
                $cellXfsEl.Add($clonedXf)
                $clonedXfIdx = $xfEls.Count
                $xfEls += ,$clonedXf
                $newFontByPair["xf:$clonedXfKey"] = $clonedXfIdx
            }
            $sAttr.Value = "$clonedXfIdx"
            $fixed++
            $wsChanged = $true
        }
        if ($wsChanged) { Save-PartXDocument -Part $wsPart -Document $wsDoc }
    }

    if ($fixed -gt 0) {
        # Update count attributes on <fonts> / <cellXfs> if they exist.
        $fontsCount = $fontsEl.Attribute([System.Xml.Linq.XName]::Get('count'))
        if ($fontsCount) { $fontsCount.Value = "$($fontEls.Count)" }
        $xfCount = $cellXfsEl.Attribute([System.Xml.Linq.XName]::Get('count'))
        if ($xfCount) { $xfCount.Value = "$($xfEls.Count)" }
        Save-PartXDocument -Part $stylesPart -Document $stylesDoc
    }
    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.SpreadsheetDocument]::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 workbook: $($_.Exception.Message)")
            exit 2
        }
        $issues.Add((New-Issue -Severity 'ERROR' -RuleName 'DocumentProtected' `
            -Description 'Workbook is IRM- or password-protected')) | Out-Null
    }

    if ($null -ne $doc) {
        $wbPart = $doc.WorkbookPart
        if ($null -eq $wbPart) {
            [Console]::Error.WriteLine("Workbook part missing — file may be corrupt.")
            exit 2
        }

        $sheetMap = Get-SheetNameMap -WorkbookPart $wbPart

        # Workbook-level rules
        foreach ($i in (Test-SheetTabName -WorkbookPart $wbPart)) { $issues.Add($i) | Out-Null }
        $usedNumFmtIds = Get-UsedNumFmtId -WorkbookPart $wbPart
        foreach ($i in (Test-RedOnlyNumberFormat -WorkbookStylesPart $wbPart.WorkbookStylesPart -UsedNumFmtIds $usedNumFmtIds)) {
            $issues.Add($i) | Out-Null
        }
        foreach ($i in (Test-LowContrast -WorkbookPart $wbPart -SheetMap $sheetMap)) {
            $issues.Add($i) | Out-Null
        }

        # Per-worksheet rules
        foreach ($wsPart in $wbPart.WorksheetParts) {
            $sheetName = Get-WorksheetDisplayName -WorkbookPart $wbPart -WorksheetPart $wsPart -SheetMap $sheetMap

            foreach ($i in (Test-MergedCell -WorksheetPart $wsPart -SheetName $sheetName)) {
                $issues.Add($i) | Out-Null
            }

            # Drawings on this worksheet
            $drawingsPart = $wsPart.DrawingsPart
            if ($null -ne $drawingsPart) {
                foreach ($i in (Test-AltText -DrawingsPart $drawingsPart -SheetName $sheetName)) {
                    $issues.Add($i) | Out-Null
                }
            }

            # Tables on this worksheet
            foreach ($tdp in $wsPart.TableDefinitionParts) {
                foreach ($i in (Test-TableDefinition -TableDefinitionPart $tdp)) {
                    $issues.Add($i) | Out-Null
                }
            }
        }
    }
}
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) {
        # TIPs only emit in detailed mode (text mode skips them entirely;
        # but in detailed mode all issues — including TIPs — appear).
        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', 'RedOnlyNegativeFormatting', '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; RedOnlyNegativeFormatting = 0; LowContrast = 0 }
$fixDoc = $null
try {
    try {
        $fixDoc = [DocumentFormat.OpenXml.Packaging.SpreadsheetDocument]::Open($fixedPath, $true)
    } catch {
        [Console]::Error.WriteLine("Failed to open fixed copy '$fixedPath' for editing: $($_.Exception.Message)")
        exit 2
    }
    $wbPartFix = $fixDoc.WorkbookPart
    if ($null -ne $wbPartFix) {
        foreach ($wsPart in $wbPartFix.WorksheetParts) {
            foreach ($tdp in $wsPart.TableDefinitionParts) {
                $fixCounts.MissingTableHeaders += (Repair-XlsxTableHeader -TableDefinitionPart $tdp)
            }
        }
        $fixCounts.RedOnlyNegativeFormatting = Repair-XlsxRedOnlyNumberFormat `
            -WorkbookStylesPart $wbPartFix.WorkbookStylesPart `
            -UsedNumFmtIds (Get-UsedNumFmtId -WorkbookPart $wbPartFix)
        $fixCounts.LowContrast = Repair-XlsxLowContrast -WorkbookPart $wbPartFix
    }
}
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