Public/Add-VellumPdfShape.ps1

function Add-VellumPdfShape {
    <#
    .SYNOPSIS
        Draws a rectangle, ellipse, arc or line on a page of a page-based document.
    .DESCRIPTION
        Coordinates are PDF points from the page's bottom-left corner (72 pt = 1
        inch). Rectangle and Ellipse take the lower-left corner (-X, -Y) and the
        size (-Width, -Height); an ellipse is inscribed in that box. A Line runs
        from (-X, -Y) to (-X2, -Y2). An Arc is part of a circle: (-X, -Y) is the centre,
        -Radius its size, and -StartAngle/-EndAngle (degrees, counter-clockwise from 3
        o'clock) the span; it is stroked, and a fill paints the segment between the arc
        and its chord.

        Colours are RGB triples in 0..1, hex ('#3366cc') or names; for CMYK use
        -FillCmyk / -StrokeCmyk with four values in 0..1. For colour-managed output give an
        ICC profile with -IccProfilePath and the colour in that profile's components with
        -FillIcc / -StrokeIcc (1, 3 or 4 values in 0..1 for a Gray, RGB/Lab or CMYK profile). A shape with neither a
        fill nor a stroke is stroked in black; a line is always stroked. Opacity is
        0 (transparent) to 1 (opaque).
    .PARAMETER Document
        A document from New-VellumPdfPageDocument.
    .PARAMETER Page
        The 1-based page number. Default 1.
    .PARAMETER Shape
        Rectangle, Ellipse, Arc or Line.
    .PARAMETER X
        Left edge (Rectangle/Ellipse) or start x (Line), in points.
    .PARAMETER Y
        Bottom edge (Rectangle/Ellipse) or start y (Line), in points.
    .PARAMETER Width
        Width of a Rectangle or Ellipse.
    .PARAMETER Height
        Height of a Rectangle or Ellipse.
    .PARAMETER Radius
        Arc: the radius in points.
    .PARAMETER StartAngle
        Arc: start angle in degrees, counter-clockwise from 3 o'clock.
    .PARAMETER EndAngle
        Arc: end angle in degrees (greater than -StartAngle for a counter-clockwise sweep).
    .PARAMETER IccProfilePath
        An ICC profile (.icc/.icm) that -FillIcc / -StrokeIcc colours are expressed in. The
        profile is embedded once per page and shared by all shapes that use it.
    .PARAMETER FillIcc
        Fill colour as components of the ICC profile (1, 3 or 4 values in 0..1).
    .PARAMETER StrokeIcc
        Outline colour as components of the ICC profile.
    .PARAMETER X2
        End x of a Line.
    .PARAMETER Y2
        End y of a Line.
    .PARAMETER FillColor
        Fill colour (RGB triple, hex or name).
    .PARAMETER FillGray
        Fill colour as a grey level, 0 (black) to 1 (white), written as DeviceGray.
    .PARAMETER StrokeGray
        Outline colour as a grey level, 0 (black) to 1 (white).
    .PARAMETER FillCmyk
        Fill colour as C,M,Y,K in 0..1.
    .PARAMETER StrokeColor
        Outline colour (RGB triple, hex or name).
    .PARAMETER StrokeCmyk
        Outline colour as C,M,Y,K in 0..1.
    .PARAMETER StrokeWidth
        Outline width in points (default 1).
    .PARAMETER LineCap
        How open line ends look: Butt (default), Round or Square.
    .PARAMETER LineJoin
        How corners look: Miter (default), Round or Bevel.
    .PARAMETER MiterLimit
        Longest miter, as a multiple of the line width, before a Miter join is bevelled.
    .PARAMETER AltText
        Alternate text. In a tagged page document the shape is then a Figure with this
        text; without it the shape is an artifact that assistive technology skips.
    .PARAMETER Dash
        Dash pattern in points, e.g. 4,2 (4 on, 2 off).
    .PARAMETER Opacity
        Fill opacity 0..1.
    .PARAMETER StrokeOpacity
        Outline opacity 0..1.
    .EXAMPLE
        $doc | Add-VellumPdfShape Rectangle -X 72 -Y 600 -Width 200 -Height 80 -FillColor '#e8f0fe' -StrokeColor navy
    .EXAMPLE
        $doc | Add-VellumPdfShape Line -X 72 -Y 590 -X2 523 -Y2 590 -StrokeWidth 2 -Dash 6,3
    .OUTPUTS
        VellumPdf.Document.PdfDocument (the same instance, for chaining)
    #>

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

        [Parameter(Mandatory, Position = 0)]
        [ValidateSet('Rectangle', 'Ellipse', 'Arc', 'Line')]
        [string]$Shape,

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

        [Parameter(Mandatory)] [double]$X,
        [Parameter(Mandatory)] [double]$Y,
        [double]$Width,
        [double]$Height,
        [double]$X2,
        [double]$Y2,
        [ValidateRange(0.01, 1000000.0)] [double]$Radius,
        [double]$StartAngle,
        [double]$EndAngle,

        [string]$IccProfilePath,
        [ValidateRange(0.0, 1.0)] [double[]]$FillIcc,
        [ValidateRange(0.0, 1.0)] [double[]]$StrokeIcc,

        [object]$FillColor,
        [ValidateCount(4, 4)] [ValidateRange(0.0, 1.0)] [double[]]$FillCmyk,
        [ValidateRange(0.0, 1.0)] [double]$FillGray,
        [object]$StrokeColor,
        [ValidateCount(4, 4)] [ValidateRange(0.0, 1.0)] [double[]]$StrokeCmyk,
        [ValidateRange(0.0, 1.0)] [double]$StrokeGray,

        [ValidateRange(0.0, 10000.0)]
        [double]$StrokeWidth = 1,

        [ValidateRange(0.01, 10000.0)]
        [double[]]$Dash,

        [ValidateRange(0.0, 1.0)]
        [double]$Opacity,

        [ValidateRange(0.0, 1.0)]
        [double]$StrokeOpacity,

        [ValidateSet('Butt', 'Round', 'Square')]
        [string]$LineCap,

        [ValidateSet('Miter', 'Round', 'Bevel')]
        [string]$LineJoin,

        [ValidateRange(1.0, 100.0)]
        [double]$MiterLimit,

        # Alternate text: in a tagged document the shape becomes a Figure instead of an artifact.
        [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) }, ErrorMessage = '-AltText must not be empty or whitespace.')]
        [string]$AltText
    )

    process {
        try {
            Assert-VellumPdfDocumentOpen -Document $Document -CommandName 'Add-VellumPdfShape'
            $Page = Resolve-VellumPdfPageNumber -Document $Document -Page $(if ($PSBoundParameters.ContainsKey('Page')) { $PSBoundParameters['Page'] } else { 1 }) -Bound $PSBoundParameters.ContainsKey('Page') -CommandName 'Add-VellumPdfShape'
            $bound = $PSBoundParameters
            foreach ($pair in @('X', $X), @('Y', $Y), @('Width', $Width), @('Height', $Height), @('X2', $X2), @('Y2', $Y2), @('Radius', $Radius)) {
                Assert-VellumPdfCoordinate $pair[1] $pair[0] 'Add-VellumPdfShape'
            }
            foreach ($angle in $StartAngle, $EndAngle) {
                if (-not [double]::IsFinite($angle)) { throw 'Add-VellumPdfShape: angles must be finite numbers.' }
            }
            if ($Shape -eq 'Line') {
                if (-not $bound.ContainsKey('X2') -or -not $bound.ContainsKey('Y2')) {
                    throw 'Add-VellumPdfShape: a Line needs -X2 and -Y2.'
                }
                if ($bound.ContainsKey('FillColor') -or $bound.ContainsKey('FillCmyk') -or $bound.ContainsKey('FillGray') -or $bound.ContainsKey('Opacity')) {
                    throw 'Add-VellumPdfShape: a Line has no fill; use -StrokeColor/-StrokeCmyk/-StrokeGray/-StrokeOpacity.'
                }
            }
            elseif ($Shape -eq 'Arc') {
                foreach ($req in 'Radius', 'StartAngle', 'EndAngle') {
                    if (-not $bound.ContainsKey($req)) { throw "Add-VellumPdfShape: an Arc needs -$req." }
                }
                foreach ($no in 'Width', 'Height', 'X2', 'Y2') {
                    if ($bound.ContainsKey($no)) { throw "Add-VellumPdfShape: -$no does not apply to an Arc; its size is -Radius." }
                }
            }
            else {
                if ($Width -le 0 -or $Height -le 0 -or -not $bound.ContainsKey('Width') -or -not $bound.ContainsKey('Height')) {
                    throw "Add-VellumPdfShape: a $Shape needs a positive -Width and -Height."
                }
                if ($bound.ContainsKey('X2') -or $bound.ContainsKey('Y2')) { throw "Add-VellumPdfShape: -X2/-Y2 apply to a Line only." }
            }
            if ($Shape -ne 'Arc') {
                foreach ($no in 'Radius', 'StartAngle', 'EndAngle') {
                    if ($bound.ContainsKey($no)) { throw "Add-VellumPdfShape: -$no applies to an Arc only." }
                }
            }
            foreach ($side in 'Fill', 'Stroke') {
                $models = @(@("${side}Color", "${side}Cmyk", "${side}Icc", "${side}Gray") | Where-Object { $bound.ContainsKey($_) })
                if ($models.Count -gt 1) { throw "Add-VellumPdfShape: give the $($side.ToLower()) colour as RGB/hex/name, CMYK, grey or ICC components, not several." }
            }
            $hasIcc = $bound.ContainsKey('FillIcc') -or $bound.ContainsKey('StrokeIcc')
            if ($hasIcc -ne $bound.ContainsKey('IccProfilePath')) {
                throw 'Add-VellumPdfShape: -IccProfilePath and -FillIcc/-StrokeIcc go together; give both.'
            }
            if ($Shape -eq 'Line' -and $bound.ContainsKey('FillIcc')) { throw 'Add-VellumPdfShape: a Line has no fill; use -StrokeIcc.' }
            if (($bound.ContainsKey('FillIcc') -and ($bound.ContainsKey('FillColor') -or $bound.ContainsKey('FillCmyk'))) -or
                ($bound.ContainsKey('StrokeIcc') -and ($bound.ContainsKey('StrokeColor') -or $bound.ContainsKey('StrokeCmyk')))) {
                throw 'Add-VellumPdfShape: give a colour as RGB/hex/name, CMYK, or ICC components, not several.'
            }
            if (($bound.ContainsKey('FillColor') -and $bound.ContainsKey('FillCmyk')) -or
                ($bound.ContainsKey('StrokeColor') -and $bound.ContainsKey('StrokeCmyk'))) {
                throw 'Add-VellumPdfShape: give a colour as RGB/hex/name or as CMYK, not both.'
            }

            if (($bound.ContainsKey('FillCmyk') -or $bound.ContainsKey('StrokeCmyk')) -and
                (Get-VellumPdfConformanceRequirement -Document $Document).IsPdfA -and
                -not $Document.PSObject.Properties['PSVellumCmykIntent']) {
                Write-Warning ('Add-VellumPdfShape: PDF/A allows CMYK colour only with a CMYK output intent. Call ' +
                    'Set-VellumPdfOutputIntent -Cmyk on this document, or use RGB colours.')
            }
            $canvas = Get-VellumPdfPageCanvas -Document $Document -Page $Page -CommandName 'Add-VellumPdfShape'
            $iccName = $null
            if ($hasIcc) {
                $icc = Register-VellumPdfIccSpace -Document $Document -Page $Page -IccProfilePath $IccProfilePath -CommandName 'Add-VellumPdfShape'
                $iccCount = $icc.Count
                $iccName = $icc.Name
                foreach ($pair in @(@('FillIcc', $FillIcc), @('StrokeIcc', $StrokeIcc))) {
                    if ($bound.ContainsKey($pair[0]) -and @($pair[1]).Count -ne $iccCount) {
                        throw "Add-VellumPdfShape: -$($pair[0]) needs $iccCount value(s) for this profile; got $(@($pair[1]).Count)."
                    }
                }
            }
            $hasFill = $bound.ContainsKey('FillColor') -or $bound.ContainsKey('FillCmyk') -or $bound.ContainsKey('FillIcc') -or $bound.ContainsKey('FillGray')
            # Any stroke option asks for an outline (black unless a colour is given), even beside a fill.
            $strokeRequested = $bound.ContainsKey('LineCap') -or $bound.ContainsKey('LineJoin') -or $bound.ContainsKey('MiterLimit') -or $bound.ContainsKey('StrokeColor') -or $bound.ContainsKey('StrokeCmyk') -or $bound.ContainsKey('StrokeIcc') -or $bound.ContainsKey('StrokeGray') -or $bound.ContainsKey('StrokeWidth') -or
                $bound.ContainsKey('Dash') -or $bound.ContainsKey('StrokeOpacity')
            $hasStroke = $strokeRequested -or $Shape -eq 'Line' -or -not $hasFill

            [void]$canvas.SaveState()
            try {
                # Tagged documents: decorative graphics are artifacts; -AltText makes the shape a Figure.
                $tagKind = if ($AltText) { 'Structure' } else { 'Artifact' }
                Invoke-VellumPdfTagged -Document $Document -Canvas $canvas -PdfPage $Document.Pages[$Page - 1] -Kind $tagKind -StructType Figure -AltText $AltText -Draw {
                if ($bound.ContainsKey('FillColor')) { $c = ConvertTo-VellumColor $FillColor -Document $Document -CommandName 'Add-VellumPdfShape'; [void]$canvas.SetFillColorRgb($c[0], $c[1], $c[2]) }
                if ($bound.ContainsKey('FillGray')) { [void]$canvas.SetFillColorGray($FillGray) }
                if ($bound.ContainsKey('FillCmyk')) { [void]$canvas.SetFillColorCmyk($FillCmyk[0], $FillCmyk[1], $FillCmyk[2], $FillCmyk[3]) }
                if ($bound.ContainsKey('StrokeColor')) { $c = ConvertTo-VellumColor $StrokeColor -Document $Document -CommandName 'Add-VellumPdfShape'; [void]$canvas.SetStrokeColorRgb($c[0], $c[1], $c[2]) }
                if ($bound.ContainsKey('StrokeGray')) { [void]$canvas.SetStrokeColorGray($StrokeGray) }
                if ($bound.ContainsKey('StrokeCmyk')) { [void]$canvas.SetStrokeColorCmyk($StrokeCmyk[0], $StrokeCmyk[1], $StrokeCmyk[2], $StrokeCmyk[3]) }
                if ($bound.ContainsKey('FillIcc')) {
                    [void]$canvas.SetFillColorSpace($iccName)
                    [void][PSVellumPDF.Interop.CanvasHelpers]::SetFillColor($canvas, $FillIcc)
                }
                if ($bound.ContainsKey('StrokeIcc')) {
                    [void]$canvas.SetStrokeColorSpace($iccName)
                    [void][PSVellumPDF.Interop.CanvasHelpers]::SetStrokeColor($canvas, $StrokeIcc)
                }
                if ($bound.ContainsKey('Opacity')) { [void]$canvas.SetFillAlpha($Opacity) }
                if ($bound.ContainsKey('StrokeOpacity')) { [void]$canvas.SetStrokeAlpha($StrokeOpacity) }
                if ($hasStroke) {
                    [void]$canvas.SetLineWidth($StrokeWidth)
                    if ($bound.ContainsKey('LineCap')) { [void]$canvas.SetLineCap(@{ Butt = 0; Round = 1; Square = 2 }[$LineCap]) }
                    if ($bound.ContainsKey('LineJoin')) { [void]$canvas.SetLineJoin(@{ Miter = 0; Round = 1; Bevel = 2 }[$LineJoin]) }
                    if ($bound.ContainsKey('MiterLimit')) { [void]$canvas.SetMiterLimit($MiterLimit) }
                    if ($Dash) { [void][PSVellumPDF.Interop.CanvasHelpers]::SetLineDash($canvas, $Dash, 0) }
                }

                switch ($Shape) {
                    'Rectangle' { [void]$canvas.Rectangle($X, $Y, $Width, $Height) }
                    'Line' { [void]$canvas.MoveTo($X, $Y).LineTo($X2, $Y2) }
                    'Arc' {
                        $toRadians = [math]::PI / 180
                        $from = $StartAngle * $toRadians
                        # AppendArc extends the current path, so start it at the arc's first point.
                        [void]$canvas.MoveTo($X + $Radius * [math]::Cos($from), $Y + $Radius * [math]::Sin($from))
                        [void]$canvas.AppendArc($X, $Y, $Radius, $from, $EndAngle * $toRadians)
                    }
                    'Ellipse' {
                        # Four cubic Beziers; k is the standard circle-approximation constant.
                        $k = 0.5522847498
                        $rx = $Width / 2; $ry = $Height / 2
                        $cx = $X + $rx; $cy = $Y + $ry
                        [void]$canvas.MoveTo($cx + $rx, $cy)
                        [void]$canvas.CurveTo($cx + $rx, $cy + $k * $ry, $cx + $k * $rx, $cy + $ry, $cx, $cy + $ry)
                        [void]$canvas.CurveTo($cx - $k * $rx, $cy + $ry, $cx - $rx, $cy + $k * $ry, $cx - $rx, $cy)
                        [void]$canvas.CurveTo($cx - $rx, $cy - $k * $ry, $cx - $k * $rx, $cy - $ry, $cx, $cy - $ry)
                        [void]$canvas.CurveTo($cx + $k * $rx, $cy - $ry, $cx + $rx, $cy - $k * $ry, $cx + $rx, $cy)
                        [void]$canvas.ClosePath()
                    }
                }
                if ($Shape -eq 'Line') { [void]$canvas.Stroke() }
                elseif ($hasFill -and $strokeRequested) { [void]$canvas.FillAndStroke() }
                elseif ($hasFill) { [void]$canvas.Fill() }
                else { [void]$canvas.Stroke() }
                }
            }
            finally { [void]$canvas.RestoreState() }
            $Document
        }
        catch {
            $PSCmdlet.ThrowTerminatingError((Resolve-VellumPdfErrorRecord -ErrorRecord $_ -CommandName 'Add-VellumPdfShape'))
        }
    }
}