Commands/Custom/SVG.ANSI.ps1
function SVG.ANSI { <# .SYNOPSIS SVG ANSI Text .DESCRIPTION Renders Text containing ANSI escape sequences as SVG. .LINK SVG.svg #> param( # The spacing between lines, in font-emphasis. # By default, 1.2. [Parameter(ValueFromPipelineByPropertyName)] [double] $LineSpacing = 1.2, # The foreground color. By default, white. # All elements that use the -ForegroundColor will also use the CSS class foreground-fill. [string]$ForegroundColor = 'white', # The background color. By default, black. # All items colored in black will also use the class background-fill. [string]$BackgroundColor = 'black', <# The console color palette. ANSI colors use a 3 bit palette (0-7), with an additional bit for brightness. This array contains default color used to fill each of the items in a 3-bit palette. By default, in order: * black * red * green * yellow * blue * magenta * cyan * white Each 3 or 4 bit ANSI color will also use the css class ansi$n-fill, where n is a number between 0 and 15. #> [string[]] $ConsolePalette = @("black", "red", "green","yellow", "blue", "magenta", "cyan", "white") ) dynamicParam { $baseCommand = if (-not $script:SVGSvg) { $script:SVGSvg = $executionContext.SessionState.InvokeCommand.GetCommand('SVG.Svg','Function') $script:SVGSvg } else { $script:SVGSvg } $IncludeParameter = @() $ExcludeParameter = @() $DynamicParameters = [Management.Automation.RuntimeDefinedParameterDictionary]::new() :nextInputParameter foreach ($paramName in ([Management.Automation.CommandMetaData]$baseCommand).Parameters.Keys) { if ($ExcludeParameter) { foreach ($exclude in $ExcludeParameter) { if ($paramName -like $exclude) { continue nextInputParameter} } } if ($IncludeParameter) { $shouldInclude = foreach ($include in $IncludeParameter) { if ($paramName -like $include) { $true;break} } if (-not $shouldInclude) { continue nextInputParameter } } $DynamicParameters.Add($paramName, [Management.Automation.RuntimeDefinedParameter]::new( $baseCommand.Parameters[$paramName].Name, $baseCommand.Parameters[$paramName].ParameterType, $baseCommand.Parameters[$paramName].Attributes )) } $DynamicParameters } begin { # We start off by declaring a big Regex to match ANSI Styles. # (thanks [Irregular](https://github.com/StartAutomating/Irregular) ! ) ${?<ANSI_Style>} = [Regex]::new(@' (?> (?<ANSI_Reset> \e # An Escape \[ # Followed by a bracket (?<Reset>0m) # 0m indicates reset ) | (?<ANSI_Bold> \e # An Escape \[ # Followed by a bracket (?> (?<BoldStart>1m) | (?<BoldEnd>22m)) ) | (?<ANSI_Blink> \e # An Escape \[ # Followed by a bracket (?> (?> (?<BlinkStart>(?<BlinkSlow>5m) # 5m starts a slow blink | (?<BlinkFast>6m) # 6m starts a slow blink )) | (?<BlinkEnd>25m) # 25m stops blinks ) ) | (?<ANSI_Faint> \e # An Escape \[ # Followed by a bracket (?> (?<FaintStart>2m) # 2m starts faint | (?<FaintEnd>22m) # 22m stops faint ) ) | (?<ANSI_Italic> \e # An Escape \[ # Followed by a bracket (?> (?<ItalicStart>3m) # 3m starts italic | (?<ItalicEnd>23m) # 23m stops italic ) ) | (?<ANSI_Invert> \e # An Escape \[ # Followed by a bracket (?> (?<InvertStart>7m) # 7m starts invert | (?<InvertEnd>27m) # 27m stops invert ) ) | (?<ANSI_Hide> \e # An Escape \[ # Followed by a bracket (?> (?<HideStart>8m) # 8m starts hide | (?<HideEnd>28m) # 28m stops hide ) ) | (?<ANSI_Strikethrough> \e # An Escape \[ # Followed by a bracket (?> (?<StrikethroughStart>9m) # 9m starts Strikethrough | (?<StrikethroughEnd>29m) # 29m stops Strikethrough ) ) | (?<ANSI_Underline> \e # An Escape \[ # Followed by a bracket (?> (?<UnderlineStart>4m) # 4m starts underline | (?<DoubleUnderlineStart>21m) # 21m start a double underline | (?<UnderlineEnd>24m) # 24m stops underline ) ) | (?<ANSI_24BitColor> (?-i)\e # An Escape \[ # Followed by a bracket (?> (?<IsForegroundColor>38) | (?<IsBackgroundColor>48) | (?<IsUnderlineColor>58));2;(?<Color>(?<Red>(?>[0-2][0-5][0-5]|[0-1]\d\d|\d{1,2})) # Red is the first 0-255 value ;(?<Green>(?>[0-2][0-5][0-5]|[0-1]\d\d|\d{1,2})) # Green is the second 0-255 value ;(?<Blue>(?>[0-2][0-5][0-5]|[0-1]\d\d|\d{1,2})) # Blue is the third 0-255 value m) ) | (?<ANSI_8BitColor> (?-i)\e # An Escape \[ # Followed by a bracket (?> (?<IsForegroundColor>38) | (?<IsBackgroundColor>48) | (?<IsUnderlineColor>58));5;(?<Color>(?> (?<StandardColor>[0-7]) # 0 -7 are standard colors m | (?<BrightColor>(?>[8-9]|1[0-5])) # 8-15 are bright colors m | (?<CubeColor>(?>[0-2][0-3][0-1]|[0-1]\d\d|\d{1,2})) # 16-231 are cubed colors m | (?<GrayscaleColor>(?>[0-2][0-5][0-5]|[0-1]\d\d|\d{1,2})) # 232-255 are grayscales m)) ) | (?<ANSI_4BitColor> \e # An Escape \[ # Followed by a bracket (?<Color>(?> (?<IsBright>1)?\;{0,1} # A 1 and a semicolon indicate a bright color (?<IsForegroundColor>3) # A number that starts with 3 indicates foreground color | (?<IsBright>(?<IsForegroundColor>9)) # OR it could be a less common bright foreground color, which starts with 9 | (?<IsBright>1)?\;{0,1} # A 1 and a semicolon indicate a bright color (?<IsBackgroundColor>4) # A number that starts with 3 indicates foreground color | (?<IsBright>(?<IsBackgroundColor>10)) # OR it could be a less common bright foreground color, which starts with 9 )(?<ColorNumber>[0-7]) # The color number will be between 0 and 7 (?:\;{0,1}(?<IsBright>1)?)? # Brightness can also come after a color m) ) | (?<ANSI_DefaultColor> (?-i)\e # An Escape \[ # Followed by a bracket (?<Color>(?> (?<DefaultForeground>39) # 39 Represents the default foreground color m | (?<DefaultForeground>49) # 49 Represents the default background color m)) ) ) '@, 'IgnoreCase,IgnorePatternWhitespace', '00:00:05') $allContent = @() } process { # We collect all piped in content $allContent += $psBoundParameters['Content'] } end { $content = $allContent if (-not $Content) { return } # Default the font size to 12 if not provided. $fontSize = if ($psBoundParameters['FontSize']) { ($psBoundParameters['FontSize'] -replace '[\D - [\.]]') -as [double] } else { 12 } # Set up a 'base' splat of all settings we always want to provide. $styleSplatBase = [Ordered]@{ XmlSpace = 'preserve' } # Create a copy of that splat for the current style. $styleSplat = [Ordered]@{} + $styleSplatBase # and prepare to go thru the string. $index = 0 $contentString = $content -join [Environment]::NewLine # We'll need a bunch of SVG 'spans' (TSpans, to be exact) $svgSpans = @() # We'll need to know what line number we're on $lineNumber = 0 # how long each line is. $lineLength = 0 # and what the longest line is. $maxLineLength = 0 # We also want to force -LineSpacing into 'emphasis' units. $lineSpacer = "${lineSpacing}em" # Now find all ANSI Styles $ansiStyleMatches = @(${?<ANSI_Style>}.Matches($Content)) if ($ansiStyleMatches) { # If there were any replace them and figure out how many lines of actual content we have $totalLines = @(${?<ANSI_Style>}.Replace($content, '') -split '(?>\r\n|\n)' -ne '') foreach ($lineToCount in $totalLines) { # and what is the longest line. if ($maxLineLength -lt $lineToCount.Length) { $maxLineLength = $lineToCount.Length } } } else { # Otherwise, make sure we count how many lines we have in total, so we can set the viewbox. $totalLines = @($content -split '(?>\r\n|\n)' -ne '') } # Walk over each match foreach ($match in $ansiStyleMatches) { # Find all of the ext between now and the last match. $textSpan = if ($match.Index -gt $index) { $contentString.Substring($index, $match.Index - $index) } else { '' } $index = $match.Index + $match.Length # If there was any previous text, now we turn it into a span. if ($textSpan) { $textSpanLines = @($textSpan -split '(?>\r\n|\n)') # Or, more properly, _several_ spans, spending on how many lines it crosses. for ($textSpanLineNumber = 0; $textSpanLineNumber -lt $textSpanLines.Length; $textSpanLineNumber++) { $lineLength = $textSpanLines[$textSpanLineNumber].Length if ($maxLineLength -lt $lineLength) { $maxLineLength = $lineLength } $textSpanLine = $textSpanLines[$textSpanLineNumber] $svgSpans += if ($textSpanLineNumber) { =<svg.tspan> -Content $textSpanLine @styleSplat -Dy $lineSpacer -X 0 $lineNumber++ } else { if ($svgSpans[-1].InnerText) { =<svg.tspan> -Content $textSpanLine @styleSplat -DX '-.5em' } else { =<svg.tspan> -Content $textSpanLine @styleSplat } } } } # If the result was followed by a newline if ($match.Result('$`') -match '[\r\n]$') { # create one more span to drop the line down. $svgSpans += =<svg.tspan> -Content ([Environment]::NewLine) @styleSplat -Dy $lineSpacer -X 0 $lineNumber++ } # Now we apply various ANIS styles to change the current style. # If we're Resetting if ($match.Groups['Reset'].Success) { $styleSplat = @{} + $styleSplatBase # reset the splat. } # If we're starting bold or are a 'bright' color if ($match.Groups['BoldStart'].Success -or $match.Groups['IsBright'].Success) { $styleSplat.FontWeight = 'Bold' # make the font bold. } # If we're stopping bold. if ($match.Groups['BoldEnd'].Success) { $styleSplat.Remove('FontWeight') # make the font unbold. } # If we're rendering faintly if ($match.Groups['FaintStart'].Success) { $styleSplat.Opacity = 0.5 # make it half opacity. } # If we're no longer rendering faintly if ($match.Groups['FaintEnd'].Success) { $styleSplat.Remove('Opacity') # Make it normal opacity. } # If it should be italic if ($match.Groups['ItalicStart'].Success) { $styleSplat.FontStyle = 'Italic' # make it so. } # If it should no longer be italic. if ($match.Groups['ItalicEnd'].Success) { $styleSplat.Remove('FontStyle') # make it so. } # If it should be hidden if ($match.Groups["HideStart"].Success) { $styleSplat.Opacity = 0 # drop opacity to 0. } # If it should no longer be hidden if ($match.Groups['HideEnd'].Success) { $styleSplat.Remove('Opacity') # remove opacity. } # If it should be struck thru if ($match.Groups['StrikethroughStart'].Success) { $styleSplat.TextDecoration = 'line-through' # add the line-through text decoration. } # If it should no longer be struck thru if ($match.Groups['StrikethroughEnd'].Success) { $styleSplat.Remove('TextDecoration') # remove the text decoration. } # For the moment, both double and single underline if ($match.Groups['UnderlineStart'].Success -or $match.Groups['DoubleUnderlineStart'].Success) { $styleSplat.TextDecoration = 'underline' # will simply underline. } # And the end of an underling if ($match.Groups['UnderlineEnd'].Success) { $styleSplat.Remove('TextDecoration') # should remove all text decorations. } # For 24-bit color if ($match.Groups['ANSI_24BitColor'].Success) { # Parse out the foreground color if ($match.Groups["IsForegroundColor"].Success) { # and set that as the fill. $styleSplat.Fill = "#{0:x2}{1:x2}{2:x2}" -f @( $match.Groups['Red'].Value, $match.Groups['Green'].Value, $match.Groups['Blue'].Value -as [int[]] ) } if ($match.Groups["IsBackgroundColor"].Success) { # There's nothing to be done about background colors without a lot of pain # since background colors are not an aspect of SVG text. <# if (-not $styleSplat.Style) { $styleSplat.Style = @{} } $styleSplat.Style.'background-color' = "#{0:x2}{1:x2}{2:x2}" -f @( $match.Groups['Red'].Value, $match.Groups['Green'].Value, $match.Groups['Blue'].Value -as [int[]] ) #> } } # If we had a 4-bit color if ($match.Groups["ANSI_4BitColor"].Success) { # determine the color number $colorNumber = $match.Groups["ColorNumber"].Value -as [int] # and find it's name in the -ConsolePalette. $realColor = $ConsolePalette[$colorNumber] if ($match.Groups["IsForegroundColor"].Success) { # The named color is what we set for -Fill $styleSplat.Fill = $realColor # and we also set a css class, so we can stylize the color scheme # with something like [4bitcss](https://4bitcss.com). $cssClassName = @( $styleSplat.Class if ($match.Groups["IsBright"].Success) { "ansi" + ($colorNumber + 8) + "-fill" } else { "ansi" + ($colorNumber) + "-fill" } ) -ne '' -join ' ' $styleSplat.Class = $cssClassName } # SVG text does not support background colors. # we can only paint a rectangle beneath the whole output. if ($match.Groups["IsBackgroundColor"].Success) { if (-not $styleSplat.Style) { $styleSplat.Style = @{} } # $styleSplat.Style.'background-color' = $realColor } } # If we're explicitly resetting the foreground color if ($match.Groups["DefaultForegroundColor"].Success) { $styleSplat.Remove('Fill') # clear the fill. } if ($match.Groups["DefaultBackgroundColor"].Success) { if ($styleSplat.Style.'background-color') { $styleSplat.Style.Remove('background-color') } } } # If we have anything left in the string, render it normally. if ($index -lt $contentString.Length) { $textSpan = $contentString.Substring($index) # we still need to go line by line $textSpanLines = @($textSpan -split '(?>\r\n|\n)') for ($textSpanLineNumber = 0; $textSpanLineNumber -lt $textSpanLines.Length; $textSpanLineNumber++) { $textSpanLine = $textSpanLines[$textSpanLineNumber] # and it is possible we do not yet have a max line length (if no ANSI content was detected). if ($maxLineLength -lt $textSpanLine.Length) { $maxLineLength = $textSpanLine.Length } $svgSpans += if ($textSpanLineNumber) { =<svg.tspan> -Content $textSpanLine @styleSplat -Dy $lineSpacer -X 0 $lineNumber++ } else { if ($svgSpans[-1].InnerText) { =<svg.tspan> -Content $textSpanLine @styleSplat -DX '-.5em' } else { =<svg.tspan> -Content $textSpanLine @styleSplat } } } } # Now, copy my parameters. $myParams = [Ordered]@{} + $PSBoundParameters # and remove parameters that do not apply to the base command. $myParams.Remove('LineSpacing') $myParams.Remove('Content') if (-not $myParams.'FontFamily') { $myParams.FontFamily = 'Monospace' } # Make sure we set a default behavior for text fill. $textSplat = [Ordered]@{} $textSplat.Fill = $ForegroundColor if (-not $myParams.ViewBox) { $goldenRatio = (1 + [Math]::Sqrt(5)) / 2 $viewBoxHeight = [Math]::Ceiling(($maxLineLength * $fontSize)/$goldenRatio) $viewBoxWidth = [Math]::Ceiling(($totalLines.Length + 2) * $fontSize * $LineSpacing) $myParams.ViewBox = "0 0 $viewBoxHeight $viewBoxWidth" } =<svg> @( # And drop in a background color rectangle if one was provided. if ($BackgroundColor -and $BackgroundColor -ne 'transparent') { # make it thrice as tall, and start at -100% (to remove vertical color bleeds) =<svg.rect> -Width 100% -Height 300% -Fill $BackgroundColor -Y -100% } =<svg.text> -Content $svgSpans -FontSize $fontSize @textSplat ) @myParams } } |