diff --git a/.scratch/_probe/TuiKit.ps1 b/.scratch/_probe/TuiKit.ps1 index 069ed19..f325173 100644 --- a/.scratch/_probe/TuiKit.ps1 +++ b/.scratch/_probe/TuiKit.ps1 @@ -1,4 +1,4 @@ -# ============================================================================ +# ============================================================================ # ZERO-DEPENDENCY PowerShell TUI PROTOTYPE (works on 5.1 and 7.x) # ASCII-only source so 5.1 (which reads non-BOM files as ANSI) parses it. # ============================================================================ diff --git a/.scratch/_probe/cells-probe.ps1 b/.scratch/_probe/cells-probe.ps1 index b25264a..6177bf2 100644 --- a/.scratch/_probe/cells-probe.ps1 +++ b/.scratch/_probe/cells-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile) +param([Parameter(Mandatory=$true)][string]$OutFile) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s) } function MK([int[]]$cp) { -join ($cp | ForEach-Object { [char]$_ }) } diff --git a/.scratch/_probe/console-probe.ps1 b/.scratch/_probe/console-probe.ps1 index 3cfa57d..c05c215 100644 --- a/.scratch/_probe/console-probe.ps1 +++ b/.scratch/_probe/console-probe.ps1 @@ -1,4 +1,4 @@ -$out = Join-Path $env:TEMP 'console-probe.txt' +$out = Join-Path $env:TEMP 'console-probe.txt' $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s) } $src = @" diff --git a/.scratch/_probe/console-probe2.ps1 b/.scratch/_probe/console-probe2.ps1 index 0a6f2ae..c3017b4 100644 --- a/.scratch/_probe/console-probe2.ps1 +++ b/.scratch/_probe/console-probe2.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile) +param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile) $o = New-Object System.Collections.Specialized.OrderedDictionary $src = @" using System; diff --git a/.scratch/_probe/console-probe3.ps1 b/.scratch/_probe/console-probe3.ps1 index b387700..56cd66f 100644 --- a/.scratch/_probe/console-probe3.ps1 +++ b/.scratch/_probe/console-probe3.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile, [int]$WatchSeconds = 3) +param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile, [int]$WatchSeconds = 3) $o = New-Object System.Collections.Specialized.OrderedDictionary $src = @" using System; diff --git a/.scratch/_probe/diag-probe.ps1 b/.scratch/_probe/diag-probe.ps1 index 2899dd9..c2a21a3 100644 --- a/.scratch/_probe/diag-probe.ps1 +++ b/.scratch/_probe/diag-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work) +param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) } L ("PSVersion: " + $PSVersionTable.PSVersion + " IsOutputRedirected=" + [Console]::IsOutputRedirected) diff --git a/.scratch/_probe/final-probe.ps1 b/.scratch/_probe/final-probe.ps1 index ed05d83..e95c0a5 100644 --- a/.scratch/_probe/final-probe.ps1 +++ b/.scratch/_probe/final-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 5) +param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 5) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) } function Braino([string]$m) { return $null } diff --git a/.scratch/_probe/final-probe2.ps1 b/.scratch/_probe/final-probe2.ps1 index 39fc148..367241a 100644 --- a/.scratch/_probe/final-probe2.ps1 +++ b/.scratch/_probe/final-probe2.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 5) +param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 5) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) } L ("PSVersion: " + $PSVersionTable.PSVersion + " CLR " + [System.Environment]::Version + " Redirected=" + [Console]::IsOutputRedirected) diff --git a/.scratch/_probe/load-probe.ps1 b/.scratch/_probe/load-probe.ps1 index 57497f3..7c3abbd 100644 --- a/.scratch/_probe/load-probe.ps1 +++ b/.scratch/_probe/load-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work) +param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s) } L ("PSVersion: " + $PSVersionTable.PSVersion + " CLR " + [System.Environment]::Version) diff --git a/.scratch/_probe/load-probe2.ps1 b/.scratch/_probe/load-probe2.ps1 index 0a2cac6..96b1415 100644 --- a/.scratch/_probe/load-probe2.ps1 +++ b/.scratch/_probe/load-probe2.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 4) +param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 4) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) } L ("PSVersion: " + $PSVersionTable.PSVersion + " CLR " + [System.Environment]::Version) diff --git a/.scratch/_probe/noconsole-probe.ps1 b/.scratch/_probe/noconsole-probe.ps1 index 5847ec6..09516e0 100644 --- a/.scratch/_probe/noconsole-probe.ps1 +++ b/.scratch/_probe/noconsole-probe.ps1 @@ -1,4 +1,4 @@ -$outFile = $args[0] +$outFile = $args[0] if (-not $outFile) { $outFile = Join-Path $env:TEMP 'noconsole-probe.txt' } $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s); Write-Host $s } diff --git a/.scratch/_probe/settle-probe.ps1 b/.scratch/_probe/settle-probe.ps1 index 27df488..e9d9578 100644 --- a/.scratch/_probe/settle-probe.ps1 +++ b/.scratch/_probe/settle-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 5) +param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 5) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) } L ("PSVersion: " + $PSVersionTable.PSVersion + " CLR " + [System.Environment]::Version + " Redirected=" + [Console]::IsOutputRedirected) diff --git a/.scratch/_probe/settle2-probe.ps1 b/.scratch/_probe/settle2-probe.ps1 index da59325..2425c05 100644 --- a/.scratch/_probe/settle2-probe.ps1 +++ b/.scratch/_probe/settle2-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 6) +param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 6) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) } function Asm([string]$n) { return [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $n } | Select-Object -First 1 } diff --git a/.scratch/_probe/tg-probe.ps1 b/.scratch/_probe/tg-probe.ps1 index 3bd16ea..200c4cb 100644 --- a/.scratch/_probe/tg-probe.ps1 +++ b/.scratch/_probe/tg-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work) +param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) } L ("PSVersion: " + $PSVersionTable.PSVersion) diff --git a/.scratch/_probe/tgwin-probe.ps1 b/.scratch/_probe/tgwin-probe.ps1 index 0ccde5b..c137d01 100644 --- a/.scratch/_probe/tgwin-probe.ps1 +++ b/.scratch/_probe/tgwin-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 6) +param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 6) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) } function Asm([string]$n) { return [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $n } | Select-Object -First 1 } diff --git a/.scratch/_probe/tgwin2-probe.ps1 b/.scratch/_probe/tgwin2-probe.ps1 index 82e8c48..a509d13 100644 --- a/.scratch/_probe/tgwin2-probe.ps1 +++ b/.scratch/_probe/tgwin2-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 6) +param([Parameter(Mandatory=$true)][string]$OutFile, [Parameter(Mandatory=$true)][string]$Work, [int]$HoldSeconds = 6) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s); [System.IO.File]::WriteAllLines($OutFile, $lines, (New-Object System.Text.UTF8Encoding($false))) } function Asm([string]$n) { return [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $n } | Select-Object -First 1 } diff --git a/.scratch/_probe/vt-probe.ps1 b/.scratch/_probe/vt-probe.ps1 index b8805fb..fe05fae 100644 --- a/.scratch/_probe/vt-probe.ps1 +++ b/.scratch/_probe/vt-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$OutFile) +param([Parameter(Mandatory=$true)][string]$OutFile) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s) } L ("PSVersion: " + $PSVersionTable.PSVersion + " PSEdition: " + $PSVersionTable.PSEdition) diff --git a/.scratch/_probe/width-probe.ps1 b/.scratch/_probe/width-probe.ps1 index c439d6a..9e5376b 100644 --- a/.scratch/_probe/width-probe.ps1 +++ b/.scratch/_probe/width-probe.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile, [int]$WatchSeconds = 2) +param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile, [int]$WatchSeconds = 2) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s) } $zh = [string]::Concat([char]0x4E2D, [char]0x6587) diff --git a/.scratch/_probe/width-probe2.ps1 b/.scratch/_probe/width-probe2.ps1 index dbc27e6..a155dc0 100644 --- a/.scratch/_probe/width-probe2.ps1 +++ b/.scratch/_probe/width-probe2.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile, [int]$WatchSeconds = 2) +param([Parameter(Mandatory=$true)][string]$HostLabel, [Parameter(Mandatory=$true)][string]$OutFile, [int]$WatchSeconds = 2) $lines = New-Object System.Collections.Generic.List[string] function L($s) { $lines.Add($s) } function MK([int[]]$cp) { -join ($cp | ForEach-Object { [char]$_ }) } diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index ce9de90..bfc2ff1 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,7 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Get-BakNRetCellWidth', 'Write-BakNRetRunSummary', 'Save-BakNRetItemRecord', 'New-BakNRetItemRecord', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index 19a7195..1ebbfa2 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -144,7 +144,10 @@ . (Join-Path $PSScriptRoot 'Public\Write-BakNRetRunSummary.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BakNRetCellWidth.ps1') + Export-ModuleMember -Function @( + 'Get-BakNRetCellWidth', 'Write-BakNRetRunSummary', 'Save-BakNRetItemRecord', 'New-BakNRetItemRecord', diff --git a/BakNRet/Public/Get-BakNRetCellWidth.ps1 b/BakNRet/Public/Get-BakNRetCellWidth.ps1 new file mode 100644 index 0000000..62fffa2 --- /dev/null +++ b/BakNRet/Public/Get-BakNRetCellWidth.ps1 @@ -0,0 +1,51 @@ +function Get-BakNRetCellWidth { + <#> + .SYNOPSIS + 算一个字符串在控制台上占多少**列**(不是多少个字符)。 + + .DESCRIPTION + 为什么必须自己算: + * `[string].Length` 是 UTF-16 code unit 的个数 —— 一个汉字是 1 个 char 但占 2 列; + * `$Host.UI.RawUI.LengthInBufferCells` 在 PowerShell 7 上是对的,但在 **Windows + PowerShell 5.1 上会给出错的答案**:实测 `'中文'` 返回 2(把宽字符当单列)、字体边框字符 + 返回 6(把 1 列的当宽字符)。更糟的是它**不报错** —— 算错的表现只是菜单右边框歪一点、 + 光标定位偏一列,而那种错没人会当场发现,所以只能用断言钉住。 + + 表里三个容易搞错的边界(断言里都有): + * 半角片假名 U+FF61–FF9F 是**半角**(1 列),不能划进全角区; + * 全角拉丁(U+FF00–FF60)是 2 列; + * emoji 是代理对(两个 char、一个码点),按一个码点算。 + #> + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text) + + # East-Asian-Wide 区。半角片假名(U+FF61–FF9F)刻意不在表里。 + $wideRanges = @( + @(0x1100, 0x115F), @(0x2E80, 0x303E), @(0x3041, 0x33FF), @(0x3400, 0x4DBF), + @(0x4E00, 0x9FFF), @(0xA000, 0xA4CF), @(0xAC00, 0xD7A3), @(0xF900, 0xFAFF), + @(0xFE30, 0xFE6F), @(0xFF00, 0xFF60), @(0xFFE0, 0xFFE6), + @(0x1F300, 0x1F64F), @(0x1F900, 0x1F9FF), @(0x20000, 0x2FFFD), @(0x30000, 0x3FFFD) + ) + + $width = 0 + $index = 0 + while ($index -lt $Text.Length) { + $codePoint = [int]$Text[$index] + # 代理对:合成一个码点,并按一个字符推进 + if ($codePoint -ge 0xD800 -and $codePoint -le 0xDBFF -and ($index + 1) -lt $Text.Length) { + $low = [int]$Text[$index + 1] + if ($low -ge 0xDC00 -and $low -le 0xDFFF) { + $codePoint = 0x10000 + (($codePoint - 0xD800) * 0x400) + ($low - 0xDC00) + $index++ + } + } + $index++ + # 控制字符不占显示宽度 + if ($codePoint -lt 0x20 -or $codePoint -eq 0x7F) { continue } + $cell = 1 + foreach ($range in $wideRanges) { + if ($codePoint -ge $range[0] -and $codePoint -le $range[1]) { $cell = 2; break } + } + $width += $cell + } + return $width +} diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index cd30bbf..a7e3f44 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1420,6 +1420,18 @@ Test-Case '结果汇总:分类列出成功/跳过/失败,带原因与失败 } } +Test-Case '控制台列宽:中文 2 列、半角片假名 1 列、emoji 按一个码点算、控制字符 0 列' { + # 这张表算错了不会报错,只会让菜单右边框歪一点 —— 所以每条边界都钉一个断言。 + Assert-Equal 8 (Get-BakNRetCellWidth -Text '12345678') '8 个 ASCII = 8 列' + Assert-Equal 4 (Get-BakNRetCellWidth -Text '中文') '2 个汉字 = 4 列(.Length 是 2)' + Assert-Equal 3 (Get-BakNRetCellWidth -Text ([string]::new([char[]]@(0x250C, 0x2500, 0x2510)))) '字体边框字符 = 1 列/个(5.1 的 LengthInBufferCells 在这里会返回 6)' + Assert-Equal 4 (Get-BakNRetCellWidth -Text ([string]::new([char[]]@(0xFF21, 0xFF22)))) '全角拉丁 = 2 列/个' + Assert-Equal 2 (Get-BakNRetCellWidth -Text ([string]::new([char[]]@(0xFF71, 0xFF72)))) '半角片假名 = 1 列/个(不能划进全角区)' + Assert-Equal 2 (Get-BakNRetCellWidth -Text ([char]::ConvertFromUtf32(0x1F600))) 'emoji = 一个码点 2 列' + Assert-Equal 0 (Get-BakNRetCellWidth -Text ([string]::new([char[]]@(9, 10)))) '控制字符 = 0 列' + Assert-Equal 0 (Get-BakNRetCellWidth -Text '') '空串 = 0 列' +} + # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue