Public/Add-VellumPdfPieChart.ps1

function Add-VellumPdfPieChart {
    <#
    .SYNOPSIS
        Adds a pie chart to a VellumPdf document.
    .DESCRIPTION
        Wraps VellumPdf.Layout.Elements.PieChart / PieSlice and Document.Add. Give
        the slices explicitly with -Slice, or pipe any objects in and name the
        properties holding the value and the label:

            $doc | Add-VellumPdfPieChart -Slice @(
                @{ Value = 40; Label = 'North' },
                @{ Value = 25; Label = 'South'; Color = 'orange' })

            Get-Process | Group-Object Company |
                Add-VellumPdfPieChart -Document $doc -ValueProperty Count -LabelProperty Name

        A slice without a Color takes the next colour of a built-in palette
        designed to stay distinguishable for colour-blind readers. The engine
        never draws slice labels on the chart; pass -Legend to add a table
        (colour swatch, label, share) below it. In a tagged document the chart is
        a Figure whose alternate text is -AltText, or - when omitted - composed
        from the labels and percentages; use -Decorative when the same numbers
        are already in nearby text.
    .PARAMETER Document
        The live VellumPdf document. Pipe it in with -Slice; pass it by parameter
        when piping the data objects.
    .PARAMETER Slice
        The slices: hashtables with Value (required, finite, >= 0), and optional
        Color (RGB triple, hex or name) and Label, or VellumPdf PieSlice objects.
    .PARAMETER InputObject
        Data objects to chart (pipeline). Use with -ValueProperty.
    .PARAMETER ValueProperty
        The property of each input object holding the slice size.
    .PARAMETER LabelProperty
        The property used as the slice label. When omitted slices are unlabeled.
    .PARAMETER Diameter
        Chart diameter in points (default 200). Wider than the page content area is
        clamped by the engine, and warned about here.
    .PARAMETER StrokeColor
        Colour of the thin separator drawn between slices. Default: none.
    .PARAMETER StrokeWidth
        Separator width in points (default 0.5).
    .PARAMETER Alignment
        Left, Center (default) or Right.
    .PARAMETER StartAngle
        Where the first slice starts, in degrees counter-clockwise from 3 o'clock.
        Default 90 (12 o'clock).
    .PARAMETER CounterClockwise
        Draw the slices counter-clockwise (default clockwise).
    .PARAMETER AltText
        Alternate text for the chart in a tagged document. Default: composed from
        the slice labels and percentages.
    .PARAMETER Decorative
        Mark the chart as an artifact (not read by screen readers). Cannot be
        combined with -AltText.
    .PARAMETER Legend
        Add a table below the chart listing each slice: swatch, label, percentage.
    .PARAMETER FontHandle
        Embedded font for the -Legend table (needed for PDF/A and non-Windows-1252
        labels).
    .PARAMETER LabelFormat
        A .NET format string for the share label of the -Legend, applied to the share as a fraction
        (0.4 is 40 percent): 'P1' (default) gives 40.0 %, 'P0' 40 %, 'N2' 0.40. An invalid format is an
        error, with or without a legend.
    .PARAMETER Culture
        The culture used for the share labels: a name ('de-DE') or a [cultureinfo]. Default: the document's
        -Culture, which is the invariant culture unless New-VellumPdfDocument set one, so the labels no
        longer follow the machine's culture. An unknown name is an error.
    .PARAMETER MarginTop
        Extra spacing in points above the chart.
    .PARAMETER MarginBottom
        Extra spacing in points below the chart.
    .EXAMPLE
        New-VellumPdfDocument -Tagged -Language en |
            Add-VellumPdfPieChart -Legend -Slice @(
                @{ Value = 60; Label = 'Hardware' }, @{ Value = 40; Label = 'Services' }) |
            Save-VellumPdfDocument -Path ./share.pdf
    .EXAMPLE
        $doc | Add-VellumPdfPieChart -Legend -Culture de-DE -LabelFormat 'P0' -Slice @(
            @{ Value = 60; Label = 'Hardware' }, @{ Value = 40; Label = 'Services' })
    .OUTPUTS
        VellumPdf.Layout.Document (the same instance, for chaining)
    #>

    [CmdletBinding(DefaultParameterSetName = 'Slices')]
    [OutputType([VellumPdf.Layout.Document])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Slices')]
        [Parameter(Mandatory, ParameterSetName = 'Data')]
        [VellumPdf.Layout.Document]$Document,

        [Parameter(Mandatory, ParameterSetName = 'Slices')]
        [object[]]$Slice,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Data')]
        [object]$InputObject,

        [Parameter(Mandatory, ParameterSetName = 'Data')]
        [string]$ValueProperty,

        [Parameter(ParameterSetName = 'Data')]
        [string]$LabelProperty,

        [ValidateRange(1.0, 100000.0)]
        [double]$Diameter = 200,

        [object]$StrokeColor,

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

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

        [ValidateRange(-360.0, 360.0)]
        [double]$StartAngle = 90,

        [switch]$CounterClockwise,

        [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) }, ErrorMessage = '-AltText must not be empty or whitespace.')]

        [string]$AltText,

        [switch]$Decorative,

        [switch]$Legend,

        [VellumPdf.Fonts.EmbeddedFontHandle]$FontHandle,

        # .NET format string for the share label of each slice, applied to its share as a fraction
        # (0.4 is 40 percent): 'P1' (default) gives 40.0 %, 'P0' 40 %, 'N2' 0.40.
        [AllowNull()]
        [AllowEmptyString()]
        [string]$LabelFormat = 'P1',

        # Culture used for the share labels; a name or a [cultureinfo].
        # Default: the document's -Culture (invariant unless set).
        [object]$Culture,

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

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

    begin {
        try {
            $collected = [System.Collections.Generic.List[object]]::new()
            $palette = Get-VellumPdfPalette
            # Inside the script block below $PSBoundParameters would be the block's own (empty) dictionary.
            $boundParameters = $PSBoundParameters

            # Builds and adds the chart (and legend) for a list of raw slice specs.
            $buildChart = {
                param([object[]]$Specs)

                Assert-VellumPdfDocumentOpen -Document $Document -CommandName 'Add-VellumPdfPieChart'
                $cultures = Resolve-VellumPdfCulture -Document $Document -Culture $Culture -CultureBound:$boundParameters.ContainsKey('Culture') -CommandName 'Add-VellumPdfPieChart'
                # An invalid -LabelFormat fails whether or not a legend is drawn.
                [void](ConvertTo-VellumPdfText -Value 0.5 -Culture $cultures.Culture -Format $LabelFormat -CommandName 'Add-VellumPdfPieChart' -What '-LabelFormat')
                if ($FontHandle) {
                    Assert-VellumPdfFontHandle -FontHandle $FontHandle -Document $Document -CommandName 'Add-VellumPdfPieChart'
                }
                if ($Decorative -and $AltText) {
                    throw 'Add-VellumPdfPieChart: -AltText and -Decorative cannot be combined; a decorative chart has no alternate text.'
                }
                if (-not $Specs -or $Specs.Count -eq 0) {
                    throw 'Add-VellumPdfPieChart: at least one slice is required.'
                }

                # Normalise every spec to (Value, Color triple, Label), validating up
                # front: the engine only throws when the document is saved.
                $items = [System.Collections.Generic.List[object]]::new()
                $sum = 0.0
                $i = 0
                foreach ($spec in $Specs) {
                    if ($spec -is [VellumPdf.Layout.Elements.PieSlice]) {
                        $value = $spec.Value
                        $rgb = @($spec.Color.R, $spec.Color.G, $spec.Color.B)
                        $label = $spec.Label
                    }
                    elseif ($spec -is [System.Collections.IDictionary] -or $spec -is [System.Management.Automation.PSCustomObject]) {
                        $get = { param($k) Get-VellumPdfPropertyValue -InputObject $spec -Name $k }
                        $raw = & $get 'Value'
                        $value = if ($null -ne $raw) { $raw -as [double] }
                        if ($null -eq $value) { throw "Add-VellumPdfPieChart: slice $($i + 1) has no numeric Value (got '$raw')." }
                        $colorSpec = & $get 'Color'
                        $rgb = if ($null -ne $colorSpec) { ConvertTo-VellumColor $colorSpec -Document $Document -CommandName 'Add-VellumPdfPieChart' } else { ConvertTo-VellumColor $palette[$i % $palette.Count] -Document $Document -CommandName 'Add-VellumPdfPieChart' }
                        $label = & $get 'Label'
                    }
                    else {
                        throw "Add-VellumPdfPieChart: slice $($i + 1) must be a hashtable (Value/Color/Label) or a PieSlice; got $($spec.GetType().Name)."
                    }
                    if (-not [double]::IsFinite($value) -or $value -lt 0) {
                        throw "Add-VellumPdfPieChart: slice $($i + 1) Value must be a finite number >= 0; got $value."
                    }
                    $sum += $value
                    $items.Add([pscustomobject]@{ Value = [double]$value; Rgb = $rgb; Label = if ($null -ne $label) { [string]$label } })
                    $i++
                }
                if ($sum -le 0) { throw 'Add-VellumPdfPieChart: the slice values must add up to more than zero.' }

                $contentWidth = $Document.PageSize.Width - $Document.Margins.Left - $Document.Margins.Right
                if ($Diameter -gt $contentWidth) {
                    Write-Warning ("Add-VellumPdfPieChart: -Diameter $Diameter is wider than the $([math]::Round($contentWidth, 1))pt " +
                        'content area; the engine will draw a smaller chart.')
                }

                $slices = [System.Collections.Generic.List[VellumPdf.Layout.Elements.PieSlice]]::new()
                foreach ($item in $items) {
                    $color = [VellumPdf.Layout.Core.ColorRgb]::new($item.Rgb[0], $item.Rgb[1], $item.Rgb[2])
                    $slices.Add([VellumPdf.Layout.Elements.PieSlice]::new($item.Value, $color, $item.Label))
                }

                $chart = [VellumPdf.Layout.Elements.PieChart]::new()
                $chart.Slices = $slices
                $chart.Diameter = $Diameter
                $chart.StrokeWidth = $StrokeWidth
                if ($boundParameters.ContainsKey('StrokeColor')) {
                    $sc = ConvertTo-VellumColor $StrokeColor -Document $Document -CommandName 'Add-VellumPdfPieChart'
                    $chart.StrokeColor = [VellumPdf.Layout.Core.ColorRgb]::new($sc[0], $sc[1], $sc[2])
                }
                $chart.Alignment = [VellumPdf.Layout.Core.HorizontalAlignment]::$Alignment
                # The engine measures the start angle in radians from +X (counter-clockwise).
                $chart.StartAngle = $StartAngle * [math]::PI / 180
                $chart.Clockwise = -not $CounterClockwise
                if ($AltText) { $chart.AltText = $AltText }
                if ($Decorative) { $chart.Decorative = $true }

                Set-VellumPdfElementMargin -Element $chart -Top $MarginTop -Bottom $MarginBottom `
                    -BoundParameters $boundParameters -Document $Document
                [void]$Document.Add($chart)

                if ($Legend) {
                    $rows = [System.Collections.Generic.List[object]]::new()
                    $n = 0
                    foreach ($item in $items) {
                        $hex = '#{0:X2}{1:X2}{2:X2}' -f [int][math]::Round($item.Rgb[0] * 255), [int][math]::Round($item.Rgb[1] * 255), [int][math]::Round($item.Rgb[2] * 255)
                        $name = if ($item.Label) { $item.Label } else { "Slice $($n + 1)" }
                        $share = ConvertTo-VellumPdfText -Value ([double]($item.Value / $sum)) -Culture $cultures.Culture -Format $LabelFormat -CommandName 'Add-VellumPdfPieChart' -What "the share label of slice $($n + 1)"
                        $rows.Add(@(@{ Text = ' '; Background = $hex }, $name, @{ Text = $share; Alignment = 'Right' }))
                        $n++
                    }
                    $tableArgs = @{ Document = $Document; Row = $rows.ToArray(); ColumnWidth = @(18, 0, 60); BorderWidth = 0; NoNumberParsing = $true }
                    if ($FontHandle) { $tableArgs['FontHandle'] = $FontHandle }
                    [void](Add-VellumPdfTable @tableArgs)
                }
            }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError((Resolve-VellumPdfErrorRecord -ErrorRecord $_ -CommandName 'Add-VellumPdfPieChart'))
        }
    }

    process {
        try {
            if ($PSCmdlet.ParameterSetName -eq 'Data') {
                foreach ($item in $InputObject) { $collected.Add($item) }
            }
            else {
                & $buildChart $Slice
                $Document
            }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError((Resolve-VellumPdfErrorRecord -ErrorRecord $_ -CommandName 'Add-VellumPdfPieChart'))
        }
    }

    end {
        try {
            if ($PSCmdlet.ParameterSetName -eq 'Data') {
                $specs = foreach ($obj in $collected) {
                    $label = if ($LabelProperty) { Get-VellumPdfPropertyValue -InputObject $obj -Name $LabelProperty }
                    @{ Value = (Get-VellumPdfPropertyValue -InputObject $obj -Name $ValueProperty); Label = $label }
                }
                & $buildChart @($specs)
                $Document
            }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError((Resolve-VellumPdfErrorRecord -ErrorRecord $_ -CommandName 'Add-VellumPdfPieChart'))
        }
    }
}