Private/Get-VellumPdfBlockVariable.ps1

function Get-VellumPdfBlockVariable {
    <#
    .SYNOPSIS
        Snapshots the caller's variables that a script block uses, for a block that runs later.
    .DESCRIPTION
        Add-VellumPdfDrawing runs its script block while the document is saved. Without help the block
        would see the variables as they are then: a loop variable holds its last value and a function's
        locals are gone. This returns copies of the variables the block reads (unqualified names only,
        taken from the scope the block was written in) so the block can run with the values they had
        when it was added.

        ScriptBlock.GetNewClosure() cannot do this from inside a module (it captures the module's own
        scope) and would also cut the block off from $script: and $global:, so the block stays bound to
        its own session state and the copies are supplied when it is invoked (ScriptBlock.InvokeWithContext).
        Returns an empty array when the block's session state cannot be reached.
    #>

    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSVariable[]])]
    param([Parameter(Mandatory)] [scriptblock]$ScriptBlock)

    $state = $null
    try {
        $property = [scriptblock].GetProperty('SessionState', [System.Reflection.BindingFlags]'Instance,NonPublic')
        if ($property) { $state = $property.GetValue($ScriptBlock) }
    }
    catch { Write-Verbose "The script block's session state is not reachable: $_" }
    if (-not $state) { return @() }

    # Names that must keep their live meaning: automatic variables and the block's own parameters.
    $skip = [System.Collections.Generic.HashSet[string]]::new([string[]]@(
            '_', 'args', 'input', 'this', 'PSItem', 'true', 'false', 'null', 'PSCmdlet', 'MyInvocation',
            'PSBoundParameters', 'PSScriptRoot', 'PSCommandPath', 'ExecutionContext', 'Error', 'LASTEXITCODE'),
        [System.StringComparer]::OrdinalIgnoreCase)
    foreach ($parameter in @($ScriptBlock.Ast.ParamBlock?.Parameters)) {
        if ($parameter) { [void]$skip.Add($parameter.Name.VariablePath.UserPath) }
    }

    $nodes = $ScriptBlock.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] }, $true)
    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($node in $nodes) {
        $path = $node.VariablePath
        if (-not $path.IsUnscopedVariable) { continue }   # $script:x, $global:x, $using:x keep their live meaning
        $name = $path.UserPath
        if ($skip.Contains($name) -or -not $seen.Add($name)) { continue }
        $found = $state.PSVariable.Get($name)
        if ($found -and -not ($found.Options -band [System.Management.Automation.ScopedItemOptions]'ReadOnly,Constant')) {
            , [System.Management.Automation.PSVariable]::new($name, $found.Value)
        }
    }
}