Public/Measure-DisplayWidth.ps1

function Measure-DisplayWidth {
    <#
    .SYNOPSIS
        Measures the display width of a string in terminal cells.
 
    .DESCRIPTION
        Answers how many terminal cells a string occupies, which String.Length does not: Length
        counts UTF-16 code units. The width of each character comes from the table of the Rust
        crate unicode-width 0.2.2, which PWRSWriteColorEX uses, so both modules measure alike:
        - Wide characters (CJK, most emoji) take 2 cells
        - Combining marks, zero-width spaces and joiners take 0 cells
        - Control characters take 0 cells
        - East Asian Ambiguous characters (box drawing, arrows, some symbols) take 1 cell, or 2 with -AmbiguousAsWide
        - Everything else takes 1 cell
 
        Emoji sequences count as the cells a terminal draws for them:
        - A character followed by U+FE0F (emoji presentation) takes 2 cells, as in ⚠️ and ❤️
        - A wide emoji followed by U+FE0E (text presentation) takes 1 cell, except with -AmbiguousAsWide
        - A skin tone modifier after an emoji that takes one adds nothing, as in 👍🏽
        - An emoji joined to the one before it by U+200D adds nothing, as in 👨‍👩‍👧
        - A pair of regional indicators (a flag) takes 2 cells
 
        The string is walked by code point, so Windows PowerShell 5.1 and PowerShell 7 give the
        same answer.
 
    .PARAMETER Text
        The text string to measure.
 
    .PARAMETER AmbiguousAsWide
        Treats East Asian Ambiguous characters as 2 cells instead of 1, and keeps a wide emoji
        followed by U+FE0E at 2 cells, as East Asian terminals draw them.
 
        Ambiguous characters include box drawing (╔═╗║), arrows (→), some symbols (●★®×○) and
        punctuation. Use this for terminals set to draw them wide.
 
    .EXAMPLE
        Measure-DisplayWidth "Hello"
        Returns: 5
 
    .EXAMPLE
        Measure-DisplayWidth "Hello 世界"
        Returns: 10 (6 for "Hello " and 2 for each CJK character)
 
    .EXAMPLE
        Measure-DisplayWidth "✓ Done"
        Returns: 6 (✓ takes 1 cell)
 
    .EXAMPLE
        Measure-DisplayWidth "😀👍"
        Returns: 4
 
    .EXAMPLE
        Measure-DisplayWidth "╔═══╗"
        Returns: 5
 
    .EXAMPLE
        Measure-DisplayWidth "╔═══╗" -AmbiguousAsWide
        Returns: 10
 
    .NOTES
        Author: MarkusMcNugen
        License: MIT
        Requires: PowerShell 5.1 or later
 
    .LINK
        https://github.com/MarkusMcNugen/PSWriteColorEX
    #>

    [CmdletBinding()]
    [Alias('MDW', 'Get-DisplayWidth')]
    [OutputType([int])]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [AllowEmptyString()]
        [string]$Text,

        [Parameter()]
        [switch]$AmbiguousAsWide
    )

    process {
        if ([string]::IsNullOrEmpty($Text)) {
            return 0
        }

        $index = $script:DisplayWidthIndex
        $starts = $index.Starts
        $ends = $index.Ends
        $classes = $index.Classes
        $last = $starts.Length - 1
        $wideAmbiguous = $AmbiguousAsWide.IsPresent

        $total = 0
        # The code point before this one and the cells it added, for the sequence rules.
        $previous = -1
        $previousWidth = 0
        # Whether the character before a U+200D was an emoji, so the next emoji joins it.
        $joining = $false

        $i = 0
        $length = $Text.Length
        while ($i -lt $length) {
            $cp = [int]$Text[$i]
            $i++

            # Printable ASCII is one cell and ends any sequence.
            if ($cp -ge 0x20 -and $cp -le 0x7E) {
                $total++
                $previous = $cp
                $previousWidth = 1
                $joining = $false
                continue
            }

            if ($cp -ge 0xD800 -and $cp -le 0xDBFF -and $i -lt $length) {
                $low = [int]$Text[$i]
                if ($low -ge 0xDC00 -and $low -le 0xDFFF) {
                    $cp = 0x10000 + (($cp - 0xD800) -shl 10) + ($low - 0xDC00)
                    $i++
                }
            }

            if ($cp -eq 0xFE0F) {
                # Emoji presentation widens a one-cell base that has an emoji form.
                if ($previousWidth -eq 1 -and (Test-DisplayWidthSet $script:DisplayWidthTable.Vs16Base $previous)) {
                    $total++
                    $previousWidth = 2
                }
                continue
            }

            if ($cp -eq 0xFE0E) {
                # Text presentation narrows a two-cell emoji, outside East Asian text.
                if (-not $wideAmbiguous -and $previousWidth -eq 2 -and (Test-DisplayWidthSet $script:DisplayWidthTable.Vs15Base $previous)) {
                    $total--
                    $previousWidth = 1
                }
                continue
            }

            if ($cp -eq 0x200D) {
                $joining = $previousWidth -eq 2 -and (Test-DisplayWidthSet $script:DisplayWidthTable.Pictographic $previous)
                continue
            }

            if ($cp -ge 0x1F3FB -and $cp -le 0x1F3FF -and $previousWidth -eq 2 -and (Test-DisplayWidthSet $script:DisplayWidthTable.ModifierBase $previous)) {
                # A skin tone belongs to the emoji before it.
                $joining = $false
                continue
            }

            if ($joining -and (Test-DisplayWidthSet $script:DisplayWidthTable.Pictographic $cp)) {
                # An emoji joined to the one before it is drawn in that emoji's cells.
                $joining = $false
                $previous = $cp
                $previousWidth = 2
                continue
            }
            $joining = $false

            $class = 0
            $lo = 0
            $hi = $last
            while ($lo -le $hi) {
                $mid = ($lo + $hi) -shr 1
                if ($cp -lt $starts[$mid]) {
                    $hi = $mid - 1
                } elseif ($cp -gt $ends[$mid]) {
                    $lo = $mid + 1
                } else {
                    $class = $classes[$mid]
                    break
                }
            }

            $width = switch ($class) {
                1 { 0 }
                2 { 2 }
                3 { 3 }
                4 { if ($wideAmbiguous) { 2 } else { 1 } }
                5 { 0 }
                default { 1 }
            }
            $total += $width
            $previous = $cp
            $previousWidth = $width
        }

        return $total
    }
}

function Test-DisplayWidthSet {
    # Whether a code point falls in a list of start and end pairs sorted by start.
    param([int[]]$Pairs, [int]$CodePoint)

    $lo = 0
    $hi = ($Pairs.Length -shr 1) - 1
    while ($lo -le $hi) {
        $mid = ($lo + $hi) -shr 1
        if ($CodePoint -lt $Pairs[2 * $mid]) {
            $hi = $mid - 1
        } elseif ($CodePoint -gt $Pairs[2 * $mid + 1]) {
            $lo = $mid + 1
        } else {
            return $true
        }
    }
    return $false
}

function Split-DisplayCharacter {
    # The text split into the characters a terminal draws: a code point with the combining marks,
    # variation selectors, skin tone, emoji joined by U+200D, or second flag letter after it, by
    # the rules Measure-DisplayWidth counts with. A gradient gives each one color, since a color
    # code inside one splits it, and between the two halves of a surrogate pair breaks it.
    param([string]$Text)

    if ([string]::IsNullOrEmpty($Text)) {
        return
    }

    $index = $script:DisplayWidthIndex
    $starts = $index.Starts
    $ends = $index.Ends
    $classes = $index.Classes
    $last = $starts.Length - 1
    $table = $script:DisplayWidthTable

    $characters = [System.Collections.Generic.List[string]]::new()
    # Where the character being built starts
    $start = 0
    # The code point before this one and the cells it added, for the sequence rules
    $previous = -1
    $previousWidth = 0
    # Whether the character before a U+200D was an emoji, so the next emoji joins it
    $joining = $false
    # Whether the character being built is one regional indicator, which the next one pairs with
    $flagOpen = $false

    $i = 0
    $length = $Text.Length
    while ($i -lt $length) {
        $at = $i
        $cp = [int]$Text[$i]
        $i++

        if ($cp -ge 0xD800 -and $cp -le 0xDBFF -and $i -lt $length) {
            $low = [int]$Text[$i]
            if ($low -ge 0xDC00 -and $low -le 0xDFFF) {
                $cp = 0x10000 + (($cp - 0xD800) -shl 10) + ($low - 0xDC00)
                $i++
            }
        }

        $joins = $true
        $isFlag = $false
        if ($cp -ge 0x20 -and $cp -le 0x7E) {
            $joins = $false
            $previous = $cp
            $previousWidth = 1
            $joining = $false
        } elseif ($cp -eq 0xFE0F) {
            if ($previousWidth -eq 1 -and (Test-DisplayWidthSet $table.Vs16Base $previous)) {
                $previousWidth = 2
            }
        } elseif ($cp -eq 0xFE0E) {
            if ($previousWidth -eq 2 -and (Test-DisplayWidthSet $table.Vs15Base $previous)) {
                $previousWidth = 1
            }
        } elseif ($cp -eq 0x200D) {
            $joining = $previousWidth -eq 2 -and (Test-DisplayWidthSet $table.Pictographic $previous)
        } elseif ($cp -ge 0x1F3FB -and $cp -le 0x1F3FF -and $previousWidth -eq 2 -and (Test-DisplayWidthSet $table.ModifierBase $previous)) {
            $joining = $false
        } elseif ($joining -and (Test-DisplayWidthSet $table.Pictographic $cp)) {
            $joining = $false
            $previous = $cp
            $previousWidth = 2
        } else {
            $joining = $false

            $class = 0
            $lo = 0
            $hi = $last
            while ($lo -le $hi) {
                $mid = ($lo + $hi) -shr 1
                if ($cp -lt $starts[$mid]) {
                    $hi = $mid - 1
                } elseif ($cp -gt $ends[$mid]) {
                    $lo = $mid + 1
                } else {
                    $class = $classes[$mid]
                    break
                }
            }

            # A character of no width belongs to the one before it; a control character is one
            # of its own
            if ($cp -ge 0x1F1E6 -and $cp -le 0x1F1FF) {
                $isFlag = -not $flagOpen
                $joins = $flagOpen
            } elseif ($class -ne 1) {
                $joins = $false
            }
            $previous = $cp
            $previousWidth = switch ($class) {
                1 { 0 }
                2 { 2 }
                3 { 3 }
                4 { 1 }
                5 { 0 }
                default { 1 }
            }
        }
        $flagOpen = $isFlag

        if (-not $joins -and $at -gt $start) {
            $characters.Add($Text.Substring($start, $at - $start))
            $start = $at
        }
    }
    $characters.Add($Text.Substring($start))

    return $characters.ToArray()
}

function Initialize-DisplayWidthIndex {
    # One list of ranges sorted by start, each with its class: 1 zero, 2 wide, 3 three cells,
    # 4 ambiguous, 5 control. The classes do not overlap, so one search finds a code point's.
    $table = $script:DisplayWidthTable
    $sources = @(
        @{ Pairs = $table.Zero; Class = 1 }
        @{ Pairs = $table.Wide; Class = 2 }
        @{ Pairs = $table.Three; Class = 3 }
        @{ Pairs = $table.Ambiguous; Class = 4 }
        @{ Pairs = $table.Control; Class = 5 }
    )
    $count = 0
    foreach ($source in $sources) { $count += $source.Pairs.Length -shr 1 }

    $starts = [int[]]::new($count)
    $ends = [int[]]::new($count)
    $classes = [byte[]]::new($count)
    $n = 0
    foreach ($source in $sources) {
        $pairs = $source.Pairs
        for ($p = 0; $p -lt $pairs.Length; $p += 2) {
            $starts[$n] = $pairs[$p]
            $ends[$n] = $pairs[$p + 1]
            $classes[$n] = $source.Class
            $n++
        }
    }

    $order = [int[]]::new($count)
    for ($k = 0; $k -lt $count; $k++) { $order[$k] = $k }
    $keys = [int[]]$starts.Clone()
    [System.Array]::Sort($keys, $order)

    $sortedEnds = [int[]]::new($count)
    $sortedClasses = [byte[]]::new($count)
    for ($k = 0; $k -lt $count; $k++) {
        $sortedEnds[$k] = $ends[$order[$k]]
        $sortedClasses[$k] = $classes[$order[$k]]
    }

    $script:DisplayWidthIndex = @{
        Starts = $keys
        Ends = $sortedEnds
        Classes = $sortedClasses
    }
}

Initialize-DisplayWidthIndex