Public/Add-VellumPdfList.ps1

function Add-VellumPdfList {
    <#
    .SYNOPSIS
        Adds an ordered or unordered list to a VellumPdf document.
    .DESCRIPTION
        Wraps Document.Add(ListElement). Builds a ListElement from an array of
        string items and an optional list style (Unordered, OrderedDecimal,
        OrderedAlpha, OrderedRoman). An optional -Indent adjusts the left indent
        for the list. An optional -Font/-FontSize override applies a TextStyle to
        every item; when omitted the document default font is used.

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

        Items become text with a culture and an optional format. A string item 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 -Format (a .NET format string such as 'N2' or
        'yyyy-MM-dd'); an item hashtable may carry its own Format key, and Parse = $false to draw its string
        as given.

        Numbers in strings are always recognised (switch it off with -NoNumberParsing): a string item 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), 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 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 items of the same call; 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.

        The document flows through the pipeline for chaining with other
        Add-VellumPdf* functions.
    .PARAMETER Document
        The live VellumPdf document flowing through the pipeline. The same
        instance is returned after the list is added, enabling chaining.
    .PARAMETER Item
        The list items. Each element is either a string (a leaf item) or a
        hashtable describing a nested item:
            @{ Text = 'Parent'; Children = @('Child A', @{ Text = 'Child B' }) }
        One level of children is supported: VellumPdf.Layout draws a child's own
        children nowhere (a grandchild's text would silently vanish), so deeper
        nesting is refused with an error. Flatten deeper trees or add separate
        lists. An item hashtable may also set Font, FontSize, FontHandle, Color,
        Leading and Language for that item alone. A nested item in an Unordered list is marked with U+25E6, which
        the base-14 fonts cannot encode (it shows as '?'). Empty strings are
        permitted. Mandatory.
    .PARAMETER Style
        The list marker style. Unordered uses bullet points; OrderedDecimal,
        OrderedAlpha, and OrderedRoman use numbered, alphabetic, and Roman
        numeral markers respectively. Defaults to Unordered.
    .PARAMETER FontHandle
        An embedded TrueType font from Register-VellumPdfFont, applied to every
        item (takes precedence over -Font). Needed for text outside Windows-1252
        and for PDF/A documents. An item may override it with its own FontHandle key.
    .PARAMETER Color
        Text colour for every item: an R,G,B triple in 0..1, a hex string
        ('#3366cc'), or a colour name.
    .PARAMETER Leading
        Line height in points for every item; 0 (the default) means 1.2 x the font size.
    .PARAMETER Language
        A BCP 47 language tag written for every item (e.g. 'fr'); only used in tagged
        documents. An item hashtable may carry its own Language key.
    .PARAMETER Indent
        Left indent for the list in points, between 0 and 1000. When omitted
        the VellumPdf library default indent is used.
    .PARAMETER Font
        A base-14 font name applied to every list item. When omitted the
        document default font is used.
    .PARAMETER FontSize
        Font size in points for list items, between 1 and 1000. When omitted
        the document default size is used.
    .PARAMETER Format
        A .NET format string for items that are not text ('N2', 'yyyy-MM-dd'), applied with -Culture; also to
        numbers read from strings. An item's own Format key wins. A format the value's type rejects is an
        error naming the item.
    .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. 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 item can do the same
        with the key Parse = $false.
    .PARAMETER MarginTop
        Extra spacing in points above the list element. Does not affect the
        left/right page margins.
    .PARAMETER MarginBottom
        Extra spacing in points below the list element. Does not affect the
        left/right page margins.
    .EXAMPLE
        New-VellumPdfDocument |
            Add-VellumPdfList -Item 'Apples','Bananas','Cherries' |
            Save-VellumPdfDocument -Path ./fruit.pdf
    .EXAMPLE
        $doc | Add-VellumPdfList -Item 'First','Second','Third' `
               -Style OrderedDecimal -Indent 20 -Font Helvetica -FontSize 11
    .EXAMPLE
        # Nested list
        $doc | Add-VellumPdfList -Item @(
            'Fruit',
            @{ Text = 'Vegetables'; Children = @('Carrot', 'Potato') }
        )
    .EXAMPLE
        # Numbers and dates with a culture and a format
        New-VellumPdfDocument |
            Add-VellumPdfList -Item 1234.5, '1.444,5', 99 -Format 'N2' -Culture de-DE |
            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,

        # Each element is a string (a leaf item) or a hashtable describing a
        # nested item: @{ Text = 'Parent'; Children = @('child', @{ Text = ... }) }.
        [Parameter(Mandatory)]
        [object[]]$Item,

        [ValidateSet('Unordered', 'OrderedDecimal', 'OrderedAlpha', 'OrderedRoman')]
        [string]$Style = 'Unordered',

        [ValidateRange(0.0, 1000.0)]
        [double]$Indent,

        [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); needed for Unicode text and PDF/A documents.
        [VellumPdf.Fonts.EmbeddedFontHandle]$FontHandle,

        # Text colour for every item: R,G,B in 0..1, '#rrggbb', or a colour name.
        [object]$Color,

        [ValidateRange(0.0, 1000.0)]
        [double]$Leading,

        # BCP 47 language tag applied to every item (tagged documents only).
        [ValidateNotNullOrEmpty()]
        [string]$Language,

        # .NET format string for items that are not text ('N2', 'yyyy-MM-dd'); an item hashtable may carry its own Format key.
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Format,

        # 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(0.0, 10000.0)]
        [double]$MarginTop,

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

    process {
        try {
            Assert-VellumPdfDocumentOpen -Document $Document -CommandName 'Add-VellumPdfList'
            Assert-VellumPdfLanguageTag -Value $Language -CommandName 'Add-VellumPdfList'
            $cultures = Resolve-VellumPdfCulture -Document $Document -Culture $Culture -CultureBound:$PSBoundParameters.ContainsKey('Culture') `
                -InputCulture $InputCulture -InputCultureBound:$PSBoundParameters.ContainsKey('InputCulture') -CommandName 'Add-VellumPdfList'
            if ($FontHandle) {
                Assert-VellumPdfFontHandle -FontHandle $FontHandle -Document $Document -CommandName 'Add-VellumPdfList'
            }

            # VellumPdf.Layout renders exactly one nesting level (ListItem.Children
            # is read, a child's own Children never are). Refuse deeper input rather
            # than lose text silently; this also bounds cyclic hashtables, which
            # would otherwise recurse into an uncatchable StackOverflow.
            $maxDepth = 1

            $depthError = {
                "Add-VellumPdfList: nested -Item exceeds the maximum depth of $maxDepth child level " +
                    '(VellumPdf.Layout draws one nesting level only; flatten the list or split it, ' +
                    'and check for cyclic input).'
            }

            # Values become text: number detection, -Format / an item's Format key, and the culture. Every item and child
            # of the call is evidence for the others about the decimal mark. The tree is rebuilt (hashtables copied, the
            # caller's input is not changed) with the converted text.
            $slotList = [System.Collections.Generic.List[object]]::new()
            $prepare = {
                param($spec, $depth, $label)
                if ($depth -gt $maxDepth) { throw (& $depthError) }
                $isMap = $spec -is [System.Collections.IDictionary]
                if ($isMap -and -not $spec.Contains('Text')) {
                    throw "Add-VellumPdfList: a nested-item hashtable must include a 'Text' key."
                }
                $itemFormat = if ($isMap -and $null -ne $spec['Format']) { [string]$spec['Format'] } else { $Format }
                $itemParse = -not ($isMap -and $null -ne $spec['Parse'] -and -not [bool]$spec['Parse'])
                $slot = New-VellumPdfTextSlot -Value $(if ($isMap) { $spec['Text'] } else { $spec }) -Format $itemFormat -Parse $itemParse -Group 0 -What $label
                $slotList.Add($slot)
                $childNodes = [System.Collections.Generic.List[object]]::new()
                if ($isMap -and $spec['Children']) {
                    $k = 0
                    foreach ($child in @($spec['Children'])) { $k++; $childNodes.Add((& $prepare $child ($depth + 1) "child $k of $label")) }
                }
                [pscustomobject]@{ Spec = $spec; Slot = $slot; Children = $childNodes }
            }
            $nodes = [System.Collections.Generic.List[object]]::new()
            $n = 0
            foreach ($spec in $Item) { $n++; $nodes.Add((& $prepare $spec 0 "item $n")) }
            Convert-VellumPdfSlotText -Slot $slotList.ToArray() -Culture $cultures.Culture -InputCulture $cultures.InputCulture `
                -NoNumberParsing:$NoNumberParsing -CommandName 'Add-VellumPdfList'
            $rebuild = {
                param($node)
                if ($node.Spec -isnot [System.Collections.IDictionary]) { return $node.Slot.Text }
                $copy = @{}
                foreach ($key in $node.Spec.Keys) { $copy[$key] = $node.Spec[$key] }
                $copy['Text'] = $node.Slot.Text
                if ($node.Children.Count -gt 0) { $copy['Children'] = @(foreach ($childNode in $node.Children) { & $rebuild $childNode }) }
                $copy
            }
            $convertedItem = @(foreach ($node in $nodes) { & $rebuild $node })

            # Gather every label (recursing into nested children) for the encoding
            # warning, which scans for characters the base-14 fonts cannot render.
            $collectAllText = {
                param($spec, $depth = 0)
                if ($depth -gt $maxDepth) { throw (& $depthError) }
                if ($spec -is [System.Collections.IDictionary]) {
                    if ($spec.Contains('Text')) { [string]$spec['Text'] }
                    if ($spec['Children']) { foreach ($c in @($spec['Children'])) { & $collectAllText $c ($depth + 1) } }
                }
                else { [string]$spec }
            }
            $collectText = {
                param($spec, $depth = 0)
                if ($depth -gt $maxDepth) { throw (& $depthError) }
                if ($spec -is [System.Collections.IDictionary]) {
                    if (-not $spec.Contains('Text')) {
                        throw "Add-VellumPdfList: a nested-item hashtable must include a 'Text' key."
                    }
                    if (-not $FontHandle -and -not $spec['FontHandle']) { [string]$spec['Text'] }
                    if ($spec['Children']) { foreach ($c in @($spec['Children'])) { & $collectText $c ($depth + 1) } }
                }
                elseif (-not $FontHandle) {
                    [string]$spec
                }
            }
            if ($FontHandle) {
                $itemTexts = @(foreach ($spec in $convertedItem) { & $collectAllText $spec })
                Write-VellumPdfMissingGlyphWarning -Handle $FontHandle -Text $itemTexts -CommandName 'Add-VellumPdfList'
            }
            Write-VellumPdfEncodingWarning -Text @(foreach ($spec in $convertedItem) { & $collectText $spec }) `
                -CommandName 'Add-VellumPdfList'
            $listStyle = [VellumPdf.Layout.Elements.ListStyle]::$Style

            # Build an empty typed list to satisfy the IEnumerable<ListItem> ctor param.
            $emptyItems = [System.Collections.Generic.List[VellumPdf.Layout.Elements.ListItem]]::new()
            $list = [VellumPdf.Layout.Elements.ListElement]::new($listStyle, $emptyItems)

            # Apply indent when supplied.
            if ($PSBoundParameters.ContainsKey('Indent')) {
                $list.Indent = $Indent
            }

            # List-level style values. Gaps are filled from the document defaults: a
            # style without a font renders in the library-global Helvetica, not the
            # document default. A $null style lets an item fall back to the engine's
            # list default.
            $default = Resolve-VellumPdfDefault -Document $Document
            $listColor = if ($PSBoundParameters.ContainsKey('Color')) { ConvertTo-VellumColor $Color -Document $Document -CommandName 'Add-VellumPdfList' }
            $base = @{
                Font       = if ($Font) { $Font } else { $default.Font }
                FontSize   = if ($PSBoundParameters.ContainsKey('FontSize')) { $FontSize } else { $default.FontSize }
                FontHandle = $FontHandle
                Color      = $listColor
                Leading    = if ($PSBoundParameters.ContainsKey('Leading')) { $Leading }
            }
            $fontNames = [Enum]::GetNames([VellumPdf.Fonts.Standard14])

            # Builds the TextStyle for one item: the list-level values overlaid with
            # the item's own Font/FontSize/FontHandle/Color/Leading keys.
            $styleFor = {
                param($spec)
                # Always an explicit style: a TextStyle without a font would draw in the library-global
                # Helvetica instead of the document's default font.
                $handle = $FontHandle
                if ($spec -is [System.Collections.IDictionary] -and $spec['FontHandle']) {
                    if ($spec['FontHandle'] -isnot [VellumPdf.Fonts.EmbeddedFontHandle]) {
                        throw 'Add-VellumPdfList: item FontHandle must be an EmbeddedFontHandle from Register-VellumPdfFont.'
                    }
                    Assert-VellumPdfFontHandle -FontHandle $spec['FontHandle'] -Document $Document -CommandName 'Add-VellumPdfList'
                    $handle = $spec['FontHandle']
                }
                $itemFont = $base.Font
                if ($spec -is [System.Collections.IDictionary] -and $spec['Font']) {
                    if ([string]$spec['Font'] -notin $fontNames) {
                        throw "Add-VellumPdfList: item Font '$($spec['Font'])' is not a base-14 font name."
                    }
                    $itemFont = [string]$spec['Font']
                    if (-not $spec['FontHandle']) { $handle = $null }   # an explicit base-14 Font on the item wins over the list handle
                }
                $size = $base.FontSize
                if ($spec -is [System.Collections.IDictionary] -and $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-VellumPdfList: item FontSize must be between 1 and 1000; got '$($spec['FontSize'])'."
                    }
                }
                $args2 = @{ FontSize = $size }
                if ($handle) { $args2['FontHandle'] = $handle } else { $args2['Font'] = $itemFont }
                $colorValue = $base.Color
                if ($spec -is [System.Collections.IDictionary] -and $spec['Color']) { $colorValue = ConvertTo-VellumColor $spec['Color'] -Document $Document -CommandName 'Add-VellumPdfList' }
                if ($colorValue) { $args2['Color'] = $colorValue }
                $lead = $base.Leading
                if ($spec -is [System.Collections.IDictionary] -and $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-VellumPdfList: item Leading must be between 0 and 1000; got '$($spec['Leading'])'."
                    }
                }
                if ($null -ne $lead) { $args2['Leading'] = $lead }
                New-VellumTextStyle @args2 -Document $Document -CommandName 'Add-VellumPdfList'
            }

            # Build each item (recursing into Children via ListItem.AddChild).
            $buildItem = {
                param($spec)
                $text = if ($spec -is [System.Collections.IDictionary]) { [string]$spec['Text'] } else { [string]$spec }
                $li = [VellumPdf.Layout.Elements.ListItem]::new($text, (& $styleFor $spec))
                $lang = if ($spec -is [System.Collections.IDictionary] -and $spec['Language']) { [string]$spec['Language'] }
                        elseif ($Language) { $Language }
                if ($lang) { $li.Language = $lang }
                if ($spec -is [System.Collections.IDictionary] -and $spec['Children']) {
                    foreach ($child in @($spec['Children'])) {
                        [void]$li.AddChild((& $buildItem $child))
                    }
                }
                $li
            }
            foreach ($spec in $convertedItem) {
                [void]$list.Add((& $buildItem $spec))
            }

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

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