Commands/Common/Write-SVG.ps1

function Write-SVG
{
    <#
    .SYNOPSIS
        Writes a SVG element
    .DESCRIPTION
        Writes a Scalable Vector Graphics element.
    .Notes
        While this function can be used directly, it is designed to be the core function that other SVG creation functions call.
    #>

    param(
    # The name of the SVG element.
    [Parameter(Mandatory)]
    [string]
    $ElementName,

    # A dictionary of attributes.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Collections.IDictionary]
    $Attribute = [Ordered]@{},

    # A dictionary of data.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Collections.IDictionary]
    $Data = [Ordered]@{},

    # An object containing content.
    # If this content is XML, it will be added as a child element.
    [Parameter(ValueFromPipelineByPropertyName)]
    [PSObject]
    $Content,

    # One or more child elements. These will be treated as if they were content.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('Child')]
    $Children,

    # A comment that will appear before the element.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('Comments')]
    [string]
    $Comment,

    # A dictionary or object containing event handlers.
    # Each key or property name will be the name of the event
    # Each value will be the handler.
    [Parameter(ValueFromPipelineByPropertyName)]
    $On,

    # An output path.
    [Parameter(ValueFromPipelineByPropertyName)]
    [string]
    $OutputPath   
    )

    begin {
        $myCmd = $MyInvocation.MyCommand
        ${?<CamelCaseSpace>} = [Regex]::new('(?<CamelCaseSpace>(?<=[a-z])(?=[A-Z]))')
    }

    process {        
        # Determine what command we're using to create the elements.
        $elementCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand("SVG.$elementName", 'Function')

        $myParams = [Ordered]@{} + $PSBoundParameters

        # If -Style was passed (and was not a string)
        if ($Attribute['Style'] -and $Attribute['Style'] -isnot [string]) {
            # Turn dictionaries into simple CSS,
            if ($Attribute['Style'] -is [Collections.IDictionary]) {
                $Attribute['Style'] =
                    @(foreach ($kv in $Attribute['Style'].GetEnumerator()) {
                        "$($kv.Key):$($kv.Value)"
                    }) -join ';'
            }
            else {
                # and do the same for other PSObjects.
                $Attribute['Style'] = @(foreach ($prop in $Attribute['Style'].psobject.properties) {
                    "$($prop.Name):$($kv.Value)"
                }) -join ';'
            }
        }        

        # Keep track of which attributes are bound.
        $boundAttributes = @()
        
        # Start creating a tag for our element.
        $elementText = "<$elementName "
        # Next, walk over the attributes of the command
        :nextParameter foreach ($kv in $Attribute.GetEnumerator()) {
            # skip any parameters from Write-SVG.
            if ($myCmd.Parameters[$kv.Key]) { continue }
            $paramValue = $kv.Value
            $paramName  = $kv.Key
            # The only attribute we treat that specially is -Viewbox.
            if ($paramName -eq 'Viewbox') {
                # For that, we basically pad out whatever list was provided to make four coordinates.
                $viewBoxLeft, $viewBoxTop, $viewBoxRight, $viewBoxBottom = $paramValue -split '\s' -as [double[]]
                $paramValue = @(if ($null -eq $viewBoxTop) {                    
                    0,0,$viewBoxLeft,$viewBoxLeft                    
                } elseif ($null -eq $viewBoxRight) {
                    0,0,$viewBoxLeft,$viewBoxTop
                } elseif ($null -eq $viewBoxBottom) {
                    $viewBoxLeft, $viewBoxTop, $viewBoxRight, $viewBoxTop
                } else {
                    $viewBoxLeft, $viewBoxTop, $viewBoxRight, $viewBoxBottom
                })
            }

            # For timespan values, we want to use the total number of seconds
            if ($paramValue -is [timespan]) {
                $paramValue = "$($paramValue.TotalSeconds)s"
            }

            # If the parameter value was a script block, run it.
            if ($paramValue -is [scriptblock]) {
                if ($null -ne $content) { # (if we had -Content, set $_ first)
                    $this = $_ = $psItem = $content
                    $scriptOut = . ([ScriptBlock]::Create($paramValue))
                    $paramValue = $scriptOut
                } else {
                    $paramValue = . ([ScriptBlock]::Create($paramValue))
                }                
            }

            # Now we refer to the element command and find the actual name of the attribute.
            foreach ($attr in $elementCmd.Parameters[$kv.Key].Attributes) {                
                if ($attr.Key -eq 'SVG.AttributeName') {
                    
                    if ($inputObject -and $inputObject.psobject.properties[$attr.Key]) {
                        $inputObject.psobject.properties.Remove($attr.Key)
                    }
                    # and append it to the XML.
                    $boundAttributes += $paramName
                    $elementText += "$($attr.Value)='$([Web.HttpUtility]::HtmlAttributeEncode($paramValue))' "
                    continue nextParameter
                }
            }
            
            $elementText += "$($kv.Key)='$([Web.HttpUtility]::HtmlAttributeEncode($kv.Value))' "
        }

        if ($data -and $data.Count) {
            if ($content.Data) { $boundAttributes += "data"}
            foreach ($kv in $data.GetEnumerator()) {
                $dataKey = ${?<CamelCaseSpace>}.Replace($kv.Key, '-').Replace('_','-')
                $dataKey = "data-$($dataKey.ToLower())"
                $dataValue = [Web.HttpUtility]::HtmlAttributeEncode($kv.Value)
                $elementText += "$dataKey='$dataValue' "
            }
        }

        if ($On) {
            if ($content.Data) { $boundAttributes += "on"}
            $eventNames = @(
                if ($on -is [Collections.IDictionary]) {
                    $on.Keys    
                } else {
                    $on.psobject.properties.name
                }
            )
            foreach ($eventName in $eventNames) {
                $svgEventName = $eventName -replace '^On' -replace '^[_-]'
                $eventValue   = if ($on -is [Collections.IDictionary]) {
                    $on[$eventName]
                } else {
                    $on.$eventName
                }
                $elementText +=
                    "on" + $svgEventName.ToLower() + "=`"" + $(
                        [Web.HttpUtility]::HtmlAttributeEncode($eventValue)
                    ) + '" '
            }
        }

        if ($elementName -eq 'svg') {
            $elementText += 'xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"'
        }

        $elementText = $elementText -replace '\s{0,1}$'

        if (
            # If there is no content
            (-not $content) -or 
            # or the content is an int
            ($content -is [int]) -or            
            # or the content is a custom object (but not XML or string or array).
            ($content -is [PSCustomObject] -and 
                -not ($content -as [xml]) -and 
                -not ($content -is [string]) -and
                -not ($content -is [array]) -and
                -not ($content -is [Xml.XmlElement])
            )
        ) {
            # If there were children
            if ($children) {
                # close the opening tag.
                $elementText += ">"
                # and the animation
                $elementText += $(@(foreach ($child in $children) {
                    if ($child.OuterXml) {
                        $child.OuterXml
                    } 
                    elseif ($child -is [scriptblock]) {
                        $scriptOut = if ($null -ne $content) { # (if we had -Content, set $_ first)
                            $this = $_ = $psItem = $content
                            . ([ScriptBlock]::Create($child))                            
                        } else {
                            . ([ScriptBlock]::Create($child))
                        }
                        foreach ($scriptOutput in $scriptOut) {
                            if ($scriptOutput.OuterXml) {
                                $scriptOutput.OuterXml
                            } else {
                                [Security.SecurityElement]::Escape("$scriptOutput")
                            }
                        }
                    }
                    else {
                        [Security.SecurityElement]::Escape("$child")
                    }                    
                }) -join ([Environment]::NewLine))

                # and close the tag.
                $elementText += "</$elementName>"
            } else {
                # ignore -Content and close the element.
                $elementText += " />"
            }

            
        } else {
            $isCData = $false
            foreach ($attr in $elementCmd.Parameters.Content.Attributes) {
                if ($attr.Key -eq 'SVG.IsCData' -and $attr.Value -eq 'true') {
                    $isCData = $true
                }
            }

            $elementText += ">"
            # If there were children,
            if ($children) {
                # then children first.
                $elementText += $(@(foreach ($child in $children) {
                    if ($child.Comment) {
                        "<!-- $($child.Comment) -->"
                    }
                    if ($child.OuterXml) {
                        $child.OuterXml
                    } else {
                        [Security.SecurityElement]::Escape("$child")
                    }                    
                }) -join ([Environment]::NewLine))
            }            
            $elementText +=
                foreach ($pieceOfContent in $Content) {
                    if ($pieceOfContent.Comment) {
                        "<!-- $($pieceOfContent.Comment) -->"
                    }
                    if ($isCData -and -not 
                        ($pieceOfContent -as [xml.xmlelement]) -and 
                        ($pieceOfContent -notmatch '^\s{0,}\<')
                    ) {
                        [Security.SecurityElement]::Escape("$pieceOfContent")
                    }
                    elseif ($pieceOfContent.Outerxml) {
                        $pieceOfContent.Outerxml
                    }
                    else {
                        "$pieceOfContent"
                    }
                }
            $elementText += "</$elementName>"                    
        }

        $elementXml = $elementText -as [xml]

        # If we have not provided a comment and the element is SVG
        if ((-not $myParams.Comment) -and ($ElementName -eq 'svg')) {
            $Comment = "Generated with PSSVG $((Get-Module PSSVG).Version) <$((Get-Module PSSVG).ProjectUri)>"
        }

        if ($elementXml -and $Comment) {
            $elementXml = "<!-- $($comment -replace '^\<\!\-\-' -replace '\-\-\>$') -->$elementText" -as [xml]
        }
        $svgOutput  =         
            if ($elementXml -and ($null -ne $elementXml.$ElementName)) {                
                $o = $elementXml.$ElementName
                if ($o -is [string]) {
                    $o = $elementXml
                }
                if ($comment) {
                    Add-Member -InputObject $o NoteProperty Comment $comment -Force
                }
                $o.pstypenames.clear()
                $o.pstypenames.add('SVG.Element')
                $o                
            } else {
                $elementText
            }

        
        if ($myParams['OutputPath']) {
            $unresolvedOutput = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
            if ($unresolvedOutput -and $svgOutput.ParentNode.Save) {
                $svgOutput.ParentNode.PreserveWhitespace = $true
                $memoryStream = [io.memorystream]::new()
                $streamWriter = [io.streamWriter]::new($memoryStream)
                $writerSettings = [Xml.XmlWriterSettings]::new()
                $writerSettings.Encoding = [Text.Encoding]::UTF8
                $writerSettings.Indent = $true                
                $writer = [Xml.XmlWriter]::Create($streamWriter, $writerSettings)                
                $svgOutput.ParentNode.Save($writer)                
                [Text.Encoding]::UTF8.GetString($memoryStream.ToArray()) | 
                    Set-Content -Path $OutputPath -Encoding UTF8
                $writer.Dispose()
                $streamWriter.Dispose()
                $memoryStream.Dispose()
                Get-Item $OutputPath
            } elseif ($unresolvedOutput -and $svgOutput) {
                $svgOutput | Set-Content -Path $OutputPath
                Get-Item $OutputPath
            }
        } else {
            $svgOutput
        }
    }
}