Public/Add-VellumPdfPageTable.ps1

function Add-VellumPdfPageTable {
    <#
    .SYNOPSIS
        Draws a table at x/y on a page of a page-based document, tagged as a real table in accessible PDFs.
    .DESCRIPTION
        The page-document counterpart of Add-VellumPdfTable: a grid whose cell text wraps inside its
        column, with column and row spans, backgrounds and borders. Because a page document has no
        flow, you give the position (-X and -Y, the top-left corner in points from the page's
        bottom-left) and the column widths yourself, and the table must fit on the page: one that is
        too tall is an error rather than being split.

        Rows are arrays of cells. A row may be empty (@()) when RowSpans from the rows above cover all of
        its columns; it is drawn as the extension of those cells and keeps an empty TR element (PDF/UA counts rows by TR). -Row may be an
        empty array when -Header is given (a header-only table); a table with neither is an error.
        A cell is text, or a hashtable with Text and optionally ColSpan,
        RowSpan, Alignment (Left, Center, Right, Justify), Background (colour), Color (text colour),
        Format (a .NET format string) and Parse ($false draws a string as given):

            -Row @(
                @('Widget', '2', '10.00'),
                @(@{ Text = 'Shipping'; ColSpan = 2 }, '5.00')
            )

        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 cell hashtable 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).

        A single row is written @(,@('a','b')). -Header takes one row of cells (-Header 'Item','Qty')
        or several (an array of arrays).

        In a tagged page document (PDF/UA-1, PDF/A-2a, -Tagged) the table is a Table element with TR rows
        and TH/TD cells: header cells carry Scope Column (with -RowHeader, the first cell of every body
        row is a TH with Scope Row), and ColSpan and RowSpan are written on the cells. The grid lines and
        backgrounds are artifacts. In an untagged document it is only drawing.

        Header text uses the bold variant of -Font (Helvetica, Times and Courier), or -HeaderFont.
        Use a base-14 font by name (-Font) or an embedded TrueType font (-FontHandle from
        Register-VellumPdfFont); PDF/A and PDF/UA documents embed base-14 text automatically.
    .PARAMETER Document
        A document from New-VellumPdfPageDocument.
    .PARAMETER Row
        The body rows: an array of rows, each an array of cells. May be omitted or empty when -Header is given.
    .PARAMETER Header
        The header row (an array of cells) or rows (an array of arrays).
    .PARAMETER Page
        The 1-based page number. Default 1.
    .PARAMETER X
        Left edge of the table in points.
    .PARAMETER Y
        Top edge of the table in points from the bottom of the page.
    .PARAMETER Width
        Total table width; the columns share it equally. Give this or -ColumnWidth.
    .PARAMETER ColumnWidth
        One width per grid column, in points. Give this or -Width.
    .PARAMETER ColumnAlignment
        Left, Center, Right or Justify per grid column. A cell's own Alignment wins.
    .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 FontSize
        Size in points (default 10).
    .PARAMETER Font
        A base-14 font name (default Helvetica).
    .PARAMETER HeaderFont
        A base-14 font for header cells (default: the bold variant of -Font).
    .PARAMETER FontHandle
        An embedded TrueType font from Register-VellumPdfFont. Overrides -Font and -HeaderFont.
    .PARAMETER Color
        Text colour: RGB triple in 0..1, hex or name. Default black.
    .PARAMETER Leading
        Distance between baselines in points. Default 1.2 times -FontSize.
    .PARAMETER Padding
        Space between a cell's border and its text, in points (default 4).
    .PARAMETER BorderWidth
        Grid line width in points (default 0.5); 0 draws no lines.
    .PARAMETER BorderColor
        Grid line colour (default black).
    .PARAMETER HeaderBackground
        Background colour of the header rows.
    .PARAMETER AlternateRowBackground
        Background colour of every second body row.
    .PARAMETER RowHeader
        Make the first cell of every body row a row header (TH, Scope Row).
    .PARAMETER Language
        BCP 47 language tag of the table text, written on the Table element.
    .PARAMETER PassThru
        Return an object with Document, Height, Bottom (the y of the table's lower edge), Rows and
        Columns instead of the document.
    .EXAMPLE
        $doc | Add-VellumPdfPageTable -X 72 -Y 700 -Width 450 -Header 'Item', 'Qty', 'Price' `
            -ColumnAlignment Left, Right, Right -HeaderBackground '#e8f0fe' -Row @(
                @('Widget', '2', '10.00'),
                @('Gadget', '1', '24.50'),
                @(@{ Text = 'Total'; ColSpan = 2 }, '44.50'))
    .EXAMPLE
        # Numbers in strings are read; the document culture and the column formats decide how they look
        $page = New-VellumPdfPageDocument -Culture de-DE | Add-VellumPdfPage
        $page | Add-VellumPdfPageTable -X 72 -Y 700 -Width 300 -Header 'Item', 'Amount' -ColumnFormat '', 'N2' `
            -ColumnAlignment Left, Right -Row @(@('Widget', '1.444,5'), @('Gadget', 55433.34))
    .OUTPUTS
        VellumPdf.Document.PdfDocument (the same instance, for chaining), or with -PassThru a summary object.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Width')]
    [OutputType([VellumPdf.Document.PdfDocument], [pscustomobject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VellumPdf.Document.PdfDocument]$Document,

        [Parameter(Position = 0)]
        [AllowEmptyCollection()]
        [AllowNull()]
        [object[]]$Row,

        [object[]]$Header,

        [ValidateRange(1, 1000000)]
        [int]$Page = 1,

        [Parameter(Mandatory)] [double]$X,
        [Parameter(Mandatory)] [double]$Y,

        [Parameter(Mandatory, ParameterSetName = 'Width')]
        [ValidateRange(1.0, 1000000.0)]
        [double]$Width,

        [Parameter(Mandatory, ParameterSetName = 'ColumnWidth')]
        [ValidateRange(0.5, 1000000.0)]
        [double[]]$ColumnWidth,

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

        # Per-column .NET format string for values that are not text ('N2', 'yyyy-MM-dd'), by grid column,
        # 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,

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

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

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

        [VellumPdf.Fonts.EmbeddedFontHandle]$FontHandle,

        [object]$Color,

        [ValidateRange(0.1, 10000.0)]
        [double]$Leading,

        [ValidateRange(0.0, 1000.0)]
        [double]$Padding = 4,

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

        [object]$BorderColor,
        [object]$HeaderBackground,
        [object]$AlternateRowBackground,

        [switch]$RowHeader,

        [ValidateNotNullOrEmpty()]
        [string]$Language,

        [switch]$PassThru
    )

    process {
        try {
            Assert-VellumPdfDocumentOpen -Document $Document -CommandName 'Add-VellumPdfPageTable'
            $Page = Resolve-VellumPdfPageNumber -Document $Document -Page $(if ($PSBoundParameters.ContainsKey('Page')) { $PSBoundParameters['Page'] } else { 1 }) -Bound $PSBoundParameters.ContainsKey('Page') -CommandName 'Add-VellumPdfPageTable'
            [void](Get-VellumPdfPage -Document $Document -Page $Page -CommandName 'Add-VellumPdfPageTable')
            Assert-VellumPdfLanguageTag -Value $Language -CommandName 'Add-VellumPdfPageTable'
            $cultures = Resolve-VellumPdfCulture -Document $Document -Culture $Culture -CultureBound:$PSBoundParameters.ContainsKey('Culture') `
                -InputCulture $InputCulture -InputCultureBound:$PSBoundParameters.ContainsKey('InputCulture') -CommandName 'Add-VellumPdfPageTable'
            if (-not $FontHandle) {
                Assert-VellumPdfMeasurableFont -Font $Font -CommandName 'Add-VellumPdfPageTable' -Reason 'it wraps cell text'
                if ($HeaderFont) { Assert-VellumPdfMeasurableFont -Font $HeaderFont -CommandName 'Add-VellumPdfPageTable' -Reason 'it wraps cell text' }
            }
            Assert-VellumPdfCoordinate $X 'X' 'Add-VellumPdfPageTable'
            Assert-VellumPdfCoordinate $Y 'Y' 'Add-VellumPdfPageTable'
            $bound = $PSBoundParameters
            if (-not $bound.ContainsKey('Leading')) { $Leading = 1.2 * $FontSize }

            # --- cells: normalise rows, place them on the grid -------------------------------------------
            # -Header is one row of cells (strings, maps, numbers) or several rows (an array of arrays).
            $headerRows = @()
            if ($bound.ContainsKey('Header')) {
                $headerList = @($Header)
                $oneRow = $headerList.Count -gt 0 -and ($headerList[0] -is [string] -or $headerList[0] -is [System.Collections.IDictionary] -or $headerList[0] -is [ValueType])
                if ($oneRow) { $headerRows = @(, $headerList) } else { $headerRows = $headerList }
            }
            # Plain assignments: an if-expression would unroll a one-row array into its cells.
            if ($null -eq $Row) { $bodyRows = @() } else { $bodyRows = @($Row) }
            $cellOf = {
                param($spec)
                $isMap = $spec -is [System.Collections.IDictionary]
                if (-not $isMap -and $spec -isnot [string] -and $spec -isnot [ValueType] -and $null -ne $spec) {
                    throw ("Add-VellumPdfPageTable: a cell must be text or a hashtable with a Text key, not $($spec.GetType().Name). " +
                        'Rows are arrays of cells; a single row is written -Row @(,@(''a'', ''b'')) and records are not supported (use Add-VellumPdfTable for objects).')
                }
                if ($isMap -and -not $spec.Contains('Text')) { throw 'Add-VellumPdfPageTable: a cell hashtable needs a Text key (rows are arrays of cells, not records).' }
                # The raw value: it becomes text (number detection, format, culture) once the grid is placed.
                $value = if ($isMap) { Get-VellumPdfPropertyValue -InputObject $spec -Name 'Text' } else { $spec }
                $cellFormatKey = if ($isMap) { Get-VellumPdfPropertyValue -InputObject $spec -Name 'Format' }
                $cellParseKey = if ($isMap) { Get-VellumPdfPropertyValue -InputObject $spec -Name 'Parse' }
                $span = {
                    param($key)
                    $v = if ($isMap) { Get-VellumPdfPropertyValue -InputObject $spec -Name $key }
                    if ($null -eq $v) { 1 }
                    else {
                        $d = $v -as [double]
                        if ($null -eq $d -or $d -ne [Math]::Floor($d) -or $d -lt 1 -or $d -gt 1000) { throw "Add-VellumPdfPageTable: cell $key must be a whole number from 1 to 1000; got '$v'." }
                        [int]$d
                    }
                }
                $align = if ($isMap) { Get-VellumPdfPropertyValue -InputObject $spec -Name 'Alignment' }
                if ($align -and [string]$align -notin 'Left', 'Center', 'Right', 'Justify') { throw "Add-VellumPdfPageTable: cell Alignment '$align' is invalid; use Left, Center, Right or Justify." }
                [pscustomobject]@{
                    Value = $value; Format = $(if ($null -ne $cellFormatKey) { [string]$cellFormatKey }); Parse = ($null -eq $cellParseKey -or [bool]$cellParseKey); Text = ''
                    ColSpan = (& $span 'ColSpan'); RowSpan = (& $span 'RowSpan'); Align = [string]$align
                    # Colours are resolved here, before anything is drawn, so a bad value cannot leave half a table behind.
                    Background = $(if ($isMap -and $null -ne (Get-VellumPdfPropertyValue -InputObject $spec -Name 'Background')) { ConvertTo-VellumColor (Get-VellumPdfPropertyValue -InputObject $spec -Name 'Background') -Document $Document -CommandName 'Add-VellumPdfPageTable' })
                    Color = $(if ($isMap -and $null -ne (Get-VellumPdfPropertyValue -InputObject $spec -Name 'Color')) { ConvertTo-VellumColor (Get-VellumPdfPropertyValue -InputObject $spec -Name 'Color') -Document $Document -CommandName 'Add-VellumPdfPageTable' })
                    Row = 0; Col = 0; IsHeader = $false; Scope = $null; Lines = $null; Height = 0.0
                }
            }
            $cells = [System.Collections.Generic.List[object]]::new()
            $taken = @{}
            $rowIndex = 0
            $rowKinds = [System.Collections.Generic.List[string]]::new()
            foreach ($kind in 'Header', 'Body') {
                # Plain assignments: an if-expression would unroll a one-row array into its cells.
                if ($kind -eq 'Header') { $source = $headerRows } else { $source = $bodyRows }
                $bodyIndex = 0
                foreach ($specRow in $source) {
                    $column = 0
                    foreach ($spec in @($specRow)) {
                        while ($taken.ContainsKey("$rowIndex,$column")) { $column++ }
                        $cell = & $cellOf $spec
                        $cell.Row = $rowIndex; $cell.Col = $column
                        $isRowHeader = $RowHeader -and $column -eq 0 -and $kind -eq 'Body'
                        $cell.IsHeader = ($kind -eq 'Header') -or $isRowHeader
                        $cell.Scope = if ($kind -eq 'Header') { 'Column' } elseif ($isRowHeader) { 'Row' }
                        for ($dr = 0; $dr -lt $cell.RowSpan; $dr++) { for ($dc = 0; $dc -lt $cell.ColSpan; $dc++) { $taken["$($rowIndex + $dr),$($column + $dc)"] = $true } }
                        $column += $cell.ColSpan
                        $cells.Add($cell)
                    }
                    $rowKinds.Add($(if ($kind -eq 'Header') { 'Header' } elseif ($bodyIndex % 2 -eq 1) { 'Alternate' } else { 'Body' }))
                    $rowIndex++
                    $bodyIndex++
                }
            }
            $rowCount = $rowIndex
            if ($cells.Count -eq 0) { throw 'Add-VellumPdfPageTable: the table has no cells; give at least one -Row of cells or a -Header.' }
            $columnCount = ($cells | ForEach-Object { $_.Col + $_.ColSpan } | Measure-Object -Maximum).Maximum
            $lastRowUsed = ($cells | ForEach-Object { $_.Row + $_.RowSpan } | Measure-Object -Maximum).Maximum
            if ($lastRowUsed -gt $rowCount) { throw "Add-VellumPdfPageTable: a RowSpan runs past the last row (the table has $rowCount row(s))." }

            # Values become text: number detection, -ColumnFormat / a cell's Format key, and the culture. Header
            # cells (and row headers) are labels and are never read as numbers.
            $slots = foreach ($cell in $cells) {
                $format = if ($null -ne $cell.Format) { $cell.Format }
                elseif (-not $cell.IsHeader -and $ColumnFormat -and $cell.Col -lt $ColumnFormat.Count) { $ColumnFormat[$cell.Col] }
                New-VellumPdfTextSlot -Value $cell.Value -Format $format -Parse ($cell.Parse -and -not $cell.IsHeader) `
                    -Group $(if ($cell.IsHeader) { "header$($cell.Row)" } else { $cell.Col }) -What $(if ($cell.IsHeader) { "header cell in column $($cell.Col + 1)" } else { "column $($cell.Col + 1)" })
            }
            $slots = @($slots)
            Convert-VellumPdfSlotText -Slot $slots -Culture $cultures.Culture -InputCulture $cultures.InputCulture `
                -NoNumberParsing:$NoNumberParsing -CommandName 'Add-VellumPdfPageTable'
            for ($si = 0; $si -lt $cells.Count; $si++) { $cells[$si].Text = $slots[$si].Text }
            Assert-VellumPdfValidText -Text @($cells | ForEach-Object { $_.Text }) -CommandName 'Add-VellumPdfPageTable'

            # A row that does not fill every grid column makes a table PDF/UA validators reject (7.2-41/42/43).
            $irregular = @(0..($rowCount - 1) | Where-Object {
                    $r = $_
                    @(0..($columnCount - 1) | Where-Object { $taken.ContainsKey("$r,$_") }).Count -ne $columnCount
                })
            if ($irregular.Count -gt 0) {
                Write-Warning ("Add-VellumPdfPageTable: row(s) $((@($irregular | ForEach-Object { $_ + 1 })) -join ', ') do not fill all $columnCount grid columns " +
                    '(cells, ColSpan and RowSpan do not add up); a tagged table like this fails PDF/UA validation.')
            }
            $cellsByRow = @{}
            foreach ($cell in $cells) {
                if (-not $cellsByRow.ContainsKey($cell.Row)) { $cellsByRow[$cell.Row] = [System.Collections.Generic.List[object]]::new() }
                $cellsByRow[$cell.Row].Add($cell)
            }

            if ($PSCmdlet.ParameterSetName -eq 'ColumnWidth') {
                if ($ColumnWidth.Count -ne $columnCount) { throw "Add-VellumPdfPageTable: -ColumnWidth has $($ColumnWidth.Count) value(s) but the table has $columnCount column(s)." }
                $widths = $ColumnWidth
            }
            else { $widths = [double[]]@(1..$columnCount | ForEach-Object { $Width / $columnCount }) }
            foreach ($w in $widths) { Assert-VellumPdfCoordinate $w 'ColumnWidth' 'Add-VellumPdfPageTable' }
            $tableWidth = ($widths | Measure-Object -Sum).Sum
            Assert-VellumPdfCoordinate $tableWidth 'Width' 'Add-VellumPdfPageTable'

            # --- fonts, wrapping, row heights --------------------------------------------------------------
            $body = Get-VellumPdfPageFont -Document $Document -FontHandle $FontHandle -Font $Font -CommandName 'Add-VellumPdfPageTable'
            $headerName = if ($HeaderFont) { $HeaderFont } else { @{ Helvetica = 'HelveticaBold'; TimesRoman = 'TimesBold'; Courier = 'CourierBold' }[$Font] ?? $Font }
            $head = if ($FontHandle) { $body } else { Get-VellumPdfPageFont -Document $Document -Font $headerName -CommandName 'Add-VellumPdfPageTable' }
            $allText = @($cells | ForEach-Object { $_.Text })
            Write-VellumPdfMissingGlyphWarning -Handle $body.Handle -Text $allText -CommandName 'Add-VellumPdfPageTable'
            if (-not $body.Handle -and $Document.Conformance -eq [VellumPdf.Document.PdfConformance]::None) {
                Write-VellumPdfEncodingWarning -Text $allText -CommandName 'Add-VellumPdfPageTable'
            }
            $rowHeights = [double[]]::new($rowCount)
            for ($r = 0; $r -lt $rowCount; $r++) { $rowHeights[$r] = $Leading + 2 * $Padding }
            foreach ($cell in $cells) {
                $span = 0.0
                for ($c = $cell.Col; $c -lt $cell.Col + $cell.ColSpan; $c++) { $span += $widths[$c] }
                $cellResolved = if ($cell.IsHeader -and $cell.Scope -eq 'Column') { $head } else { $body }
                $inner = [Math]::Max(1.0, $span - 2 * $Padding)
                $cell.Lines = Split-VellumPdfTextLine -Text $cell.Text -Width $inner -Resolved $cellResolved -FontSize $FontSize
                $cell.Height = $cell.Lines.Count * $Leading + 2 * $Padding
                if ($cell.RowSpan -eq 1 -and $cell.Height -gt $rowHeights[$cell.Row]) { $rowHeights[$cell.Row] = $cell.Height }
            }
            foreach ($cell in ($cells | Where-Object { $_.RowSpan -gt 1 })) {
                $have = 0.0
                for ($r = $cell.Row; $r -lt $cell.Row + $cell.RowSpan; $r++) { $have += $rowHeights[$r] }
                if ($cell.Height -gt $have) { $rowHeights[$cell.Row + $cell.RowSpan - 1] += $cell.Height - $have }
            }
            $tableHeight = ($rowHeights | Measure-Object -Sum).Sum
            $pdfPage = $Document.Pages[$Page - 1]
            if ($Y - $tableHeight -lt $pdfPage.MediaBox.LlY) {
                throw ("Add-VellumPdfPageTable: the table is $([math]::Round($tableHeight, 1)) pt tall but only " +
                    "$([math]::Round($Y - $pdfPage.MediaBox.LlY, 1)) pt are left below -Y on page $Page. Page tables are not split across pages.")
            }
            $rowTop = [double[]]::new($rowCount + 1)
            $rowTop[0] = $Y
            for ($r = 0; $r -lt $rowCount; $r++) { $rowTop[$r + 1] = $rowTop[$r] - $rowHeights[$r] }
            $colLeft = [double[]]::new($columnCount + 1)
            $colLeft[0] = $X
            for ($c = 0; $c -lt $columnCount; $c++) { $colLeft[$c + 1] = $colLeft[$c] + $widths[$c] }

            # --- drawing -------------------------------------------------------------------------------------
            $canvas = Get-VellumPdfPageCanvas -Document $Document -Page $Page -CommandName 'Add-VellumPdfPageTable'
            if ($body.Handle) { $Document.RegisterEmbeddedFontUsage($pdfPage, $body.Handle) }
            if ($head.Handle -and $head.Handle -ne $body.Handle) { $Document.RegisterEmbeddedFontUsage($pdfPage, $head.Handle) }
            # Colour operators are written only for colours that were asked for: a default RGB operator would
            # fail PDF/A with a CMYK output intent.
            $textRgb = if ($bound.ContainsKey('Color')) { ConvertTo-VellumColor $Color -Document $Document -CommandName 'Add-VellumPdfPageTable' }
            $borderRgb = if ($bound.ContainsKey('BorderColor')) { ConvertTo-VellumColor $BorderColor -Document $Document -CommandName 'Add-VellumPdfPageTable' }
            $headerBg = if ($bound.ContainsKey('HeaderBackground')) { ConvertTo-VellumColor $HeaderBackground -Document $Document -CommandName 'Add-VellumPdfPageTable' }
            $altBg = if ($bound.ContainsKey('AlternateRowBackground')) { ConvertTo-VellumColor $AlternateRowBackground -Document $Document -CommandName 'Add-VellumPdfPageTable' }

            [void]$canvas.SaveState()
            try {
                # Backgrounds and grid lines are decoration: artifacts in a tagged document.
                Invoke-VellumPdfTagged -Document $Document -Canvas $canvas -PdfPage $pdfPage -Kind Artifact -Draw {
                    for ($r = 0; $r -lt $rowCount; $r++) {
                        $fill = if ($rowKinds[$r] -eq 'Header') { $headerBg } elseif ($rowKinds[$r] -eq 'Alternate') { $altBg }
                        if ($fill) {
                            [void]$canvas.SaveState().SetFillColorRgb($fill[0], $fill[1], $fill[2]).Rectangle($X, $rowTop[$r + 1], $tableWidth, $rowHeights[$r]).Fill().RestoreState()
                        }
                    }
                    foreach ($cell in $cells) {
                        if ($null -eq $cell.Background) { continue }
                        $bg = $cell.Background
                        $bottom = $rowTop[$cell.Row + $cell.RowSpan]
                        [void]$canvas.SaveState().SetFillColorRgb($bg[0], $bg[1], $bg[2]).Rectangle($colLeft[$cell.Col], $bottom, $colLeft[$cell.Col + $cell.ColSpan] - $colLeft[$cell.Col], $rowTop[$cell.Row] - $bottom).Fill().RestoreState()
                    }
                    if ($BorderWidth -gt 0) {
                        [void]$canvas.SaveState().SetLineWidth($BorderWidth)
                        if ($borderRgb) { [void]$canvas.SetStrokeColorRgb($borderRgb[0], $borderRgb[1], $borderRgb[2]) }
                        foreach ($cell in $cells) {
                            $bottom = $rowTop[$cell.Row + $cell.RowSpan]
                            [void]$canvas.Rectangle($colLeft[$cell.Col], $bottom, $colLeft[$cell.Col + $cell.ColSpan] - $colLeft[$cell.Col], $rowTop[$cell.Row] - $bottom).Stroke()
                        }
                        [void]$canvas.RestoreState()
                    }
                }

                # Text: Table > TR > TH/TD in a tagged document.
                $table = $null
                if ($Document.Tagged) {
                    $table = [VellumPdf.Document.PdfStructElem]::new('Table')
                    if ($Language) { $table.Language = $Language }
                    Push-VellumPdfStructParent -Document $Document -Element $table
                }
                try {
                    for ($r = 0; $r -lt $rowCount; $r++) {
                        # A row whose slots are all covered by RowSpans from above has no cells of its own, but it
                        # still gets its (empty) TR: PDF/UA counts rows from the TR elements (7.2-41..43).
                        $tableRow = $null
                        if ($Document.Tagged) {
                            $tableRow = [VellumPdf.Document.PdfStructElem]::new('TR')
                            [void]$table.AddChild($tableRow)
                            Push-VellumPdfStructParent -Document $Document -Element $tableRow
                        }
                        try {
                            foreach ($cell in $(if ($cellsByRow.ContainsKey($r)) { $cellsByRow[$r] })) {
                                $structType = if ($cell.IsHeader) { 'TH' } else { 'TD' }
                                $configure = {
                                    param($element)
                                    if ($cell.Scope) { $element.TableHeaderScope = $cell.Scope }
                                    if ($cell.RowSpan -gt 1) { $element.TableRowSpan = $cell.RowSpan }
                                    if ($cell.ColSpan -gt 1) { $element.TableColSpan = $cell.ColSpan }
                                }.GetNewClosure()
                                $cellResolved = if ($cell.IsHeader -and $cell.Scope -eq 'Column') { $head } else { $body }
                                $alignment = if ($cell.Align) { $cell.Align } elseif ($ColumnAlignment -and $cell.Col -lt $ColumnAlignment.Count) { $ColumnAlignment[$cell.Col] } else { 'Left' }
                                $rgb = if ($null -ne $cell.Color) { $cell.Color } else { $textRgb }
                                $cellLeft = $colLeft[$cell.Col] + $Padding
                                $cellWidth = $colLeft[$cell.Col + $cell.ColSpan] - $colLeft[$cell.Col] - 2 * $Padding
                                $cellTop = $rowTop[$cell.Row] - $Padding
                                $cellLines = $cell.Lines
                                $cellFont = $cellResolved
                                Invoke-VellumPdfTagged -Document $Document -Canvas $canvas -PdfPage $pdfPage -Kind Structure -StructType $structType -Configure $configure -Draw {
                                    [void]$canvas.SaveState()
                                    try {
                                        if ($rgb) { [void]$canvas.SetFillColorRgb($rgb[0], $rgb[1], $rgb[2]) }
                                        $hasText = [bool]($cellLines | Where-Object { $_.Words.Count -gt 0 })
                                        if ($hasText) {
                                            Write-VellumPdfTextLine -Canvas $canvas -Resolved $cellFont -FontSize $FontSize -Lines $cellLines -X $cellLeft -Top $cellTop -Width $cellWidth -Leading $Leading -Align $alignment
                                        }
                                    }
                                    finally { [void]$canvas.RestoreState() }
                                }
                            }
                        }
                        finally { if ($Document.Tagged) { Pop-VellumPdfStructParent -Document $Document } }
                    }
                }
                finally {
                    # Even a table that failed half way is registered: the cells already drawn own MCIDs.
                    if ($Document.Tagged) { Pop-VellumPdfStructParent -Document $Document; Add-VellumPdfStructElement -Document $Document -Element $table }
                }
            }
            finally { [void]$canvas.RestoreState() }

            if ($PassThru) {
                [pscustomobject]@{ Document = $Document; Height = $tableHeight; Bottom = $Y - $tableHeight; Rows = $rowCount; Columns = $columnCount }
            }
            else { $Document }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError((Resolve-VellumPdfErrorRecord -ErrorRecord $_ -CommandName 'Add-VellumPdfPageTable'))
        }
    }
}