Public/Add-VellumPdfFormField.ps1

function Add-VellumPdfFormField {
    <#
    .SYNOPSIS
        Adds a fillable AcroForm field (text, check box, choice, radio group, button) to a page.
    .DESCRIPTION
        Places an interactive form field on a page of a page-based document, at the
        rectangle (-X, -Y, -Width, -Height) in points from the page's bottom-left.
        Types:

          Text a text box; -Value is the initial text, -Multiline allows several lines
          CheckBox -Checked sets the initial state
          Choice a combo box (drop-down, default) or, with -List, a list box;
                      -Option lists the entries and -Selected the initial one
          Radio a group of mutually exclusive buttons; -RadioOption describes the
                      buttons (page, rectangle and export value of each) and -Selected
                      the export value that starts selected
          PushButton a button labelled -Caption

        -Name is the field's unique name (what a form processor reads); for Radio it
        names the group. Field names, values and captions may be Unicode, but the
        text drawn in the field's appearance supports Latin-1 and the Windows-1252
        punctuation (euro, curly quotes, dashes, ...) only; anything else is refused
        here rather than failing when the file is saved. Field appearances use
        Helvetica (and ZapfDingbats for check marks), which VellumPdf does not embed;
        ISO 19005-2 requires every font to be embedded, so a PDF/A page document
        cannot contain form fields and the cmdlet refuses them there. (The in-process
        preflight does not inspect fonts used only by form fields, so it will not
        flag such a file; a certified validator such as veraPDF does.)
    .PARAMETER Document
        A document from New-VellumPdfPageDocument.
    .PARAMETER Type
        Text, CheckBox, Choice, Radio or PushButton.
    .PARAMETER Name
        The field name (unique within the document).
    .PARAMETER Page
        The 1-based page number (Text, CheckBox, Choice, PushButton). Default 1.
    .PARAMETER X
        Left edge in points.
    .PARAMETER Y
        Bottom edge in points.
    .PARAMETER Width
        Width in points.
    .PARAMETER Height
        Height in points.
    .PARAMETER Value
        Text: the initial text.
    .PARAMETER Checked
        CheckBox: start checked.
    .PARAMETER Option
        Choice: the list of entries.
    .PARAMETER Selected
        Choice: the initially selected entry. Radio: the initially selected export value. Matching ignores
        case, and the field stores the entry's own spelling ('large' selects the option 'Large' and writes
        Large). If two entries differ only in case and -Selected is not an exact match for one of them, the
        choice is ambiguous and an error is raised.
    .PARAMETER List
        Choice: a list box instead of a drop-down combo box.
    .PARAMETER RadioOption
        Radio: one hashtable per button: @{ Page = 1; X = 100; Y = 500; Width = 14; Height = 14; ExportValue = 'yes' }
    .PARAMETER Caption
        PushButton: the label.
    .PARAMETER FontSize
        Text size in the field (default 12). Must be a finite number greater than 0.
    .PARAMETER ReadOnly
        The user cannot change the field.
    .PARAMETER Required
        The field must be filled in before submitting.
    .PARAMETER Multiline
        Text: allow several lines.
    .EXAMPLE
        $doc | Add-VellumPdfFormField Text -Name email -X 72 -Y 700 -Width 250 -Height 20 -Value 'you@example.com'
    .EXAMPLE
        $doc | Add-VellumPdfFormField Radio -Name plan -Selected basic -RadioOption @(
            @{ X = 72; Y = 650; Width = 14; Height = 14; ExportValue = 'basic' },
            @{ X = 72; Y = 625; Width = 14; Height = 14; ExportValue = 'pro' })
    .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('Text', 'CheckBox', 'Choice', 'Radio', 'PushButton')]
        [string]$Type,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

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

        [double]$X, [double]$Y,
        [ValidateRange(0.01, 1000000.0)] [double]$Width,
        [ValidateRange(0.01, 1000000.0)] [double]$Height,

        [string]$Value = '',
        [switch]$Checked,
        [string[]]$Option,
        [string]$Selected,
        [switch]$List,
        [hashtable[]]$RadioOption,
        [string]$Caption,

        [double]$FontSize = 12,
        [switch]$ReadOnly,
        [switch]$Required,
        [switch]$Multiline
    )

    process {
        try {
            Assert-VellumPdfDocumentOpen -Document $Document -CommandName 'Add-VellumPdfFormField'
            $Page = Resolve-VellumPdfPageNumber -Document $Document -Page $(if ($PSBoundParameters.ContainsKey('Page')) { $PSBoundParameters['Page'] } else { 1 }) -Bound $PSBoundParameters.ContainsKey('Page') -CommandName 'Add-VellumPdfFormField'
            $bound = $PSBoundParameters

            if ($Document.Conformance -ne [VellumPdf.Document.PdfConformance]::None) {
                throw ('Add-VellumPdfFormField: form fields draw with the non-embedded Helvetica font, which PDF/A forbids. ' +
                    'Create the page document without -Conformance to use forms.')
            }
            Write-VellumPdfUntaggedWarning -Document $Document -CommandName 'Add-VellumPdfFormField' -What 'a form field'
            if (-not [double]::IsFinite($FontSize) -or $FontSize -le 0) {
                throw "Add-VellumPdfFormField: -FontSize must be a finite number greater than 0; got $FontSize."
            }

            # Options that belong to one field type only.
            $only = @{
                Value = 'Text'; Multiline = 'Text'; Checked = 'CheckBox'; Option = 'Choice'; List = 'Choice'
                RadioOption = 'Radio'; Caption = 'PushButton'
            }
            foreach ($n in $only.Keys) {
                if ($bound.ContainsKey($n) -and $only[$n] -ne $Type) { throw "Add-VellumPdfFormField: -$n applies to -Type $($only[$n]) only." }
            }
            if ($bound.ContainsKey('Selected') -and $Type -notin 'Choice', 'Radio') {
                throw 'Add-VellumPdfFormField: -Selected applies to -Type Choice and Radio only.'
            }

            # The appearance stream can draw Latin-1 and the Windows-1252 punctuation; anything else makes Save fail
            # (after the document is spent), so check here.
            $assertDrawable = {
                param([string]$What, [string]$Text)
                foreach ($ch in $Text.ToCharArray()) {
                    $code = [int]$ch
                    if (-not (Test-VellumPdfWinAnsiChar -CodeUnit $code)) {
                        throw ("Add-VellumPdfFormField: $What contains U+$($code.ToString('X4')), which the field appearance cannot draw " +
                            '(Latin-1 and Windows-1252 punctuation only).')
                    }
                }
            }

            # -Selected matches case-insensitively but the field must hold the entry's own spelling, otherwise the
            # stored value names no option (and no radio button is switched on). An exact-case match wins; entries
            # that differ only in case make any other spelling ambiguous.
            $resolveSelected = {
                param([string[]]$Candidates, [string]$Description)
                $exact = @($Candidates | Where-Object { $_ -ceq $Selected })
                if ($exact.Count -gt 0) { return $exact[0] }
                $loose = @($Candidates | Where-Object { $_ -eq $Selected } | Select-Object -Unique)
                if ($loose.Count -eq 0) { throw "Add-VellumPdfFormField: -Selected '$Selected' is not $Description." }
                if ($loose.Count -gt 1) {
                    throw ("Add-VellumPdfFormField: -Selected '$Selected' matches several entries that differ only in case " +
                        "($($loose -join ', ')); give the exact spelling.")
                }
                $loose[0]
            }

            $fieldOptions = [VellumPdf.Forms.FormFieldOptions]::new()
            $fieldOptions.FontSize = $FontSize
            if ($ReadOnly) { $fieldOptions.ReadOnly = $true }
            if ($Required) { $fieldOptions.Required = $true }
            if ($Multiline) { $fieldOptions.Multiline = $true }

            $rect = {
                param($x, $y, $w, $h, $what)
                foreach ($v in $x, $y, $w, $h) {
                    if ($null -eq $v -or -not [double]::IsFinite([double]$v)) { throw "Add-VellumPdfFormField: $what needs finite X, Y, Width and Height." }
                }
                if ([double]$w -le 0 -or [double]$h -le 0) { throw "Add-VellumPdfFormField: $what needs a positive Width and Height." }
                [VellumPdf.Document.PdfRectangle]::new([double]$x, [double]$y, [double]$x + [double]$w, [double]$y + [double]$h)
            }

            if ($Type -eq 'Radio') {
                foreach ($n in 'Page', 'X', 'Y', 'Width', 'Height') {
                    if ($bound.ContainsKey($n)) {
                        throw "Add-VellumPdfFormField: -$n does not apply to -Type Radio; give each button's own Page/X/Y/Width/Height in -RadioOption."
                    }
                }
            }
            else {
                foreach ($req in 'X', 'Y', 'Width', 'Height') {
                    if (-not $bound.ContainsKey($req)) { throw "Add-VellumPdfFormField: -Type $Type needs -$req." }
                }
                $pdfPage = Get-VellumPdfPage -Document $Document -Page $Page -CommandName 'Add-VellumPdfFormField'
                $box = & $rect $X $Y $Width $Height "-Type $Type"
            }

            switch ($Type) {
                'Text' {
                    & $assertDrawable '-Value' $Value
                    $Document.AddTextField($pdfPage, $Name, $box, $Value, $fieldOptions)
                }
                'CheckBox' { $Document.AddCheckBox($pdfPage, $Name, $box, [bool]$Checked, $fieldOptions) }
                'Choice' {
                    if (-not $Option -or $Option.Count -eq 0) { throw 'Add-VellumPdfFormField: -Type Choice needs -Option.' }
                    foreach ($o in $Option) { & $assertDrawable "option '$o'" $o }
                    $sel = if ($bound.ContainsKey('Selected')) { & $resolveSelected $Option 'one of the -Option entries' } else { $null }
                    $Document.AddChoiceField($pdfPage, $Name, $box, [string[]]$Option, $sel, (-not $List), $fieldOptions)
                }
                'PushButton' {
                    if (-not $bound.ContainsKey('Caption')) { throw 'Add-VellumPdfFormField: -Type PushButton needs -Caption.' }
                    & $assertDrawable '-Caption' $Caption
                    $Document.AddPushButton($pdfPage, $Name, $box, $Caption, $fieldOptions)
                }
                'Radio' {
                    if (-not $RadioOption -or $RadioOption.Count -eq 0) { throw 'Add-VellumPdfFormField: -Type Radio needs -RadioOption.' }
                    $buttons = [System.Collections.Generic.List[VellumPdf.Forms.RadioOption]]::new()
                    $i = 0
                    foreach ($ro in $RadioOption) {
                        $i++
                        if (-not $ro.ContainsKey('ExportValue') -or [string]::IsNullOrEmpty([string]$ro['ExportValue'])) {
                            throw "Add-VellumPdfFormField: radio button $i needs an ExportValue."
                        }
                        $roPage = Get-VellumPdfPage -Document $Document -Page $(if ($ro.ContainsKey('Page')) { [int]$ro['Page'] } else { $Page }) -CommandName 'Add-VellumPdfFormField'
                        $roBox = & $rect $ro['X'] $ro['Y'] $ro['Width'] $ro['Height'] "radio button $i"
                        $buttons.Add([VellumPdf.Forms.RadioOption]::new($roPage, $roBox, [string]$ro['ExportValue']))
                    }
                    $sel = if ($bound.ContainsKey('Selected')) { & $resolveSelected @($buttons | ForEach-Object { $_.ExportValue }) 'an ExportValue of any radio button' } else { $null }
                    $Document.AddRadioButtonGroup($Name, $buttons, $sel, $fieldOptions)
                }
            }
            $Document
        }
        catch {
            $PSCmdlet.ThrowTerminatingError((Resolve-VellumPdfErrorRecord -ErrorRecord $_ -CommandName 'Add-VellumPdfFormField'))
        }
    }
}