PSWriteText.psm1
|
# PSWriteText # https://github.com/steveh8758/PSWriteText # # Copyright (c) 2026 Steven Hsin # # PSWriteText is derived from and extends PSWriteColor by EvotecIT: # https://github.com/EvotecIT/PSWriteColor # # Original idea attribution: # Josh - https://stackoverflow.com/users/81769/josh # # Licensed under the MIT License. # See the LICENSE.md file in the project repository for full license information. # # Supports Windows PowerShell 5.1 and PowerShell 7.x (pwsh). # PowerShell 7.x operation has been manually verified by the maintainer. # This is a source-code notice only; no runtime version check is intentionally performed. # --------------------------------------------------------------------------- # Private Functions # --------------------------------------------------------------------------- function Get-AnsiStyleSequence { <# .SYNOPSIS Converts a Write-Text style definition to ANSI prefix and suffix sequences. .PARAMETER Style Style name or a '+' separated style combination. .OUTPUTS PSCustomObject with Prefix and Suffix properties. #> [CmdletBinding()] param ( [AllowEmptyString()] [string]$Style = 'None' ) $styleMap = @{ Bold = @{ Enable = 1; Disable = 22 } Dim = @{ Enable = 2; Disable = 22 } Italic = @{ Enable = 3; Disable = 23 } Underline = @{ Enable = 4; Disable = 24 } Blink = @{ Enable = 5; Disable = 25 } Reverse = @{ Enable = 7; Disable = 27 } Hidden = @{ Enable = 8; Disable = 28 } Strike = @{ Enable = 9; Disable = 29 } } if ([string]::IsNullOrWhiteSpace($Style) -or $Style -eq 'None') { return [PSCustomObject]@{ Prefix = '' Suffix = '' } } $styleNames = @( $Style -split '\+' | ForEach-Object { $_.Trim() } | Where-Object { $_ } ) $enableCodes = @() $disableCodes = @() foreach ($styleName in $styleNames) { if ($styleName -eq 'None') { continue } if (-not $styleMap.ContainsKey($styleName)) { $validStyles = @( 'None', 'Bold', 'Dim', 'Italic', 'Underline', 'Reverse', 'Strike', 'Blink', 'Hidden' ) -join ', ' throw "Invalid style '$styleName'. Valid styles: $validStyles." } $enableCodes += $styleMap[$styleName].Enable $disableCodes += $styleMap[$styleName].Disable } if ($enableCodes.Count -eq 0) { return [PSCustomObject]@{ Prefix = '' Suffix = '' } } # A single disable code can reset multiple attributes, for example # SGR 22 disables both Bold and Dim. $disableCodes = @($disableCodes | Select-Object -Unique) $esc = [char]27 return [PSCustomObject]@{ Prefix = "$esc[$($enableCodes -join ';')m" Suffix = "$esc[$($disableCodes -join ';')m" } } function Get-DisplayWidth { <# .SYNOPSIS Gets the approximate console display width of a string. .DESCRIPTION Calculates display width for console alignment with best-effort Unicode handling. Common CJK/full-width characters and Emoji are treated as width 2. Combining marks, formatting characters, variation selectors, and Emoji skin tone modifiers do not add width. ZWJ Emoji sequences and regional-indicator flag pairs are treated as a single display cluster where possible. Exact rendering can still vary by terminal, font, and Unicode version. .PARAMETER Text Text to measure. .OUTPUTS System.Int32 #> [CmdletBinding()] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text ) if ($Text.Length -eq 0) { return 0 } $totalWidth = 0 $clusterWidth = 0 $clusterHasBase = $false $joinNextBase = $false $regionalIndicatorCount = 0 $index = 0 while ($index -lt $Text.Length) { $char = $Text[$index] $scalarText = $null if ( [char]::IsHighSurrogate($char) -and ($index + 1 -lt $Text.Length) -and [char]::IsLowSurrogate($Text[$index + 1]) ) { $codePoint = [char]::ConvertToUtf32( $char, $Text[$index + 1] ) $scalarText = $Text.Substring($index, 2) $index += 2 } else { $codePoint = [int]$char $scalarText = $Text.Substring($index, 1) $index++ } # Zero-width joiner joins the previous and next base characters into # one display cluster (for example, many Emoji sequences). if ($codePoint -eq 0x200D) { $joinNextBase = $true continue } # Variation Selector-16 requests Emoji-style presentation on terminals # that support it, so the current cluster should be at least width 2. if ($codePoint -eq 0xFE0F) { if ($clusterHasBase -and $clusterWidth -lt 2) { $clusterWidth = 2 } continue } # Variation Selector-15 and supplementary variation selectors do not # consume an additional console cell. if ( ($codePoint -eq 0xFE0E) -or ($codePoint -ge 0xE0100 -and $codePoint -le 0xE01EF) ) { continue } # Emoji skin-tone modifiers modify the previous Emoji and do not add # additional display width. if ($codePoint -ge 0x1F3FB -and $codePoint -le 0x1F3FF) { continue } $unicodeCategory = [System.Globalization.CharUnicodeInfo]::GetUnicodeCategory( $scalarText, 0 ) # Combining/format characters normally occupy no additional cells. if ( $unicodeCategory -eq [System.Globalization.UnicodeCategory]::NonSpacingMark -or $unicodeCategory -eq [System.Globalization.UnicodeCategory]::SpacingCombiningMark -or $unicodeCategory -eq [System.Globalization.UnicodeCategory]::EnclosingMark -or $unicodeCategory -eq [System.Globalization.UnicodeCategory]::Format -or $unicodeCategory -eq [System.Globalization.UnicodeCategory]::Control ) { # U+20E3 COMBINING ENCLOSING KEYCAP generally renders the cluster # as a two-cell keycap Emoji. if ($codePoint -eq 0x20E3 -and $clusterHasBase) { $clusterWidth = 2 } continue } $isRegionalIndicator = ( $codePoint -ge 0x1F1E6 -and $codePoint -le 0x1F1FF ) $isWide = ( ($codePoint -ge 0x1100 -and $codePoint -le 0x115F) -or ($codePoint -eq 0x2329) -or ($codePoint -eq 0x232A) -or ($codePoint -ge 0x2E80 -and $codePoint -le 0xA4CF) -or ($codePoint -ge 0xAC00 -and $codePoint -le 0xD7A3) -or ($codePoint -ge 0xF900 -and $codePoint -le 0xFAFF) -or ($codePoint -ge 0xFE10 -and $codePoint -le 0xFE19) -or ($codePoint -ge 0xFE30 -and $codePoint -le 0xFE6F) -or ($codePoint -ge 0xFF00 -and $codePoint -le 0xFF60) -or ($codePoint -ge 0xFFE0 -and $codePoint -le 0xFFE6) -or ($codePoint -ge 0x1F000 -and $codePoint -le 0x1FAFF) -or ($codePoint -ge 0x20000 -and $codePoint -le 0x3FFFD) ) $baseWidth = if ($isWide) { 2 } else { 1 } if (-not $clusterHasBase) { $clusterWidth = $baseWidth $clusterHasBase = $true $regionalIndicatorCount = if ($isRegionalIndicator) { 1 } else { 0 } $joinNextBase = $false continue } if ($joinNextBase) { if ($baseWidth -gt $clusterWidth) { $clusterWidth = $baseWidth } $joinNextBase = $false $regionalIndicatorCount = 0 continue } # Pair two regional indicators into one flag cluster. if ($isRegionalIndicator -and $regionalIndicatorCount -eq 1) { if ($baseWidth -gt $clusterWidth) { $clusterWidth = $baseWidth } $regionalIndicatorCount = 2 continue } $totalWidth += $clusterWidth $clusterWidth = $baseWidth $clusterHasBase = $true $regionalIndicatorCount = if ($isRegionalIndicator) { 1 } else { 0 } } if ($clusterHasBase) { $totalWidth += $clusterWidth } return $totalWidth } # --------------------------------------------------------------------------- # Public Functions # --------------------------------------------------------------------------- function Write-Text { <# .SYNOPSIS Writes segmented text to the console with per-segment colors, optional ANSI styles, formatting, centering, and file logging. .DESCRIPTION Write-Text is a wrapper around Write-Host for Windows PowerShell 5.1 and PowerShell 7.x that supports multiple text segments and independent formatting for each segment. The command supports: - Per-segment foreground colors. - Independent per-segment background colors. - Optional ANSI text styles when the current host supports virtual-terminal sequences. - Tabs, spaces, blank lines, timestamps, and horizontal centering. - Best-effort Unicode display-width calculation for horizontal centering. - Optional file logging with configurable encoding and retry behavior. - Short aliases for commonly used parameters. WhatIf and Confirm apply only to log-file appends. Console output is not suppressed by these common parameters. Supports Windows PowerShell 5.1 and PowerShell 7.x (pwsh). PowerShell 7.x operation has been manually verified by the maintainer. No additional runtime version check is performed. .PARAMETER Text Text to display on screen and, when LogFile is specified, write to the log file. Accepts an array of strings. Each array element is treated as a separate text segment for color, background color, and style selection. .PARAMETER Color Foreground color of the text. Accepts an array of ConsoleColor values. Colors are applied to text segments by matching array index while values are available. If there are more text segments than colors, all remaining segments use White. Available colors are: Black, DarkBlue, DarkGreen, DarkCyan, DarkRed, DarkMagenta, DarkYellow, Gray, DarkGray, Blue, Green, Cyan, Red, Magenta, Yellow, White. .PARAMETER BackgroundColor Background color of the text. Accepts an array of ConsoleColor values and is handled independently from Color. Background colors are applied only to text segments with a matching array index. If there are more text segments than background colors, the remaining segments are written without a background color. Therefore, a single background color applies only to the first text segment. Color and BackgroundColor counts do not need to match. .PARAMETER Style Optional ANSI text style. Accepts an array of style definitions, one per text segment. If there are more text segments than styles, the first style is reused. Multiple styles can be combined with '+', for example Bold+Underline. Available styles are: None, Bold, Dim, Italic, Underline, Reverse, Strike, Blink, Hidden. If the current host does not advertise virtual-terminal support, ANSI style sequences are omitted and the text is written normally. .PARAMETER StartTab Number of tab characters to write before the text. Default is 0. .PARAMETER LinesBefore Number of empty lines to write before the text. Default is 0. .PARAMETER LinesAfter Number of empty lines to write after the text. Default is 0. .PARAMETER StartSpaces Number of spaces to write before the text. Default is 0. .PARAMETER LogFile Literal path to the log file. If not specified, no log file is written. .PARAMETER DateTimeFormat Custom date and time format string used by ShowTime and LogTime. Default is yyyy-MM-dd HH:mm:ss. .PARAMETER LogTime Controls whether a timestamp is added to each log-file entry. Default is $true. .PARAMETER LogRetry Number of retry attempts after the initial log-file write fails. Accepts 0 through 5 retries (up to 6 total write attempts). Default is 2, allowing up to 3 total write attempts: 1 initial attempt plus 2 retries. .PARAMETER IgnoreLogError Silences log-write failure diagnostics, including retry messages. By default, exhausted retries produce a non-terminating error. Use -ErrorAction Stop to terminate instead. IgnoreLogError takes precedence over ErrorAction for log-write failures only. .PARAMETER Encoding Encoding used by Add-Content when writing the log file. Default is UTF8. Under Windows PowerShell 5.1, UTF8 creates new files with a byte-order mark (BOM). Encoding names and BOM behavior depend on the PowerShell version. Explicit values must be supported by Add-Content in the current session. .PARAMETER ShowTime Adds the current time to console output using DateTimeFormat. .PARAMETER NoNewLine Prevents Write-Text from adding the final console newline. Explicit LinesAfter output is still written when both options are used. .PARAMETER NoConsoleOutput Suppresses console output. Logging can still occur when LogFile is specified. .PARAMETER HorizontalCenter Horizontally centers the complete visible line, including tabs, spaces, and an optional timestamp, using the current host WindowSize.Width and a best-effort Unicode display-width calculation. When centering is applied, StartTab values are rendered as eight spaces each so their width is deterministic. If Text contains control characters, or if RawUI window width is unavailable, no centering padding is added and a verbose message is emitted. Exact alignment can still vary by terminal, font, and Unicode rendering behavior. .EXAMPLE Write-Text -Text 'Red ', 'Green ', 'Yellow' -Color Red, Green, Yellow Writes three text segments using matching foreground colors. .EXAMPLE Write-Text -Text 'Primary', ' fallback', ' fallback again' -Color Cyan Uses Cyan for the first segment and White for all remaining segments. .EXAMPLE Write-Text -Text 'With background', ' without background' -Color White, Yellow -BackgroundColor DarkBlue Applies DarkBlue only to the first text segment. The second segment is written without a background color. .EXAMPLE Write-Text -Text 'Bold', ' Normal', ' Underline' -Style Bold, None, Underline Applies independent ANSI styles when the host supports virtual-terminal sequences. .EXAMPLE Write-Text -Text 'Important' -Color Yellow -Style 'Bold+Underline' Combines multiple ANSI styles for one text segment when supported by the host. .EXAMPLE Write-Text -Text 'Centered Unicode: 䏿–‡ 😀' -HorizontalCenter Centers the text using WindowSize.Width and the module's best-effort Unicode display-width calculation. .EXAMPLE Write-Text -Text 'Logged message' -LogFile 'C:\Temp\example.log' -LogRetry 2 Attempts the initial log write and retries up to two additional times if the write fails. .EXAMPLE Write-Text -T 'Short aliases ', 'still work' -C Yellow, Green -B DarkBlue Uses parameter aliases. The command itself has no exported compatibility alias. .NOTES Project: PSWriteText Author: Steve H. Repository: https://github.com/steveh8758/PSWriteText Supports Windows PowerShell 5.1 and PowerShell 7.x (pwsh). PowerShell 7.x operation has been manually verified by the maintainer. This compatibility note is informational only; the module performs no runtime PowerShell-version check. PSWriteText is derived from and extends PSWriteColor by EvotecIT: https://github.com/EvotecIT/PSWriteColor Original idea attribution: Josh - https://stackoverflow.com/users/81769/josh Date/time format reference: https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings #> [CmdletBinding(SupportsShouldProcess)] param ( [alias ('T')] [String[]]$Text, [alias ('C', 'ForegroundColor', 'FGC')] [ValidateNotNullOrEmpty()] [ConsoleColor[]]$Color = [ConsoleColor]::White, [alias ('B', 'BGC')] [ConsoleColor[]]$BackgroundColor = $null, [alias ('S')] [string[]]$Style = @('None'), [alias ('Indent')] [ValidateRange(0, 100)] [int] $StartTab = 0, [ValidateRange(0, 100)] [int] $LinesBefore = 0, [ValidateRange(0, 100)] [int] $LinesAfter = 0, [ValidateRange(0, 10000)] [int] $StartSpaces = 0, [alias ('L')] [string] $LogFile = '', [Alias('DateFormat', 'TimeFormat')][ValidateNotNullOrEmpty()][string] $DateTimeFormat = 'yyyy-MM-dd HH:mm:ss', [alias ('LogTimeStamp')][bool] $LogTime = $true, [ValidateRange(0, 5)] [int] $LogRetry = 2, [ValidateSet('unknown', 'string', 'unicode', 'bigendianunicode', 'utf8', 'utf7', 'utf32', 'ascii', 'default', 'oem')][string]$Encoding = 'UTF8', [switch] $ShowTime, [switch] $NoNewLine, [switch] $HorizontalCenter, [alias('HideConsole')][switch] $NoConsoleOutput, [switch] $IgnoreLogError ) $UsesConsoleTimestamp = -not $NoConsoleOutput -and $ShowTime $UsesLogTimestamp = $Text.Count -and $LogFile -and $LogTime $TimestampPrefix = '' # Format the timestamp once so console and log output from the same call use # the same value. Validate the format before output, but only when this call # actually requests a timestamp. if ($UsesConsoleTimestamp -or $UsesLogTimestamp) { $CurrentDateTime = [datetime]::Now try { $FormattedDateTime = $CurrentDateTime.ToString($DateTimeFormat) } catch [System.FormatException] { throw "Write-Text - Invalid DateTimeFormat '$DateTimeFormat': $($_.Exception.Message)" } $TimestampPrefix = "[$FormattedDateTime] " } if (-not $NoConsoleOutput) { $DefaultColor = [ConsoleColor]::White $DefaultStyle = if ($Style.Count -gt 0) { $Style[0] } else { 'None' } # Validate all requested styles before writing any console output. foreach ($styleValue in $Style) { Get-AnsiStyleSequence -Style $styleValue | Out-Null } # ANSI styles are optional. If the current host does not advertise # virtual-terminal support, preserve the text and ConsoleColor output # but omit ANSI escape sequences. $SupportsVirtualTerminal = ( $null -ne $Host.UI.PSObject.Properties['SupportsVirtualTerminal'] -and [bool]$Host.UI.SupportsVirtualTerminal ) $CenterPadding = 0 $ExpandTabsForCentering = $false if ($HorizontalCenter) { $Message = $Text -join '' if ($Message -match '\p{Cc}') { Write-Verbose 'Horizontal centering skipped because Text contains control characters.' } else { $MessageWidth = Get-DisplayWidth -Text $Message $PrefixWidth = $StartSpaces + ($StartTab * 8) if ($ShowTime) { $PrefixWidth += Get-DisplayWidth -Text $TimestampPrefix } $LineWidth = $PrefixWidth + $MessageWidth try { $WindowWidth = [int]$Host.UI.RawUI.WindowSize.Width if ($WindowWidth -gt 0 -and $WindowWidth -ge $LineWidth) { $CenterPadding = [int][Math]::Floor( ($WindowWidth - $LineWidth) / 2 ) $ExpandTabsForCentering = $true } elseif ($WindowWidth -le 0) { Write-Verbose 'Horizontal centering skipped because RawUI returned an invalid window width.' } } catch { Write-Verbose "Horizontal centering skipped because RawUI window width is unavailable: $($_.Exception.Message)" } } } if ($LinesBefore -ne 0) { for ($i = 0; $i -lt $LinesBefore; $i++) { Write-Host -Object "`n" -NoNewline } } # Add empty line before if ($CenterPadding -gt 0) { Write-Host -Object (' ' * $CenterPadding) -NoNewline } # Center the complete visible line when RawUI window width is available if ($StartTab -ne 0) { if ($ExpandTabsForCentering) { Write-Host -Object (' ' * ($StartTab * 8)) -NoNewline } else { for ($i = 0; $i -lt $StartTab; $i++) { Write-Host -Object "`t" -NoNewline } } } # Add TABS before text if ($StartSpaces -ne 0) { for ($i = 0; $i -lt $StartSpaces; $i++) { Write-Host -Object ' ' -NoNewline } } # Add SPACES before text if ($ShowTime) { Write-Host -Object $TimestampPrefix -NoNewline } # Add Time before output if ($Text.Count -ne 0) { for ($i = 0; $i -lt $Text.Length; $i++) { $segmentColor = if ($i -lt $Color.Count) { $Color[$i] } else { $DefaultColor } $segmentStyle = if ($i -lt $Style.Count) { $Style[$i] } else { $DefaultStyle } if ($SupportsVirtualTerminal) { $ansiStyle = Get-AnsiStyleSequence -Style $segmentStyle # Keep ANSI style codes and text in the same Write-Host call. # Write-Host applies ConsoleColor before emitting the object; # embedding ANSI here prevents ForegroundColor/BackgroundColor # from overwriting the requested ANSI text attributes. $styledText = '{0}{1}{2}' -f ( $ansiStyle.Prefix, $Text[$i], $ansiStyle.Suffix ) } else { # Host does not support virtual-terminal sequences. # Fall back to plain text while keeping ConsoleColor handling. $styledText = $Text[$i] } if ($null -eq $BackgroundColor -or $i -ge $BackgroundColor.Count) { Write-Host ` -Object $styledText ` -ForegroundColor $segmentColor ` -NoNewline } else { Write-Host ` -Object $styledText ` -ForegroundColor $segmentColor ` -BackgroundColor $BackgroundColor[$i] ` -NoNewline } } } if ($NoNewLine -eq $true) { Write-Host -NoNewline } else { Write-Host } # Support for no new line if ($LinesAfter -ne 0) { for ($i = 0; $i -lt $LinesAfter; $i++) { Write-Host -Object "`n" -NoNewline } } # Add empty line after } if ($Text.Count -and $LogFile) { $TextToFile = $Text -join '' $LogEntry = if ($LogTime) { "$TimestampPrefix$TextToFile" } else { $TextToFile } if ($PSCmdlet.ShouldProcess($LogFile, 'Append log entry')) { $Saved = $false $Attempt = 0 $MaxAttempts = $LogRetry + 1 while (-not $Saved -and $Attempt -lt $MaxAttempts) { $Attempt++ try { Add-Content ` -LiteralPath $LogFile ` -Value $LogEntry ` -Encoding $Encoding ` -ErrorAction Stop ` -Confirm:$false $Saved = $true } catch { if ($Attempt -ge $MaxAttempts) { $OriginalException = $_.Exception $FailureMessage = "Write-Text - Could not write to log file '$LogFile'. $($OriginalException.Message) Tried $Attempt time(s): 1 initial attempt + $LogRetry retry/retries." if (-not $IgnoreLogError) { $FailureException = [System.IO.IOException]::new( $FailureMessage, $OriginalException ) $LogError = [System.Management.Automation.ErrorRecord]::new( $FailureException, 'PSWriteText.LogWriteFailed', [System.Management.Automation.ErrorCategory]::WriteError, $LogFile ) $LogError.ErrorDetails = [System.Management.Automation.ErrorDetails]::new( $FailureMessage ) $PSCmdlet.WriteError($LogError) } } else { $NextRetry = $Attempt if (-not $IgnoreLogError) { Write-Verbose "Write-Text - Could not write to log file '$LogFile'. $($_.Exception.Message) Retrying... ($NextRetry/$LogRetry)" } Start-Sleep -Milliseconds 200 } } } } } } # Export the public function only Export-ModuleMember -Function @('Write-Text') |