Utils/fmt.ps1
|
Register-GnuUtil @{ Name = 'fmt' Category = 'Text processing' Synopsis = 'Simple optimal text formatter.' Usage = @('fmt [-WIDTH] [OPTION]... [FILE]...') Description = @( 'Reformat each paragraph in the FILE(s), writing to standard output.', 'The option -WIDTH is an abbreviated form of --width=DIGITS.', '', 'With no FILE, or when FILE is -, read standard input.' ) FileOperands = $true NumericOption = 'w' Options = @( @{ Short = 'c'; Long = 'crown-margin'; Help = 'preserve indentation of first two lines' } @{ Short = 'p'; Long = 'prefix'; Arg = 'STRING'; Help = 'reformat only lines beginning with STRING, reattaching the prefix to reformatted lines' } @{ Short = 's'; Long = 'split-only'; Help = 'split long lines, but do not refill' } @{ Short = 't'; Long = 'tagged-paragraph'; Help = 'indentation of first line different from second' } @{ Short = 'u'; Long = 'uniform-spacing'; Help = 'one space between words, two after sentences' } @{ Short = 'w'; Long = 'width'; Arg = 'WIDTH'; Help = 'maximum line width (default of 75 columns)' } @{ Short = 'g'; Long = 'goal'; Arg = 'WIDTH'; Help = 'goal width (default of 93% of width)' } ) Notes = @( 'PowerShell note: CRLF line endings are read as LF. Paragraphs of more than 1000 words', 'or 5000 characters are formatted as a whole (GNU formats them in parts).' ) } function script:Invoke-GnuFmtFile { <# .SYNOPSIS Implementation of fmt: options, then every FILE. #> param([Parameter(Mandatory)][hashtable]$Context) $maxWidth = 75 $widthGiven = Test-GnuOption -Context $Context -Key 'width' if ($widthGiven) { $text = [string](Get-GnuOptionValue -Context $Context -Key 'width') if ($text -notmatch '^\d+$') { Write-GnuError -Context $Context -Message "invalid width: '$text'"; return } if ([long]$text -gt 2500) { Write-GnuError -Context $Context -Message "invalid width: '$text': Numerical result out of range"; return } $maxWidth = [int]$text } if (Test-GnuOption -Context $Context -Key 'goal') { $text = [string](Get-GnuOptionValue -Context $Context -Key 'goal') if ($text -notmatch '^\d+$') { Write-GnuError -Context $Context -Message "invalid width: '$text'"; return } if ([long]$text -gt $maxWidth) { Write-GnuError -Context $Context -Message "invalid width: '$text': Numerical result out of range"; return } $goalWidth = [int]$text if (-not $widthGiven) { $maxWidth = $goalWidth + 10 } } else { $goalWidth = [int][Math]::Floor($maxWidth * 187 / 200) } $prefix = '' $leadSpace = 0 $fullLength = 0 if (Test-GnuOption -Context $Context -Key 'prefix') { $raw = [string](Get-GnuOptionValue -Context $Context -Key 'prefix') while ($leadSpace -lt $raw.Length -and $raw[$leadSpace] -eq ' ') { $leadSpace++ } $raw = $raw.Substring($leadSpace) $fullLength = $raw.Length $prefix = $raw.TrimEnd(' ') } $settings = @{ MaxWidth = $maxWidth; GoalWidth = $goalWidth; Prefix = $prefix; PrefixLead = $leadSpace; PrefixFull = $fullLength Crown = Test-GnuOption -Context $Context -Key 'crown-margin'; Tagged = Test-GnuOption -Context $Context -Key 'tagged-paragraph' Split = Test-GnuOption -Context $Context -Key 'split-only'; Uniform = Test-GnuOption -Context $Context -Key 'uniform-spacing' } $operands = @($Context.Operands) if ($operands.Count -eq 0) { $operands = @('-') } foreach ($operand in $operands) { $text = Get-GnuInputText -Context $Context -Operand $operand if ($null -eq $text) { continue } ConvertTo-GnuOutputLine -Text (Format-GnuFmtText -Text $text.Replace("`r`n", "`n") -Settings $settings) } } function script:Format-GnuFmtText { <# .SYNOPSIS A port of GNU fmt.c (get_paragraph, get_line, fmt_paragraph, put_paragraph) for one input text. #> param([Parameter(Mandatory)][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][hashtable]$Settings) $s = @{ Text = $Text; Pos = 0; Out = (New-Object System.Text.StringBuilder); Settings = $Settings InColumn = 0; OutColumn = 0; NextPrefixIndent = 0; PrefixIndent = 0; FirstIndent = 0; OtherIndent = 0 Tabs = $false; LastLineLength = 0; Words = $null; NextChar = -1 } $s.NextChar = Get-GnuFmtPrefix -S $s while (Get-GnuFmtParagraph -S $s) { Optimize-GnuFmtParagraph -S $s Write-GnuFmtParagraph -S $s } $s.Out.ToString() } function script:Read-GnuFmtChar { <# .SYNOPSIS getc(): the next character code, or -1 at the end. #> param([Parameter(Mandatory)][hashtable]$S) if ($S.Pos -ge $S.Text.Length) { return -1 } $S.Pos++ [int]$S.Text[$S.Pos - 1] } function script:Get-GnuFmtSpace { <# .SYNOPSIS get_space(): skips blanks from C, keeping InColumn; returns the first non-blank character. #> param([Parameter(Mandatory)][hashtable]$S, [Parameter(Mandatory)][int]$C) while ($true) { if ($C -eq 32) { $S.InColumn++ } elseif ($C -eq 9) { $S.Tabs = $true; $S.InColumn = ([Math]::Floor($S.InColumn / 8) + 1) * 8 } else { return $C } $C = Read-GnuFmtChar -S $S } } function script:Get-GnuFmtPrefix { <# .SYNOPSIS get_prefix(): reads the indentation and prefix of a line; returns the first character after them. #> param([Parameter(Mandatory)][hashtable]$S) $S.InColumn = 0 $c = Get-GnuFmtSpace -S $S -C (Read-GnuFmtChar -S $S) $prefix = $S.Settings.Prefix if ($prefix.Length -eq 0) { $S.NextPrefixIndent = [Math]::Min($S.Settings.PrefixLead, $S.InColumn) return $c } $S.NextPrefixIndent = $S.InColumn foreach ($char in $prefix.ToCharArray()) { if ($c -ne [int]$char) { return $c } $S.InColumn++ $c = Read-GnuFmtChar -S $S } Get-GnuFmtSpace -S $S -C $c } function script:Write-GnuFmtSpace { <# .SYNOPSIS put_space(): SPACE columns of blanks, as tabs where the input used tabs. #> param([Parameter(Mandatory)][hashtable]$S, [Parameter(Mandatory)][int]$Space) $target = $S.OutColumn + $Space if ($S.Tabs) { $tabTarget = [Math]::Floor($target / 8) * 8 if ($S.OutColumn + 1 -lt $tabTarget) { while ($S.OutColumn -lt $tabTarget) { [void]$S.Out.Append("`t") $S.OutColumn = ([Math]::Floor($S.OutColumn / 8) + 1) * 8 } } } while ($S.OutColumn -lt $target) { [void]$S.Out.Append(' '); $S.OutColumn++ } } function script:Copy-GnuFmtRest { <# .SYNOPSIS copy_rest(): copies a line that is not part of a paragraph; returns the newline or -1 that ends it. #> param([Parameter(Mandatory)][hashtable]$S, [Parameter(Mandatory)][int]$C) $S.OutColumn = 0 $prefix = $S.Settings.Prefix if ($S.InColumn -gt $S.NextPrefixIndent -or ($C -ne 10 -and $C -ne -1)) { Write-GnuFmtSpace -S $S -Space $S.NextPrefixIndent $k = 0 while ($S.OutColumn -ne $S.InColumn -and $k -lt $prefix.Length) { [void]$S.Out.Append($prefix[$k]); $k++; $S.OutColumn++ } if ($C -ne -1 -and $C -ne 10) { Write-GnuFmtSpace -S $S -Space ($S.InColumn - $S.OutColumn) } if ($C -eq -1 -and $S.InColumn -ge $S.NextPrefixIndent + $prefix.Length) { [void]$S.Out.Append("`n") } } while ($C -ne 10 -and $C -ne -1) { [void]$S.Out.Append([char]$C) $C = Read-GnuFmtChar -S $S } $C } function script:Test-GnuFmtSameParagraph { <# .SYNOPSIS same_paragraph(): could the line starting with C belong to the current paragraph? #> param([Parameter(Mandatory)][hashtable]$S, [Parameter(Mandatory)][int]$C) $S.NextPrefixIndent -eq $S.PrefixIndent -and $S.InColumn -ge $S.NextPrefixIndent + $S.Settings.PrefixFull -and $C -ne 10 -and $C -ne -1 } function script:Set-GnuFmtOtherIndent { <# .SYNOPSIS set_other_indent(): the indentation of the lines after the first. #> param([Parameter(Mandatory)][hashtable]$S, [Parameter(Mandatory)][bool]$Same) if ($S.Settings.Split) { $S.OtherIndent = $S.FirstIndent } elseif ($S.Settings.Crown) { if ($Same) { $S.OtherIndent = $S.InColumn } else { $S.OtherIndent = $S.FirstIndent } } elseif ($S.Settings.Tagged) { if ($Same -and $S.InColumn -ne $S.FirstIndent) { $S.OtherIndent = $S.InColumn } elseif ($S.OtherIndent -eq $S.FirstIndent) { if ($S.FirstIndent -eq 0) { $S.OtherIndent = 3 } else { $S.OtherIndent = 0 } } } else { $S.OtherIndent = $S.FirstIndent } } function script:Get-GnuFmtParagraph { <# .SYNOPSIS get_paragraph(): copies blank and non-prefixed lines, then reads one paragraph into words. $false at the end. #> param([Parameter(Mandatory)][hashtable]$S) $S.LastLineLength = 0 $c = $S.NextChar while ($c -eq 10 -or $c -eq -1 -or $S.NextPrefixIndent -lt $S.Settings.PrefixLead -or $S.InColumn -lt $S.NextPrefixIndent + $S.Settings.PrefixFull) { $c = Copy-GnuFmtRest -S $S -C $c if ($c -eq -1) { $S.NextChar = -1; return $false } [void]$S.Out.Append("`n") $c = Get-GnuFmtPrefix -S $S } $S.PrefixIndent = $S.NextPrefixIndent $S.FirstIndent = $S.InColumn $S.Words = New-Object 'System.Collections.Generic.List[hashtable]' $c = Read-GnuFmtLine -S $S -C $c Set-GnuFmtOtherIndent -S $S -Same (Test-GnuFmtSameParagraph -S $S -C $c) if ($S.Settings.Split) { } elseif ($S.Settings.Crown) { if (Test-GnuFmtSameParagraph -S $S -C $c) { do { $c = Read-GnuFmtLine -S $S -C $c } while ((Test-GnuFmtSameParagraph -S $S -C $c) -and $S.InColumn -eq $S.OtherIndent) } } elseif ($S.Settings.Tagged) { if ((Test-GnuFmtSameParagraph -S $S -C $c) -and $S.InColumn -ne $S.FirstIndent) { do { $c = Read-GnuFmtLine -S $S -C $c } while ((Test-GnuFmtSameParagraph -S $S -C $c) -and $S.InColumn -eq $S.OtherIndent) } } else { while ((Test-GnuFmtSameParagraph -S $S -C $c) -and $S.InColumn -eq $S.OtherIndent) { $c = Read-GnuFmtLine -S $S -C $c } } $last = $S.Words[$S.Words.Count - 1] $last.Period = $true $last.Final = $true $S.NextChar = $c $true } function script:Read-GnuFmtLine { <# .SYNOPSIS get_line(): splits one line into words (with punctuation and sentence-end flags); returns the next line's first character. #> param([Parameter(Mandatory)][hashtable]$S, [Parameter(Mandatory)][int]$C) do { $builder = New-Object System.Text.StringBuilder do { [void]$builder.Append([char]$C) $C = Read-GnuFmtChar -S $S } while ($C -ne -1 -and -not [char]::IsWhiteSpace([char]$C)) $text = $builder.ToString() $S.InColumn += $text.Length $close = $text.Length - 1 while ($close -gt 0 -and ")]'`"".IndexOf($text[$close]) -ge 0) { $close-- } $word = @{ Text = $text; Length = $text.Length; Space = 0; Final = $false; LineLength = 0; BestCost = [long]0; NextBreak = 0 Paren = "(['``""".IndexOf($text[0]) -ge 0 Punct = [char]::IsPunctuation($text[$text.Length - 1]) -or [char]::IsSymbol($text[$text.Length - 1]) Period = '.?!'.IndexOf($text[$close]) -ge 0 } $start = $S.InColumn $C = Get-GnuFmtSpace -S $S -C $C $word.Space = $S.InColumn - $start $word.Final = ($C -eq -1) -or ($word.Period -and ($C -eq 10 -or $word.Space -gt 1)) if ($C -eq 10 -or $C -eq -1 -or $S.Settings.Uniform) { if ($word.Final) { $word.Space = 2 } else { $word.Space = 1 } } $S.Words.Add($word) } while ($C -ne 10 -and $C -ne -1) Get-GnuFmtPrefix -S $S } function script:Get-GnuFmtBaseCost { <# .SYNOPSIS base_cost(): the cost of starting a line with word Index (sentence, punctuation, widow and orphan rules). #> param([Parameter(Mandatory)][hashtable]$S, [Parameter(Mandatory)][int]$Index) $words = $S.Words $cost = [long]4900 # LINE_COST = 70^2 if ($Index -gt 0) { $previous = $words[$Index - 1] if ($previous.Period) { if ($previous.Final) { $cost -= 2500 } else { $cost += 360000 } # SENTENCE_BONUS, NOBREAK_COST } elseif ($previous.Punct) { $cost -= 1600 } # PUNCT_BONUS elseif ($Index -gt 1 -and $words[$Index - 2].Final) { $cost += [long][Math]::Floor(40000 / ($previous.Length + 2)) # WIDOW_COST = EQUIV(200) / (n + 2) } } $word = $words[$Index] if ($word.Paren) { $cost -= 1600 } # PAREN_BONUS elseif ($word.Final) { $cost += [long][Math]::Floor(22500 / ($word.Length + 2)) # ORPHAN_COST = EQUIV(150) / (n + 2) } $cost } function script:Get-GnuFmtLineCost { <# .SYNOPSIS line_cost(): the cost of a line of length Length followed by word Next. #> param([Parameter(Mandatory)][hashtable]$S, [Parameter(Mandatory)][int]$Next, [Parameter(Mandatory)][long]$Length) $count = $S.Words.Count if ($Next -eq $count) { return [long]0 } $n = [long]($S.Settings.GoalWidth - $Length) $cost = (10 * $n) * (10 * $n) # SHORT_COST if ($S.Words[$Next].NextBreak -ne $count) { $n = $Length - $S.Words[$Next].LineLength $cost += [long][Math]::Floor(((10 * $n) * (10 * $n)) / 2) # RAGGED_COST } $cost } function script:Optimize-GnuFmtParagraph { <# .SYNOPSIS fmt_paragraph(): chooses the line breaks with the lowest total cost (dynamic programming from the end). #> param([Parameter(Mandatory)][hashtable]$S) $words = $S.Words $count = $words.Count $maxWidth = $S.Settings.MaxWidth $bestCosts = New-Object long[] ($count + 1) for ($start = $count - 1; $start -ge 0; $start--) { $best = [long]::MaxValue if ($start -eq 0) { $length = [long]$S.FirstIndent } else { $length = [long]$S.OtherIndent } $w = $start $length += $words[$w].Length do { $w++ $cost = (Get-GnuFmtLineCost -S $S -Next $w -Length $length) + $bestCosts[$w] if ($cost -lt $best) { $best = $cost $words[$start].NextBreak = $w $words[$start].LineLength = $length } if ($w -eq $count) { break } $length += $words[$w - 1].Space + $words[$w].Length } while ($length -lt $maxWidth) $bestCosts[$start] = $best + (Get-GnuFmtBaseCost -S $S -Index $start) } } function script:Write-GnuFmtParagraph { <# .SYNOPSIS put_paragraph() / put_line(): writes the lines with prefix and indentation. #> param([Parameter(Mandatory)][hashtable]$S) $words = $S.Words $index = 0 $indent = $S.FirstIndent while ($index -lt $words.Count) { $S.OutColumn = 0 Write-GnuFmtSpace -S $S -Space $S.PrefixIndent [void]$S.Out.Append($S.Settings.Prefix) $S.OutColumn += $S.Settings.Prefix.Length Write-GnuFmtSpace -S $S -Space ($indent - $S.OutColumn) $end = $words[$index].NextBreak for ($w = $index; $w -lt $end; $w++) { [void]$S.Out.Append($words[$w].Text) $S.OutColumn += $words[$w].Length if ($w -lt $end - 1) { Write-GnuFmtSpace -S $S -Space $words[$w].Space } } $S.LastLineLength = $S.OutColumn [void]$S.Out.Append("`n") $index = $end $indent = $S.OtherIndent } } |