Private/ConvertTo-SpectreRenderableLines.ps1
|
function New-TerminalStyledLine { param( [AllowEmptyString()][string]$Text = '', [string]$Foreground, [string]$Background, [switch]$Bold, [switch]$Italic, [switch]$Underline ) $line = [TerminalStyledLine]::new() if ($Text.Length -gt 0) { $line.Runs.Add([TerminalStyledRun]::new($Text, $Foreground, $Background, $Bold.IsPresent, $Italic.IsPresent, $Underline.IsPresent)) } return $line } function Add-TerminalStyledRun { param( [Parameter(Mandatory)][TerminalStyledLine]$Line, [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [string]$Foreground, [string]$Background, [switch]$Bold, [switch]$Italic, [switch]$Underline ) if ($Text.Length -gt 0) { $Line.Runs.Add([TerminalStyledRun]::new($Text, $Foreground, $Background, $Bold.IsPresent, $Italic.IsPresent, $Underline.IsPresent)) } } function Get-TerminalStyledLineText { param([Parameter(Mandatory)][object]$Line) if ($Line -is [TerminalPreparedLine]) { return $Line.GetText() } if ($Line -is [TerminalStyledLine]) { return $Line.GetText() } return [string]$Line } function Get-TerminalStyledGraphemes { param([Parameter(Mandatory)][TerminalStyledLine]$Line) $text = [System.Text.StringBuilder]::new() $segments = [System.Collections.Generic.List[object]]::new() foreach ($run in $Line.Runs) { $plainRunText = Strip-AnsiSequences -Text $run.Text if ($plainRunText.Length -eq 0) { continue } $start = $text.Length [void]$text.Append($plainRunText) $segments.Add([pscustomobject]@{ Start = $start; End = $text.Length; Style = $run }) } if ($text.Length -eq 0) { return ,([TerminalStyledGrapheme[]]@()) } Assert-TerminalValidUtf16 -Value $text.ToString() $graphemes = [System.Collections.Generic.List[TerminalStyledGrapheme]]::new() $enumerator = [System.Globalization.StringInfo]::GetTextElementEnumerator($text.ToString()) $segmentIndex = 0 while ($enumerator.MoveNext()) { while ($segmentIndex -lt $segments.Count - 1 -and $enumerator.ElementIndex -ge $segments[$segmentIndex].End) { $segmentIndex++ } $graphemes.Add([TerminalStyledGrapheme]::new($enumerator.GetTextElement(), $segments[$segmentIndex].Style)) } return ,$graphemes.ToArray() } function Get-TerminalStyledTextElements { param( [Parameter(Mandatory)][TerminalStyledLine]$Line, [ValidateRange(0, [int]::MaxValue)][int]$StartColumn ) $elements = [System.Collections.Generic.List[TerminalStyledTextElement]]::new() $column = $StartColumn foreach ($grapheme in (Get-TerminalStyledGraphemes -Line $Line)) { $measured = @(Get-TerminalTextElements -Text $grapheme.Text -StartColumn $column -PreserveTabs) $width = 0 foreach ($part in $measured) { $width += $part.Width } $elements.Add([TerminalStyledTextElement]::new($grapheme.Text, $width, $grapheme.Style)) $column += $width } return ,$elements.ToArray() } function Add-TerminalStyledBuffer { param( [Parameter(Mandatory)][TerminalStyledLine]$Line, [Parameter(Mandatory)][System.Text.StringBuilder]$Buffer, [Parameter(Mandatory)][TerminalStyledRun]$Style ) if ($Buffer.Length -eq 0) { return } $Line.Runs.Add([TerminalStyledRun]::new( $Buffer.ToString(), $Style.Foreground, $Style.Background, $Style.Bold, $Style.Italic, $Style.Underline )) [void]$Buffer.Clear() } function Split-TerminalStyledLine { param([Parameter(Mandatory)][object]$Line) $styledLine = if ($Line -is [TerminalStyledLine]) { $Line } else { New-TerminalStyledLine -Text ([string]$Line) } $rows = [System.Collections.Generic.List[TerminalStyledLine]]::new() $row = [TerminalStyledLine]::new() $buffer = [System.Text.StringBuilder]::new() $activeStyle = $null foreach ($grapheme in (Get-TerminalStyledGraphemes -Line $styledLine)) { if ($grapheme.Text.Contains("`r") -or $grapheme.Text.Contains("`n")) { if ($buffer.Length -gt 0) { Add-TerminalStyledBuffer -Line $row -Buffer $buffer -Style $activeStyle } $rows.Add($row) $row = [TerminalStyledLine]::new() $activeStyle = $null continue } if (-not [object]::ReferenceEquals($activeStyle, $grapheme.Style) -and $buffer.Length -gt 0) { Add-TerminalStyledBuffer -Line $row -Buffer $buffer -Style $activeStyle } $activeStyle = $grapheme.Style [void]$buffer.Append($grapheme.Text) } if ($buffer.Length -gt 0) { Add-TerminalStyledBuffer -Line $row -Buffer $buffer -Style $activeStyle } $rows.Add($row) return ,$rows.ToArray() } function Split-TerminalStyledLineByCellWidth { param( [Parameter(Mandatory)][TerminalStyledLine]$Line, [Parameter(Mandatory)][ValidateRange(1, [int]::MaxValue)][int]$Width, [ValidateRange(0, [int]::MaxValue)][int]$StartColumn = 0 ) $segments = [System.Collections.Generic.List[TerminalStyledLine]]::new() foreach ($logicalLine in (Split-TerminalStyledLine -Line $Line)) { $segment = [TerminalStyledLine]::new() $buffer = [System.Text.StringBuilder]::new() $activeStyle = $null $usedWidth = 0 foreach ($grapheme in (Get-TerminalStyledGraphemes -Line $logicalLine)) { $graphemeWidth = 0 foreach ($part in (Get-TerminalTextElements -Text $grapheme.Text -StartColumn ($StartColumn + $usedWidth) -PreserveTabs)) { $graphemeWidth += $part.Width } $hasContent = $segment.Runs.Count -gt 0 -or $buffer.Length -gt 0 if ($hasContent -and $usedWidth + $graphemeWidth -gt $Width) { if ($buffer.Length -gt 0) { Add-TerminalStyledBuffer -Line $segment -Buffer $buffer -Style $activeStyle } $segments.Add($segment) $segment = [TerminalStyledLine]::new() $activeStyle = $null $usedWidth = 0 $graphemeWidth = 0 foreach ($part in (Get-TerminalTextElements -Text $grapheme.Text -StartColumn $StartColumn -PreserveTabs)) { $graphemeWidth += $part.Width } } if (-not [object]::ReferenceEquals($activeStyle, $grapheme.Style) -and $buffer.Length -gt 0) { Add-TerminalStyledBuffer -Line $segment -Buffer $buffer -Style $activeStyle } $activeStyle = $grapheme.Style [void]$buffer.Append($grapheme.Text) $usedWidth += $graphemeWidth } if ($buffer.Length -gt 0) { Add-TerminalStyledBuffer -Line $segment -Buffer $buffer -Style $activeStyle } $segments.Add($segment) } return ,$segments.ToArray() } function Measure-TerminalStyledLineWidth { param( [Parameter(Mandatory)][TerminalStyledLine]$Line, [ValidateRange(0, [int]::MaxValue)][int]$StartColumn ) $width = 0 foreach ($textElement in (Get-TerminalStyledTextElements -Line $Line -StartColumn $StartColumn)) { $width += $textElement.Width } return $width } function Resolve-TerminalPreparedLineOrigin { param( [Parameter(Mandatory)][TerminalStyledLine]$Line, [ValidateRange(0, [int]::MaxValue)][int]$StartColumn, [ValidateRange(1, [int]::MaxValue)][int]$MaxWidth, [ValidateSet('Left', 'Center', 'Right')][string]$Alignment ) if ($Alignment -eq 'Left' -or $MaxWidth -eq [int]::MaxValue) { return $StartColumn } $rightBoundary = $StartColumn + $MaxWidth if (-not $Line.GetText().Contains("`t")) { $lineWidth = Measure-TerminalStyledLineWidth -Line $Line -StartColumn $StartColumn if ($lineWidth -gt $MaxWidth) { return $StartColumn } $freeWidth = $MaxWidth - $lineWidth if ($Alignment -eq 'Center') { return $StartColumn + [Math]::Floor($freeWidth / 2) } return $StartColumn + $freeWidth } $bestOrigin = -1 $bestError = [int]::MaxValue for ($residue = 0; $residue -lt $script:TerminalTabStopWidth; $residue++) { $offset = ($residue - ($StartColumn % $script:TerminalTabStopWidth) + $script:TerminalTabStopWidth) % $script:TerminalTabStopWidth $firstOrigin = $StartColumn + $offset if ($firstOrigin -gt $rightBoundary) { continue } $lineWidth = Measure-TerminalStyledLineWidth -Line $Line -StartColumn $firstOrigin $lastFeasibleOrigin = $rightBoundary - $lineWidth if ($firstOrigin -gt $lastFeasibleOrigin) { continue } $maxStep = [Math]::Floor(($lastFeasibleOrigin - $firstOrigin) / $script:TerminalTabStopWidth) $steps = if ($Alignment -eq 'Right') { @($maxStep) } else { $idealOrigin = ($StartColumn + $rightBoundary - $lineWidth) / 2.0 $idealStep = [Math]::Floor(($idealOrigin - $firstOrigin) / $script:TerminalTabStopWidth) @([Math]::Max(0, [Math]::Min($maxStep, $idealStep)), [Math]::Max(0, [Math]::Min($maxStep, $idealStep + 1))) | Select-Object -Unique } foreach ($step in $steps) { $candidate = $firstOrigin + ($step * $script:TerminalTabStopWidth) $rightMargin = $rightBoundary - ($candidate + $lineWidth) $leftMargin = $candidate - $StartColumn $error = if ($Alignment -eq 'Center') { [Math]::Abs($leftMargin - $rightMargin) } else { $rightMargin } $tieWins = $error -eq $bestError -and ( ($Alignment -eq 'Center' -and $candidate -lt $bestOrigin) -or ($Alignment -eq 'Right' -and $candidate -gt $bestOrigin) ) if ($error -lt $bestError -or $tieWins) { $bestOrigin = $candidate $bestError = $error } } } if ($bestOrigin -ge 0) { return $bestOrigin } return $StartColumn } function ConvertTo-TerminalPreparedLine { [CmdletBinding()] param( [Parameter(Mandatory)][object]$Line, [ValidateRange(0, [int]::MaxValue)][int]$StartColumn = 0, [ValidateRange(1, [int]::MaxValue)][int]$MaxWidth = [int]::MaxValue, [ValidateSet('Left', 'Center', 'Right')][string]$Alignment = 'Left' ) $styledLine = if ($Line -is [TerminalStyledLine]) { $Line } else { New-TerminalStyledLine -Text ([string]$Line) } if ($styledLine.GetText().Contains("`r") -or $styledLine.GetText().Contains("`n")) { throw 'TerminalPreparedLine accepts exactly one logical row. Use ConvertTo-TerminalPreparedLines for multiline content.' } $renderStartColumn = Resolve-TerminalPreparedLineOrigin -Line $styledLine -StartColumn $StartColumn -MaxWidth $MaxWidth -Alignment $Alignment $availableWidth = if ($MaxWidth -eq [int]::MaxValue) { $MaxWidth } else { [Math]::Max(1, $MaxWidth - ($renderStartColumn - $StartColumn)) } $prepared = [TerminalPreparedLine]::new($renderStartColumn, $availableWidth) $renderedWidth = 0 $prefixFits = $true $activeStyle = $null $runText = [System.Text.StringBuilder]::new() $runWidth = 0 foreach ($textElement in (Get-TerminalStyledTextElements -Line $styledLine -StartColumn $renderStartColumn)) { if (-not [object]::ReferenceEquals($activeStyle, $textElement.Style) -and $runText.Length -gt 0) { $prepared.Runs.Add([TerminalPreparedRun]::new( $runText.ToString(), $runWidth, $activeStyle.Foreground, $activeStyle.Background, $activeStyle.Bold, $activeStyle.Italic, $activeStyle.Underline )) [void]$runText.Clear() $runWidth = 0 } $activeStyle = $textElement.Style $pieceCount = if ($textElement.Text -eq "`t") { $textElement.Width } else { 1 } for ($piece = 0; $piece -lt $pieceCount; $piece++) { $pieceWidth = if ($textElement.Text -eq "`t") { 1 } else { $textElement.Width } $prepared.Width += $pieceWidth if (-not $prefixFits) { continue } if ($renderedWidth + $pieceWidth -gt $availableWidth) { $prefixFits = $false continue } $renderedText = if ($textElement.Text -eq "`t") { ' ' } else { $textElement.Text } [void]$runText.Append($renderedText) $runWidth += $pieceWidth $renderedWidth += $pieceWidth } } if ($runText.Length -gt 0) { $prepared.Runs.Add([TerminalPreparedRun]::new( $runText.ToString(), $runWidth, $activeStyle.Foreground, $activeStyle.Background, $activeStyle.Bold, $activeStyle.Italic, $activeStyle.Underline )) } $prepared.RenderedWidth = $renderedWidth return $prepared } function ConvertTo-TerminalPreparedLines { [CmdletBinding()] param( [AllowEmptyCollection()][object[]]$Lines, [ValidateRange(0, [int]::MaxValue)][int]$StartColumn = 0, [ValidateRange(1, [int]::MaxValue)][int]$MaxWidth = [int]::MaxValue, [ValidateSet('Left', 'Center', 'Right')][string]$Alignment = 'Left' ) $prepared = [System.Collections.Generic.List[TerminalPreparedLine]]::new() foreach ($line in $Lines) { if ($line -is [TerminalPreparedLine]) { $prepared.Add($line) continue } foreach ($row in (Split-TerminalStyledLine -Line $line)) { $prepared.Add((ConvertTo-TerminalPreparedLine -Line $row -StartColumn $StartColumn -MaxWidth $MaxWidth -Alignment $Alignment)) } } return ,$prepared.ToArray() } function ConvertTo-TerminalHexColor { param([Parameter(Mandatory)][Spectre.Console.Color]$Color) if ($Color -eq [Spectre.Console.Color]::Default) { return $null } return '#{0:X2}{1:X2}{2:X2}' -f $Color.R, $Color.G, $Color.B } function ConvertTo-SpectreStyledLines { [CmdletBinding()] param( [Parameter(Mandatory)][object]$Renderable, [Parameter(Mandatory)][int]$Width, [int]$Height = 0, [TerminalSlides.Schema.V1.TerminalCapability]$Capability ) if ($Renderable -isnot [Spectre.Console.Rendering.IRenderable]) { throw 'Renderable must implement Spectre.Console.Rendering.IRenderable.' } $capabilities = [TerminalSpectreCapabilities]::new() $capabilities.Ansi = if ($null -eq $Capability) { $true } else { $Capability.AnsiSupport } $capabilities.Interactive = $false $capabilities.Unicode = if ($null -eq $Capability) { $true } else { $Capability.UnicodeSupport } $capabilities.Links = $false $capabilities.Legacy = $false $capabilities.IsTerminal = $true $capabilities.ColorSystem = if ($null -eq $Capability -or $Capability.TrueColorSupport) { [Spectre.Console.ColorSystem]::TrueColor } elseif ($Capability.Color256Support) { [Spectre.Console.ColorSystem]::EightBit } else { [Spectre.Console.ColorSystem]::NoColors } $options = [Spectre.Console.Rendering.RenderOptions]::new( $capabilities, [Spectre.Console.Size]::new([Math]::Max(1, $Width), [Math]::Max(1, $Height)) ) $segmentLines = [Spectre.Console.Rendering.Segment]::SplitLines($Renderable.Render($options, [Math]::Max(1, $Width))) $lineCount = if ($Height -gt 0) { [Math]::Min($Height, $segmentLines.Count) } else { $segmentLines.Count } $result = [System.Collections.Generic.List[TerminalStyledLine]]::new() for ($lineIndex = 0; $lineIndex -lt $lineCount; $lineIndex++) { $line = [TerminalStyledLine]::new() foreach ($segment in $segmentLines[$lineIndex]) { if ($segment.IsControlCode -or -not $segment.Text) { continue } $style = $segment.Style Add-TerminalStyledRun -Line $line -Text $segment.Text ` -Foreground (ConvertTo-TerminalHexColor $style.Foreground) ` -Background (ConvertTo-TerminalHexColor $style.Background) ` -Bold:($style.Decoration.HasFlag([Spectre.Console.Decoration]::Bold)) ` -Italic:($style.Decoration.HasFlag([Spectre.Console.Decoration]::Italic)) ` -Underline:($style.Decoration.HasFlag([Spectre.Console.Decoration]::Underline)) } $result.Add($line) } return ,$result.ToArray() } function ConvertTo-SpectreRenderableLines { [CmdletBinding()] param( [Parameter(Mandatory)][object]$Renderable, [Parameter(Mandatory)][int]$Width, [int]$Height = 0, [TerminalSlides.Schema.V1.TerminalCapability]$Capability ) return ,(ConvertTo-SpectreStyledLines -Renderable $Renderable -Width $Width -Height $Height -Capability $Capability) } function ConvertTo-TerminalImageLines { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][int]$Width, [int]$Height = 0, [Alias('BasePath')][string]$SourceDirectory, [TerminalSlides.Schema.V1.TerminalCapability]$Capability ) $resolvedPath = $Path if (-not [System.IO.Path]::IsPathRooted($Path)) { $resolvedPath = if ($SourceDirectory) { Join-Path $SourceDirectory $Path } else { $Path } } if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) { Write-Warning "Image '$Path' was not found. Rendering its accessible text fallback." return $null } try { $image = Get-SpectreImage -ImagePath $resolvedPath -Format Blocks -ErrorAction Stop $maxWidth = [Math]::Max(1, $Width) if ($Height -gt 0 -and $image.Width -gt 0 -and $image.Height -gt 0) { $heightLimitedWidth = [Math]::Floor(($Height * 2.0 * $image.Width) / $image.Height) $maxWidth = [Math]::Min($maxWidth, [Math]::Max(1, $heightLimitedWidth)) } $image.MaxWidth = $maxWidth return ,(ConvertTo-SpectreRenderableLines -Renderable $image -Width $Width -Height $Height -Capability $Capability) } catch { Write-Verbose "Image decoder error: $($_.Exception.Message)" Write-Warning "Image '$Path' could not be decoded. Rendering its accessible text fallback." return $null } } |