Public/Add-VellumPdfTable.ps1

function Add-VellumPdfTable {
    <#
    .SYNOPSIS
        Adds a table to a VellumPdf document.
    .DESCRIPTION
        Wraps Document.Add(TableElement). Builds a TableElement from row data, an
        optional header row, optional column widths, border and row styling, and a
        default cell text style. The document flows through the pipeline for
        chaining with other Add-VellumPdf* functions.

        -Row accepts two shapes (the first row decides which):
          - Records: PSCustomObject rows straight from Import-Csv or Select-Object,
            or one [ordered] dictionary per row. Columns are the property/key names,
            in the first row's order, and a header is derived from them when -Header
            is omitted. A plain @{} hashtable with more than one key is refused: it
            has no fixed key order, so the columns would come out shuffled. Write
            [ordered]@{ ... } or [pscustomobject]@{ ... } (a one-key hashtable is fine).
          - Cell arrays: one array per row. Each element is a scalar (added as
            text) or a rich-cell hashtable for that one cell:
              @{ Text = 'Total'; ColSpan = 2; Alignment = 'Right';
                 Background = '#eeeeee'; Font = 'HelveticaBold'; FontSize = 11;
                 Color = 'navy' }
            Text is required; the rest are optional. More keys: FontHandle (an
            embedded font for this cell), Leading, Padding (1, 2 or 4 numbers),
            Language (BCP 47 tag, tagged documents only), Format (a .NET format string for
            this cell) and Parse (set $false to draw a string as given). A LinkUri key is
            refused because VellumPdf ignores hyperlinks in table cells.
          - The same keys work in -Header entries.

        Values become text with a culture and an optional format. A string cell is drawn as given unless it
        consists only of a number; any other value (a number, a date, ...) is converted with -Culture (the
        document's -Culture, invariant unless set) and the column's -ColumnFormat or the cell's own Format
        key (a .NET format string such as 'N2' or 'yyyy-MM-dd'). A rich cell may carry a Format key, which
        wins over -ColumnFormat, and Parse = $false to draw its string exactly as given.

        Numbers in strings are always recognised (switch it off with -NoNumberParsing): a string cell (never
        a header cell) that is only a number is read as [decimal], keeping its decimals, and then drawn like
        any other number, so with the invariant default
        '1.444,50' becomes 1444.50, '1,976.08' becomes 1976.08 and '55433.34' stays 55433.34. The
        separators are detected from the text:
          1. With both '.' and ',' the LAST one is the decimal mark (1.444,50 and 1,976.08).
          2. A separator that appears more than once is grouping (1.234.567).
          3. A separator that appears once is a decimal mark (55433.34, 66443,85, 777544434,7755), except
             that exactly three digits after it with at most three digits before it is ambiguous (1,234
             and 1.234: with more than three digits before it, as in 12345,678, it is a decimal mark).
          4. Grouping must be regular: 1-3 digits, then groups of exactly 3 (1.2.3 and the IP-like 10.0.0.1
             stay text).
          5. Not a number, so left as is: a leading zero in a multi-digit integer part (007, 01234),
             spaces, currency symbols, '%', exponents, an empty string.
          6. An ambiguous number takes the convention of the other values in the same column
             ('1.444,50' above shows that '.' groups); without such evidence it is left unchanged and
             ONE warning per call names the values.
        -InputCulture switches detection off: every string is read strictly with that culture's separators
        (de-DE: '1.234' is 1234 and '55433.34' is text). -NoNumberParsing draws every string as given
        (codes, ids, phone numbers).

        Colour parameters (-BorderColor, -HeaderBackground, -AlternateRowBackground,
        and a cell's Background/Color) accept an R,G,B triple in 0..1, a hex string
        ('#3366cc'/'#36c'), or a colour name.

        NOTE: with cell-array rows, a SINGLE row needs the unary comma operator so
        PowerShell does not flatten the outer array: -Row @(,@('Cell1','Cell2')).
        A flat array like -Row @('a','b') is treated as two one-cell rows. Records
        do not need this.

        -MarginTop and -MarginBottom apply spacing above and below the table
        without affecting the left/right margins already set on the element.

        Import-Csv example:
            Import-Csv data.csv | ForEach-Object { $rows += $_ }
            Add-VellumPdfTable -Row (Import-Csv data.csv)
    .PARAMETER Document
        The live VellumPdf document flowing through the pipeline. The same
        instance is returned after the table is added, enabling chaining.
    .PARAMETER Header
        Optional column header entries: strings, or rich-cell hashtables with the
        same keys as a data cell. For several header rows pass an array per row, e.g.
        -Header @('Region', @{ Text = 'Sales'; ColSpan = 2 }), @('', 'Q1', 'Q2'); the
        leading header rows repeat on every page. When supplied, a header row is prepended to the
        table via AddHeaderRow(). The count of header cells determines the
        expected column count for -ColumnWidth mismatch warnings.
    .PARAMETER Row
        The table rows. Either PSCustomObject/[ordered] records (columns are the
        property/key names; header derived when -Header is omitted), or one array
        per row whose elements are scalars or rich-cell hashtables (see the
        description). For a single cell-array row, use the unary comma operator to
        prevent PowerShell from flattening the outer array: -Row @(,@('a','b')).
        May be empty (or $null) when -Header is given: the result is a header-only
        table (drawn as ordinary rows, because the engine cannot lay out a table
        of header rows alone, so a tagged document does not mark them as header
        cells). An empty -Row without -Header is an error.
    .PARAMETER ColumnWidth
        Column widths in points, each between 0 and 100000. A width of 0 means
        "auto": the column shares the remaining space. The count should
        match the number of columns determined by the -Header or first -Row; a
        mismatch emits a warning and extra widths are ignored.
    .PARAMETER FontHandle
        An embedded TrueType font from Register-VellumPdfFont, used as the table's
        default cell font. Takes precedence over -Font. Needed for text outside
        Windows-1252 and for PDF/A documents. A cell may override it with its own
        FontHandle key.
    .PARAMETER CellPadding
        Default padding for every cell, in points: one number (all sides), two
        (top/bottom, left/right) or four (top, right, bottom, left). A cell's own
        Padding key wins. When omitted the engine default (4, 6, 4, 6) applies.
    .PARAMETER BorderWidth
        Border line width in points applied to all cell borders, between 0 and
        100. When omitted the VellumPdf library default is used.
    .PARAMETER BorderColor
        Border line colour: an R,G,B triple in 0..1, a hex string ('#3366cc'),
        or a colour name. When omitted the library default border colour is used.
    .PARAMETER HeaderBackground
        Background fill colour for the header row (R,G,B triple, hex, or name).
        Applied only when a header is present (supplied via -Header or derived
        from record columns).
    .PARAMETER AlternateRowBackground
        Background fill colour applied to every second data row (zebra striping):
        an R,G,B triple in 0..1, a hex string, or a colour name.
    .PARAMETER ColumnAlignment
        Per-column horizontal alignment by column index (Left/Center/Right/
        Justify), overriding -Alignment for those columns. A cell's own
        Alignment key still wins over this.
    .PARAMETER ColumnFormat
        A .NET format string per column, parallel to -ColumnAlignment (by grid column index, a $null or ''
        entry leaves a column on the type's default): -ColumnFormat '', 'N2', 'yyyy-MM-dd'. Applied to numbers
        and dates (and to numbers read from strings) with -Culture; text is never changed. A cell's own
        Format key wins. A format the value's type rejects is an error naming the column.
    .PARAMETER Culture
        The culture used to turn numbers and dates into text: a name ('de-DE') or a [cultureinfo]
        (-Culture (Get-Culture) matches the console). Default: the document's -Culture, which is the
        invariant culture unless New-VellumPdfDocument/New-VellumPdfPageDocument set one, so the output is
        the same on every machine. An unknown name is an error.
    .PARAMETER InputCulture
        Read number strings strictly with this culture (name or [cultureinfo]) instead of detecting the
        separators. Default: the document's -InputCulture, else detection. Pass $null to switch the
        document's -InputCulture off for this call.
    .PARAMETER NoNumberParsing
        Draw every string exactly as given instead of reading numbers from it. A single cell can do the same
        with the rich-cell key Parse = $false.
    .PARAMETER Font
        A base-14 font name applied as the default cell style for all data
        cells. When omitted the document default font is used.
    .PARAMETER FontSize
        Font size in points for all data cells, between 1 and 1000. When
        omitted the document default size is used.
    .PARAMETER Alignment
        Horizontal text alignment for all cells (header and data). Accepts
        Left, Center, Right, or Justify. Defaults to Left.
    .PARAMETER MarginTop
        Extra spacing in points above the table element. Does not affect the
        left/right page margins.
    .PARAMETER MarginBottom
        Extra spacing in points below the table element. Does not affect the
        left/right page margins.
    .EXAMPLE
        $headers = @('Name', 'Score', 'Grade')
        $rows = @(
            @('Alice', '95', 'A'),
            @('Bob', '82', 'B')
        )
        New-VellumPdfDocument |
            Add-VellumPdfTable -Header $headers -Row $rows -BorderWidth 0.5 |
            Save-VellumPdfDocument -Path ./report.pdf
    .EXAMPLE
        $doc | Add-VellumPdfTable -Row @(@('Cell1','Cell2')) `
               -ColumnWidth @(100, 200) -Font Helvetica -FontSize 10 -Alignment Center
    .EXAMPLE
        # Numbers in strings are read, dates and numbers use the culture and the column formats
        $rows = @(
            @('Widget', '1.444,50', [datetime]'2026-03-05'),
            @('Gadget', '55433.34', [datetime]'2026-12-25'))
        New-VellumPdfDocument |
            Add-VellumPdfTable -Header 'Item', 'Amount', 'Date' -Row $rows -Culture de-DE `
                -ColumnFormat '', 'N2', 'd' -ColumnAlignment Left, Right, Right |
            Save-VellumPdfDocument -Path ./amounts.pdf
    .OUTPUTS
        VellumPdf.Layout.Document (the same instance, for chaining)
    #>

    [CmdletBinding()]
    [OutputType([VellumPdf.Layout.Document])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VellumPdf.Layout.Document]$Document,

        # Header labels. Each entry is a string or a rich-cell hashtable (same
        # keys as a data cell), e.g. @{ Text = 'Total'; Alignment = 'Right' }.
        [object[]]$Header,

        [Parameter(Mandatory)]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]]$Row,

        [ValidateRange(0.0, 100000.0)]
        [double[]]$ColumnWidth,

        [ValidateRange(0.0, 100.0)]
        [double]$BorderWidth,

        # RGB triple (0..1), hex ('#3366cc'/'#36c'), or a colour name.
        [object]$BorderColor,

        # Background for the header row. RGB triple, hex, or a colour name.
        [object]$HeaderBackground,

        # Background applied to every second data row (zebra striping). RGB
        # triple, hex, or a colour name.
        [object]$AlternateRowBackground,

        # Per-column horizontal alignment, by column index, overriding
        # -Alignment for that column. Shorter than the column count leaves the
        # remaining columns on -Alignment.
        [ValidateSet('Left', 'Center', 'Right', 'Justify')]
        [string[]]$ColumnAlignment,

        # Per-column .NET format string for values that are not text ('N2', 'yyyy-MM-dd'), by column
        # index, parallel to -ColumnAlignment; $null or '' leaves a column on the type's default.
        [AllowNull()]
        [AllowEmptyString()]
        [string[]]$ColumnFormat,

        # Culture used to turn dates and numbers into text; a name or a [cultureinfo].
        # Default: the document's -Culture (invariant unless set).
        [object]$Culture,

        # Culture used to read numbers given as strings (strict, no detection); $null switches the
        # document's -InputCulture off for this call. Default: the document's -InputCulture, else detection.
        [AllowNull()]
        [object]$InputCulture,

        # Draw strings exactly as given instead of reading numbers from them.
        [switch]$NoNumberParsing,

        [ValidateSet('Courier', 'CourierBold', 'CourierBoldOblique', 'CourierOblique',
            'Helvetica', 'HelveticaBold', 'HelveticaBoldOblique', 'HelveticaOblique',
            'Symbol', 'TimesBold', 'TimesBoldItalic', 'TimesItalic', 'TimesRoman', 'ZapfDingbats')]
        [string]$Font,

        [ValidateRange(1.0, 1000.0)]
        [double]$FontSize,

        # An embedded TrueType font from Register-VellumPdfFont. Takes precedence
        # over -Font; required for Unicode text and for PDF/A documents.
        [VellumPdf.Fonts.EmbeddedFontHandle]$FontHandle,

        # Default padding for every cell: one number (all sides), two (top/bottom,
        # left/right) or four (top, right, bottom, left), in points. A cell's own
        # Padding key wins. The engine default is 4,6,4,6.
        [object]$CellPadding,

        [ValidateSet('Left', 'Center', 'Right', 'Justify')]
        [string]$Alignment = 'Left',

        [ValidateRange(0.0, 10000.0)]
        [double]$MarginTop,

        [ValidateRange(0.0, 10000.0)]
        [double]$MarginBottom
    )

    process {
        try {
            Assert-VellumPdfDocumentOpen -Document $Document -CommandName 'Add-VellumPdfTable'
            $cultures = Resolve-VellumPdfCulture -Document $Document -Culture $Culture -CultureBound:$PSBoundParameters.ContainsKey('Culture') `
                -InputCulture $InputCulture -InputCultureBound:$PSBoundParameters.ContainsKey('InputCulture') -CommandName 'Add-VellumPdfTable'
            if ($FontHandle) {
                Assert-VellumPdfFontHandle -FontHandle $FontHandle -Document $Document -CommandName 'Add-VellumPdfTable'
            }
            # An empty -Row is a header-only table; without a -Header there would be nothing to draw.
            if ($null -eq $Row) { $Row = @() }
            if ($Row.Count -eq 0 -and ($null -eq $Header -or $Header.Count -eq 0)) {
                throw 'Add-VellumPdfTable: -Row is empty and no -Header was given; supply at least one row, or a -Header for a header-only table.'
            }
            $tablePadding = if ($PSBoundParameters.ContainsKey('CellPadding')) {
                ConvertTo-VellumInsets -Value $CellPadding -What 'Add-VellumPdfTable: -CellPadding'
            }

            # Two row shapes are accepted:
            # - records: PSCustomObject (e.g. from Import-Csv) or a hashtable per
            # row. Columns are the property/key names; values are read by name.
            # - cell arrays: an array per row, each element a scalar or a rich-cell
            # hashtable (@{ Text=...; ColSpan=...; Background=...; Alignment=... }).
            # The first row decides the mode.
            $isRecord = {
                param($r)
                ($r -is [System.Management.Automation.PSCustomObject]) -or ($r -is [System.Collections.IDictionary])
            }
            $recordMode = $Row.Count -gt 0 -and (& $isRecord $Row[0])

            # All rows must be the same shape. Mixing records and cell-arrays would
            # otherwise render silently-wrong output (an object stringified into one
            # cell, or array cells read by property name as all-empty).
            for ($i = 1; $i -lt $Row.Count; $i++) {
                if ((& $isRecord $Row[$i]) -ne $recordMode) {
                    throw ('Add-VellumPdfTable: -Row mixes record rows (PSCustomObject/hashtable) and ' +
                        "cell-array rows; row $i does not match the shape of the first row. Use one shape " +
                        'for all rows.')
                }
            }

            # Resolve a single cell value to a spec hashtable carrying at least Text.
            $toSpec = {
                param($value)
                if ($value -is [System.Collections.IDictionary]) {
                    if (-not $value.Contains('Text')) {
                        throw "Add-VellumPdfTable: a rich-cell hashtable must include a 'Text' key."
                    }
                    # A copy: the text is converted in place below and the caller's hashtable must stay as it was.
                    $copy = @{}
                    foreach ($key in $value.Keys) { $copy[$key] = $value[$key] }
                    return $copy
                }
                return @{ Text = $value }
            }

            # Build the rows as arrays of cell specs, and the effective header cells.
            $headerLabels = $Header
            $specRows = [System.Collections.Generic.List[object]]::new()
            if ($recordMode) {
                # A plain hashtable enumerates its keys in no fixed order, so the columns would come out shuffled.
                if ($Row[0] -is [hashtable] -and $Row[0].Count -gt 1) {
                    throw ('Add-VellumPdfTable: a plain @{} hashtable row has no fixed key order, so its columns would come ' +
                        'out in an arbitrary order. Write each row as [ordered]@{ ... } or [pscustomobject]@{ ... }, or pass ' +
                        'cell-array rows (one array per row) together with -Header.')
                }
                $columns = if ($Row[0] -is [System.Collections.IDictionary]) {
                    @($Row[0].Keys)
                }
                else {
                    @($Row[0].PSObject.Properties.Name)
                }
                if (-not $headerLabels) { $headerLabels = [string[]]$columns }
                foreach ($rec in $Row) {
                    $cells = foreach ($col in $columns) {
                        $cellValue = Get-VellumPdfPropertyValue -InputObject $rec -Name $col
                        & $toSpec $cellValue
                    }
                    $specRows.Add(@($cells))
                }
            }
            else {
                foreach ($r in $Row) {
                    $cells = foreach ($v in @($r)) { & $toSpec $v }
                    $specRows.Add(@($cells))
                }
            }

            # -Header is one row of entries, or several rows when its entries are themselves arrays.
            $isHeaderRow = { param($e) $e -is [System.Collections.IEnumerable] -and $e -isnot [string] -and $e -isnot [System.Collections.IDictionary] }
            $headerRowSpecs = [System.Collections.Generic.List[object]]::new()
            if ($null -ne $headerLabels) {
                $nested = @(@($headerLabels) | Where-Object { & $isHeaderRow $_ }).Count
                if ($nested -gt 0 -and $nested -ne @($headerLabels).Count) {
                    throw 'Add-VellumPdfTable: -Header mixes plain entries and row arrays; give either one row of entries or several row arrays.'
                }
                if ($nested -gt 0) {
                    foreach ($hrow in $headerLabels) { $headerRowSpecs.Add(@(foreach ($h in @($hrow)) { & $toSpec $h })) }
                }
                else { $headerRowSpecs.Add(@(foreach ($h in @($headerLabels)) { & $toSpec $h })) }
            }
            $headerSpecs = if ($headerRowSpecs.Count) { @($headerRowSpecs | ForEach-Object { $_ }) } else { @() }   # every header cell, for text checks

            # Spans are read while the grid is laid out, before any cell is built: check them first.
            foreach ($spec in @($headerSpecs) + @($specRows | ForEach-Object { $_ })) {
                foreach ($spanKey in 'ColSpan', 'RowSpan') {
                    if ($null -eq $spec[$spanKey]) { continue }
                    $spanValue = $spec[$spanKey] -as [double]
                    if ($null -eq $spanValue -or -not [double]::IsFinite($spanValue) -or $spanValue -ne [Math]::Floor($spanValue) -or $spanValue -lt 1 -or $spanValue -gt 1000) {
                        throw "Add-VellumPdfTable: rich-cell $spanKey must be a whole number from 1 to 1000; got '$($spec[$spanKey])'."
                    }
                }
            }

            # Spans: a cell advances the grid column by its ColSpan, and a RowSpan reserves its columns
            # in the rows below, so -ColumnAlignment and the -ColumnWidth count follow the grid, not the cell count.
            $spanOf = {
                param($cellSpec, $key)
                if ($cellSpec -is [System.Collections.IDictionary] -and $null -ne $cellSpec[$key]) { [Math]::Max(1, [int]$cellSpec[$key]) } else { 1 }
            }
            $gridTaken = @{}
            $gridState = @{ Row = 0 }
            $placeRow = {
                param($rowSpecs)
                $column = 0
                $placed = foreach ($cellSpec in $rowSpecs) {
                    while ($gridTaken.ContainsKey("$($gridState.Row),$column")) { $column++ }
                    $column
                    $colSpan = & $spanOf $cellSpec 'ColSpan'
                    $rowSpan = & $spanOf $cellSpec 'RowSpan'
                    for ($dr = 0; $dr -lt $rowSpan; $dr++) { for ($dc = 0; $dc -lt $colSpan; $dc++) { $gridTaken["$($gridState.Row + $dr),$($column + $dc)"] = $true } }
                    $column += $colSpan
                }
                $gridState.Row++
                , @($placed)
            }

            # Grid column of every cell, header rows first (spans and RowSpans decide it); -ColumnFormat, -ColumnAlignment
            # and the number evidence of a column follow the grid, not the cell count.
            $headerPlacements = [System.Collections.Generic.List[object]]::new()
            foreach ($specsOfRow in $headerRowSpecs) { $headerPlacements.Add((& $placeRow $specsOfRow)) }
            $dataPlacements = [System.Collections.Generic.List[object]]::new()
            foreach ($specRow in $specRows) { $dataPlacements.Add((& $placeRow $specRow)) }

            # Values become text here: number detection, -ColumnFormat / a cell's Format key, and the culture.
            # Header cells are labels: never read as numbers.
            $slots = [System.Collections.Generic.List[object]]::new()
            $slotSpecs = [System.Collections.Generic.List[object]]::new()
            $cellFormat = {
                param($cellSpec, $gridColumn)
                if ($null -ne $cellSpec['Format']) { return [string]$cellSpec['Format'] }
                if ($gridColumn -ge 0 -and $ColumnFormat -and $gridColumn -lt $ColumnFormat.Count) { return $ColumnFormat[$gridColumn] }
            }
            for ($hr = 0; $hr -lt $headerRowSpecs.Count; $hr++) {
                for ($hc = 0; $hc -lt $headerRowSpecs[$hr].Count; $hc++) {
                    $cellSpec = $headerRowSpecs[$hr][$hc]
                    $headerFormat = if ($null -ne $cellSpec['Format']) { [string]$cellSpec['Format'] }
                    $slots.Add((New-VellumPdfTextSlot -Value $cellSpec['Text'] -Format $headerFormat -Parse $false -Group 'header' -What "header cell $($hc + 1)"))
                    $slotSpecs.Add($cellSpec)
                }
            }
            for ($dr = 0; $dr -lt $specRows.Count; $dr++) {
                for ($dc = 0; $dc -lt $specRows[$dr].Count; $dc++) {
                    $cellSpec = $specRows[$dr][$dc]
                    $gridColumn = $dataPlacements[$dr][$dc]
                    $parseCell = $null -eq $cellSpec['Parse'] -or [bool]$cellSpec['Parse']
                    $slots.Add((New-VellumPdfTextSlot -Value $cellSpec['Text'] -Format (& $cellFormat $cellSpec $gridColumn) -Parse $parseCell -Group $gridColumn -What "column $($gridColumn + 1)"))
                    $slotSpecs.Add($cellSpec)
                }
            }
            Convert-VellumPdfSlotText -Slot $slots.ToArray() -Culture $cultures.Culture -InputCulture $cultures.InputCulture `
                -NoNumberParsing:$NoNumberParsing -CommandName 'Add-VellumPdfTable'
            for ($si = 0; $si -lt $slots.Count; $si++) { $slotSpecs[$si]['Text'] = $slots[$si].Text }

            # Encoding warning over every header and data cell's text, skipping cells
            # that render in an embedded font (the table's, or the cell's own).
            $cellText = @(
                foreach ($spec in @($headerSpecs) + @($specRows | ForEach-Object { $_ })) {
                    if (-not $FontHandle -and -not $spec['FontHandle']) { [string]$spec['Text'] }
                }
            )
            Write-VellumPdfEncodingWarning -Text $cellText -CommandName 'Add-VellumPdfTable'

            $table = [VellumPdf.Layout.Elements.Table.TableElement]::new()

            # Effective table font/size: the -Font/-FontSize overrides, else the
            # document defaults. A TextStyle without a font renders in the
            # library-global Helvetica, so these fill the gap for the default cell
            # style AND for rich cells that set only Colour (see $buildCell).
            $default = Resolve-VellumPdfDefault -Document $Document
            $effFont = if ($Font) { $Font } else { $default.Font }
            $effSize = if ($PSBoundParameters.ContainsKey('FontSize')) { $FontSize } else { $default.FontSize }
            if ($FontHandle) {
                $table.DefaultCellStyle = New-VellumTextStyle -FontHandle $FontHandle -FontSize $effSize -Document $Document -CommandName 'Add-VellumPdfTable'
            }
            else {
                $table.DefaultCellStyle = New-VellumTextStyle -Font $effFont -FontSize $effSize -Document $Document -CommandName 'Add-VellumPdfTable'
            }

            # The base-14 font names a rich cell's Font key may use (same set as the
            # -Font parameter's ValidateSet, which does not reach per-cell hashtables).
            $fontNames = @('Courier', 'CourierBold', 'CourierBoldOblique', 'CourierOblique',
                'Helvetica', 'HelveticaBold', 'HelveticaBoldOblique', 'HelveticaOblique',
                'Symbol', 'TimesBold', 'TimesBoldItalic', 'TimesItalic', 'TimesRoman', 'ZapfDingbats')

            # Apply border width.
            if ($PSBoundParameters.ContainsKey('BorderWidth')) {
                $table.BorderWidth = $BorderWidth
            }

            # Resolve flexible colours once (RGB triple / hex / name -> ColorRgb).
            $toRgb = {
                param($value)
                $c = ConvertTo-VellumColor $value -Document $Document -CommandName 'Add-VellumPdfTable'
                [VellumPdf.Layout.Core.ColorRgb]::new($c[0], $c[1], $c[2])
            }
            if ($PSBoundParameters.ContainsKey('BorderColor')) { $table.BorderColor = & $toRgb $BorderColor }
            $headerBg = if ($PSBoundParameters.ContainsKey('HeaderBackground')) { & $toRgb $HeaderBackground }
            $altBg    = if ($PSBoundParameters.ContainsKey('AlternateRowBackground')) { & $toRgb $AlternateRowBackground }

            # Builds a styled Cell from a spec hashtable at a column index. Rich-cell
            # values are validated here because the -Font/-FontSize/-Alignment
            # parameter guards do not reach per-cell hashtables.
            $buildCell = {
                param($spec, $colIndex)
                $cell = [VellumPdf.Layout.Elements.Table.Cell]::new([string]$spec['Text'])

                $align = if ($spec['Alignment']) { [string]$spec['Alignment'] }
                    elseif ($ColumnAlignment -and $colIndex -lt $ColumnAlignment.Count) { $ColumnAlignment[$colIndex] }
                    else { $Alignment }
                if ($align -notin @('Left', 'Center', 'Right', 'Justify')) {
                    throw "Add-VellumPdfTable: rich-cell Alignment '$align' is invalid; use Left, Center, Right, or Justify."
                }
                $cell.Alignment = [VellumPdf.Layout.Core.HorizontalAlignment]::$align

                foreach ($spanKey in 'ColSpan', 'RowSpan') {
                    if ($null -ne $spec[$spanKey]) {
                        $cell.$spanKey = [int]$spec[$spanKey]   # validated up front
                    }
                }

                if ($spec['Background']) { $cell.Background = & $toRgb $spec['Background'] }

                if ($spec['LinkUri']) {
                    throw ('Add-VellumPdfTable: a table cell cannot carry a LinkUri - VellumPdf ignores hyperlinks ' +
                        'inside table cells. Put the link in a paragraph with -LinkUri instead.')
                }

                $cellHandle = $spec['FontHandle']
                if ($cellHandle) {
                    if ($cellHandle -isnot [VellumPdf.Fonts.EmbeddedFontHandle]) {
                        throw 'Add-VellumPdfTable: rich-cell FontHandle must be an EmbeddedFontHandle from Register-VellumPdfFont.'
                    }
                    Assert-VellumPdfFontHandle -FontHandle $cellHandle -Document $Document -CommandName 'Add-VellumPdfTable'
                }
                $effHandle = if ($cellHandle) { $cellHandle } else { $FontHandle }
                Write-VellumPdfMissingGlyphWarning -Handle $effHandle -Text ([string]$spec['Text']) -CommandName 'Add-VellumPdfTable'

                if ($null -ne $spec['Leading']) {
                    $lead = $spec['Leading'] -as [double]
                    if ($null -eq $lead -or -not [double]::IsFinite($lead) -or $lead -lt 0 -or $lead -gt 1000) {
                        throw "Add-VellumPdfTable: rich-cell Leading must be between 0 and 1000; got '$($spec['Leading'])'."
                    }
                }

                if ($spec['Font'] -or $null -ne $spec['FontSize'] -or $spec['Color'] -or $cellHandle -or $null -ne $spec['Leading']) {
                    if ($spec['Font'] -and [string]$spec['Font'] -notin $fontNames) {
                        throw ("Add-VellumPdfTable: rich-cell Font '$($spec['Font'])' is not a base-14 font name. " +
                            "Valid names: $($fontNames -join ', ').")
                    }
                    if ($null -ne $spec['FontSize']) {
                        $size = $spec['FontSize'] -as [double]
                        if ($null -eq $size -or -not [double]::IsFinite($size) -or $size -lt 1 -or $size -gt 1000) {
                            throw "Add-VellumPdfTable: rich-cell FontSize must be between 1 and 1000; got '$($spec['FontSize'])'."
                        }
                    }
                    # Fill the font/size gap from the table's effective default so a
                    # Colour-only cell keeps the table font (base-14 or embedded)
                    # instead of falling back to the library-global Helvetica.
                    $cellStyle = @{
                        FontSize = if ($null -ne $spec['FontSize']) { [double]$spec['FontSize'] } else { $effSize }
                    }
                    if ($effHandle -and -not $spec['Font']) { $cellStyle['FontHandle'] = $effHandle }
                    else { $cellStyle['Font'] = if ($spec['Font']) { [string]$spec['Font'] } else { $effFont } }
                    if ($spec['Color']) { $cellStyle['Color'] = ConvertTo-VellumColor $spec['Color'] -Document $Document -CommandName 'Add-VellumPdfTable' }
                    if ($null -ne $spec['Leading']) { $cellStyle['Leading'] = [double]$spec['Leading'] }
                    $cell.Style = New-VellumTextStyle @cellStyle -Document $Document -CommandName 'Add-VellumPdfTable'
                }

                if ($null -ne $spec['Padding']) {
                    $cell.Padding = ConvertTo-VellumInsets -Value $spec['Padding'] -What 'Add-VellumPdfTable: rich-cell Padding'
                }
                elseif ($tablePadding) {
                    $cell.Padding = $tablePadding
                }
                if ($spec['Language']) { Assert-VellumPdfLanguageTag -Value ([string]$spec['Language']) -CommandName 'Add-VellumPdfTable' -Name 'Language (cell)'; $cell.Language = [string]$spec['Language'] }
                $cell
            }

            # Apply column widths.
            if ($ColumnWidth) {
                $firstRow = if ($headerRowSpecs.Count) { $headerRowSpecs[0] } elseif ($specRows.Count) { $specRows[0] } else { @() }
                $columnCount = 0
                foreach ($cellSpec in $firstRow) { $columnCount += (& $spanOf $cellSpec 'ColSpan') }
                if ($ColumnWidth.Count -ne $columnCount) {
                    Write-Warning ("Add-VellumPdfTable: -ColumnWidth has $($ColumnWidth.Count) value(s) " +
                        "but the table has $columnCount column(s); extra widths are ignored and " +
                        'missing ones fall back to the library default.')
                }
                [void]$table.SetColumnWidths($ColumnWidth)
            }

            # Add optional header row.
            $headerRowNumber = 0
            foreach ($specsOfRow in $headerRowSpecs) {
                # The engine cannot lay out a table made only of header rows, so a header-only table draws its
                # header as ordinary rows (same look and background, but not marked as header cells).
                $headerRow = if ($specRows.Count -eq 0) { $table.AddRow($false) } else { $table.AddHeaderRow() }
                if ($headerBg) { $headerRow.Background = $headerBg }
                $gridColumns = $headerPlacements[$headerRowNumber]
                $headerRowNumber++
                $hi = 0
                foreach ($headerSpec in $specsOfRow) {
                    [void]$headerRow.AddCell((& $buildCell $headerSpec $gridColumns[$hi]))
                    $hi++
                }
            }

            # Add data rows, applying zebra striping to every second row.
            $rowIndex = 0
            foreach ($specRow in $specRows) {
                $tableRow = $table.AddRow($false)
                if ($altBg -and ($rowIndex % 2 -eq 1)) { $tableRow.Background = $altBg }
                $gridColumns = $dataPlacements[$rowIndex]
                $colIndex = 0
                foreach ($spec in $specRow) {
                    [void]$tableRow.AddCell((& $buildCell $spec $gridColumns[$colIndex]))
                    $colIndex++
                }
                $rowIndex++
            }

            Set-VellumPdfElementMargin -Element $table -Top $MarginTop -Bottom $MarginBottom `
                -BoundParameters $PSBoundParameters -Document $Document -DeferBottom

            [void]$Document.Add($table)
            $Document
        }
        catch {
            $PSCmdlet.ThrowTerminatingError((Resolve-VellumPdfErrorRecord -ErrorRecord $_ -CommandName 'Add-VellumPdfTable'))
        }
    }
}