Private/ConvertTo-VellumColor.ps1

function ConvertTo-VellumColor {
    <#
    .SYNOPSIS
        Normalises a colour value to a [double[3]] of 0..1 RGB components.
    .DESCRIPTION
        Internal helper so every public colour parameter (-Color, -BorderColor,
        -HeaderBackground, -AlternateRowBackground, and rich-cell backgrounds)
        accepts the same set of forms:

          - an R,G,B array of three numbers in 0..1 (the original contract);
          - a hex string '#RRGGBB', 'RRGGBB', '#RGB', or 'RGB';
          - a known colour name (the 16 HTML basic colours plus a few aliases).

        Returns the normalised triple, or $null when the input is $null (callers
        treat $null as "not specified"). Throws a clear error on anything else.
        Named colours are kept to a small curated table on purpose: System.Drawing
        colour parsing is not dependable cross-platform on .NET 10.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Pure conversion; no state change.')]
    [CmdletBinding()]
    [OutputType([double[]])]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [object]$Color
    )

    if ($null -eq $Color) { return $null }

    # Already an array of components.
    if ($Color -is [System.Array]) {
        $vals = @($Color)
        if ($vals.Count -ne 3) {
            throw "ConvertTo-VellumColor: an RGB array must have exactly 3 components; got $($vals.Count)."
        }
        $triple = foreach ($v in $vals) {
            # Cast straight to [double] (PowerShell converts numbers and numeric
            # strings invariantly). Avoid [string]+TryParse: that round-trips
            # through the current culture and misreads '0.2' as 2 where '.' is a
            # thousands separator.
            try { $d = [double]$v }
            catch { throw "ConvertTo-VellumColor: RGB component '$v' is not a number." }
            if ($d -lt 0 -or $d -gt 1) {
                throw "ConvertTo-VellumColor: RGB components must be between 0 and 1; got $d."
            }
            $d
        }
        return [double[]]$triple
    }

    $text = ([string]$Color).Trim()

    # Hex: #RRGGBB / RRGGBB, or shorthand #RGB / RGB.
    $hex = $text.TrimStart('#')
    if ($hex -match '^[0-9a-fA-F]{6}$') {
        return [double[]]@(
            [Convert]::ToInt32($hex.Substring(0, 2), 16) / 255.0
            [Convert]::ToInt32($hex.Substring(2, 2), 16) / 255.0
            [Convert]::ToInt32($hex.Substring(4, 2), 16) / 255.0
        )
    }
    if ($hex -match '^[0-9a-fA-F]{3}$') {
        # Each nibble doubles: 'abc' -> 'aabbcc'.
        return [double[]]@(
            [Convert]::ToInt32("$($hex[0])$($hex[0])", 16) / 255.0
            [Convert]::ToInt32("$($hex[1])$($hex[1])", 16) / 255.0
            [Convert]::ToInt32("$($hex[2])$($hex[2])", 16) / 255.0
        )
    }

    # Curated named colours (HTML basic 16 plus common aliases).
    $named = @{
        black = @(0, 0, 0);            white  = @(1, 1, 1)
        red   = @(1, 0, 0);            lime   = @(0, 1, 0)
        green = @(0, 0.5, 0);          blue   = @(0, 0, 1)
        yellow = @(1, 1, 0);           cyan   = @(0, 1, 1)
        aqua  = @(0, 1, 1);            magenta = @(1, 0, 1)
        fuchsia = @(1, 0, 1);          silver = @(0.7529, 0.7529, 0.7529)
        gray  = @(0.5, 0.5, 0.5);      grey   = @(0.5, 0.5, 0.5)
        maroon = @(0.5, 0, 0);         olive  = @(0.5, 0.5, 0)
        navy  = @(0, 0, 0.5);          teal   = @(0, 0.5, 0.5)
        purple = @(0.5, 0, 0.5);       orange = @(1, 0.6471, 0)
    }
    $key = $text.ToLowerInvariant()
    if ($named.ContainsKey($key)) {
        return [double[]]$named[$key]
    }

    throw ("ConvertTo-VellumColor: unrecognised colour '$Color'. Use an R,G,B array of three " +
        "numbers in 0..1, a hex string like '#3366cc' or '#36c', or a known name " +
        '(black, white, red, lime, green, blue, yellow, cyan, magenta, silver, gray, ' +
        'maroon, olive, navy, teal, purple, orange).')
}