rum-goggles/v1/vendor/github.com/leaanthony/go-ansi-parser/ansi.go
2024-02-23 12:10:39 -05:00

548 lines
14 KiB
Go

package ansi
import (
"fmt"
"github.com/rivo/uniseg"
"strconv"
"strings"
)
// TextStyle is a type representing the
// ansi text styles
type TextStyle int
const (
// Bold Style
Bold TextStyle = 1 << 0
// Faint Style
Faint TextStyle = 1 << 1
// Italic Style
Italic TextStyle = 1 << 2
// Blinking Style
Blinking TextStyle = 1 << 3
// Inversed Style
Inversed TextStyle = 1 << 4
// Invisible Style
Invisible TextStyle = 1 << 5
// Underlined Style
Underlined TextStyle = 1 << 6
// Strikethrough Style
Strikethrough TextStyle = 1 << 7
// Bright Style
Bright TextStyle = 1 << 8
)
type ColourMode int
const (
Default ColourMode = 0
TwoFiveSix ColourMode = 1
TrueColour ColourMode = 2
)
var invalid = fmt.Errorf("invalid ansi string")
var missingTerminator = fmt.Errorf("missing escape terminator 'm'")
var invalidTrueColorSequence = fmt.Errorf("invalid TrueColor sequence")
var invalid256ColSequence = fmt.Errorf("invalid 256 colour sequence")
const (
// Default colors uses foreground color codes [30-37].
// See ColourMap and case for background colors.
defaultForegroundColor = "37"
defaultBackgroundColor = "30"
)
// StyledText represents a single formatted string
type StyledText struct {
Label string
FgCol *Col
BgCol *Col
Style TextStyle
ColourMode ColourMode
// Offset is the offset into the input string where the StyledText begins
Offset int
// Len is the length in bytes of the substring of the input text that
// contains the styled text
Len int
}
func (s *StyledText) styleToParams() []string {
var params []string
if s.Bold() {
params = append(params, "1")
}
if s.Faint() {
params = append(params, "2")
}
if s.Italic() {
params = append(params, "3")
}
if s.Underlined() {
params = append(params, "4")
}
if s.Blinking() {
params = append(params, "5")
}
if s.Inversed() {
params = append(params, "7")
}
if s.Invisible() {
params = append(params, "8")
}
if s.Strikethrough() {
params = append(params, "9")
}
if s.FgCol != nil {
// Do we have an ID?
switch s.ColourMode {
case Default:
offset := 30
id := s.FgCol.Id
// Adjust when bold has been applied to the id
if (s.Bold() || s.Bright()) && id > 7 && id < 16 {
id -= 8
}
if s.Bright() {
offset = 90
}
params = append(params, fmt.Sprintf("%d", id+offset))
case TwoFiveSix:
params = append(params, []string{"38", "5", fmt.Sprintf("%d", s.FgCol.Id)}...)
case TrueColour:
r := fmt.Sprintf("%d", s.FgCol.Rgb.R)
g := fmt.Sprintf("%d", s.FgCol.Rgb.G)
b := fmt.Sprintf("%d", s.FgCol.Rgb.B)
params = append(params, []string{"38", "2", r, g, b}...)
}
}
if s.BgCol != nil {
// Do we have an ID?
switch s.ColourMode {
case Default:
id := s.BgCol.Id
offset := 40
if s.Bright() {
offset = 100
}
// Adjust when bold has been applied to the id
if (s.Bold() || s.Bright()) && id > 7 && id < 16 {
id -= 8
}
params = append(params, fmt.Sprintf("%d", id+offset))
case TwoFiveSix:
params = append(params, []string{"48", "5", fmt.Sprintf("%d", s.BgCol.Id)}...)
case TrueColour:
r := fmt.Sprintf("%d", s.BgCol.Rgb.R)
g := fmt.Sprintf("%d", s.BgCol.Rgb.G)
b := fmt.Sprintf("%d", s.BgCol.Rgb.B)
params = append(params, []string{"48", "2", r, g, b}...)
}
}
return params
}
func (s *StyledText) String() string {
params := strings.Join(s.styleToParams(), ";")
return "\033[0;" + params + "m" + s.Label + "\033[0m"
}
// Bold will return true if the text has a Bold style
func (s *StyledText) Bold() bool {
return s.Style&Bold == Bold
}
// Faint will return true if the text has a Faint style
func (s *StyledText) Faint() bool {
return s.Style&Faint == Faint
}
// Italic will return true if the text has an Italic style
func (s *StyledText) Italic() bool {
return s.Style&Italic == Italic
}
// Blinking will return true if the text has a Blinking style
func (s *StyledText) Blinking() bool {
return s.Style&Blinking == Blinking
}
// Inversed will return true if the text has an Inversed style
func (s *StyledText) Inversed() bool {
return s.Style&Inversed == Inversed
}
// Invisible will return true if the text has an Invisible style
func (s *StyledText) Invisible() bool {
return s.Style&Invisible == Invisible
}
// Underlined will return true if the text has an Underlined style
func (s *StyledText) Underlined() bool {
return s.Style&Underlined == Underlined
}
// Strikethrough will return true if the text has a Strikethrough style
func (s *StyledText) Strikethrough() bool {
return s.Style&Strikethrough == Strikethrough
}
// Bright will return true if the text has a Bright style
func (s *StyledText) Bright() bool {
return s.Style&Bright == Bright
}
// ColourMap maps ansi identifiers to a colour
var ColourMap = map[string]map[string]*Col{
"Regular": {
"30": Cols[0],
"31": Cols[1],
"32": Cols[2],
"33": Cols[3],
"34": Cols[4],
"35": Cols[5],
"36": Cols[6],
"37": Cols[7],
"90": Cols[8],
"91": Cols[9],
"92": Cols[10],
"93": Cols[11],
"94": Cols[12],
"95": Cols[13],
"96": Cols[14],
"97": Cols[15],
"100": Cols[8],
"101": Cols[9],
"102": Cols[10],
"103": Cols[11],
"104": Cols[12],
"105": Cols[13],
"106": Cols[14],
"107": Cols[15],
},
"Bold": {
"30": Cols[8],
"31": Cols[9],
"32": Cols[10],
"33": Cols[11],
"34": Cols[12],
"35": Cols[13],
"36": Cols[14],
"37": Cols[15],
"90": Cols[8],
"91": Cols[9],
"92": Cols[10],
"93": Cols[11],
"94": Cols[12],
"95": Cols[13],
"96": Cols[14],
"97": Cols[15],
"100": Cols[8],
"101": Cols[9],
"102": Cols[10],
"103": Cols[11],
"104": Cols[12],
"105": Cols[13],
"106": Cols[14],
"107": Cols[15],
},
"Faint": {
"30": Cols[0],
"31": Cols[1],
"32": Cols[2],
"33": Cols[3],
"34": Cols[4],
"35": Cols[5],
"36": Cols[6],
"37": Cols[7],
},
}
// Parse will convert an ansi encoded string and return
// a slice of StyledText structs that represent the text.
// If parsing is unsuccessful, an error is returned.
func Parse(input string, options ...ParseOption) ([]*StyledText, error) {
var result []*StyledText
index := 0
offset := 0
escapeCodeLen := 0
var currentStyledText = &StyledText{}
if len(input) == 0 {
return []*StyledText{currentStyledText}, nil
}
for {
// Read all chars to next escape code
esc := strings.Index(input, "\033[")
// If no more esc chars, save what's left and return
if esc == -1 {
text := input[index:]
if len(text) > 0 {
currentStyledText.Label = text
currentStyledText.Offset = offset
currentStyledText.Len = len(text) + escapeCodeLen
result = append(result, currentStyledText)
}
return result, nil
}
label := input[:esc]
if len(label) > 0 {
currentStyledText.Label = label
currentStyledText.Offset = offset
currentStyledText.Len = len(label) + escapeCodeLen
offset += currentStyledText.Len
result = append(result, currentStyledText)
currentStyledText = &StyledText{
Label: "",
FgCol: currentStyledText.FgCol,
BgCol: currentStyledText.BgCol,
Style: currentStyledText.Style,
}
escapeCodeLen = 0
}
input = input[esc:]
// skip
input = input[2:]
// Read in params
endesc := strings.Index(input, "m")
if endesc == -1 {
return nil, missingTerminator
}
paramText := input[:endesc]
input = input[endesc+1:]
escapeCodeLen += 2 + endesc + 1
params := strings.Split(paramText, ";")
colourMap := ColourMap["Regular"]
skip := 0
for index, param := range params {
if skip > 0 {
skip--
continue
}
switch param {
case "0", "":
colourMap = ColourMap["Regular"]
currentStyledText.Style = 0
currentStyledText.FgCol = nil
currentStyledText.BgCol = nil
case "1":
// Bold
colourMap = ColourMap["Bold"]
currentStyledText.Style |= Bold
case "2":
// Dim/Feint
colourMap = ColourMap["Faint"]
currentStyledText.Style |= Faint
case "3":
// Italic
currentStyledText.Style |= Italic
case "4":
// Underlined
currentStyledText.Style |= Underlined
case "5":
// Blinking
currentStyledText.Style |= Blinking
case "7":
// Inversed
currentStyledText.Style |= Inversed
case "8":
// Invisible
currentStyledText.Style |= Invisible
case "9":
// Strikethrough
currentStyledText.Style |= Strikethrough
case "30", "31", "32", "33", "34", "35", "36", "37":
currentStyledText.FgCol = colourMap[param]
case "90", "91", "92", "93", "94", "95", "96", "97":
currentStyledText.FgCol = colourMap[param]
currentStyledText.Style |= Bright
case "100", "101", "102", "103", "104", "105", "106", "107":
currentStyledText.BgCol = colourMap[param]
currentStyledText.Style |= Bright
case "40", "41", "42", "43", "44", "45", "46", "47":
bgcol := "3" + param[1:] // Equivalent of -10
currentStyledText.BgCol = colourMap[bgcol]
case "38", "48":
if len(params)-index < 3 {
return nil, invalid
}
// 256 colours
if params[index+1] == "5" {
skip = 2
colIndexText := params[index+2]
colIndex, err := strconv.Atoi(colIndexText)
if err != nil {
return nil, invalid256ColSequence
}
if colIndex < 0 || colIndex > 255 {
return nil, invalid256ColSequence
}
currentStyledText.ColourMode = TwoFiveSix
if param == "38" {
currentStyledText.FgCol = Cols[colIndex]
continue
}
currentStyledText.BgCol = Cols[colIndex]
continue
}
// we must have 4 params left
if len(params)-index < 5 {
return nil, invalidTrueColorSequence
}
if params[index+1] != "2" {
return nil, invalidTrueColorSequence
}
var r, g, b uint8
ri, err := strconv.Atoi(params[index+2])
if err != nil {
return nil, invalidTrueColorSequence
}
gi, err := strconv.Atoi(params[index+3])
if err != nil {
return nil, invalidTrueColorSequence
}
bi, err := strconv.Atoi(params[index+4])
if err != nil {
return nil, invalidTrueColorSequence
}
if bi > 255 || gi > 255 || ri > 255 {
return nil, invalidTrueColorSequence
}
if bi < 0 || gi < 0 || ri < 0 {
return nil, invalidTrueColorSequence
}
r = uint8(ri)
g = uint8(gi)
b = uint8(bi)
skip = 4
colvalue := fmt.Sprintf("#%02x%02x%02x", r, g, b)
currentStyledText.ColourMode = TrueColour
if param == "38" {
currentStyledText.FgCol = &Col{Id: 256, Hex: colvalue, Rgb: Rgb{r, g, b}}
continue
}
currentStyledText.BgCol = &Col{Id: 256, Hex: colvalue, Rgb: Rgb{r, g, b}}
case "39":
// Lookup for default foreground color.
foregroundColor := colourMap[defaultForegroundColor]
for _, option := range options {
if option.ansiForegroundColor != "" {
foregroundColor = colourMap[option.ansiForegroundColor]
break
}
}
// Set selected foreground color.
currentStyledText.FgCol = foregroundColor
case "49":
// Lookup for default background color.
backgroundColor := colourMap[defaultBackgroundColor]
for _, option := range options {
if option.ansiBackgroundColor != "" {
backgroundColor = colourMap[option.ansiBackgroundColor]
break
}
}
// Set selected background color.
currentStyledText.BgCol = backgroundColor
default:
// Unexpected codes may be ignored.
unexpectedCodeIgnored := false
for _, option := range options {
if option.ignoreUnexpectedCode {
unexpectedCodeIgnored = true
break
}
}
if !unexpectedCodeIgnored {
return nil, invalid
}
}
}
}
}
// HasEscapeCodes tests that input has escape codes.
func HasEscapeCodes(input string) bool {
return strings.IndexAny(input, "\033[") != -1
}
// String builds an ANSI string for specified StyledText slice.
func String(input []*StyledText) string {
var result strings.Builder
for _, text := range input {
params := text.styleToParams()
if len(params) == 0 {
result.WriteString(text.Label)
continue
}
result.WriteString(text.String())
}
return result.String()
}
// Truncate truncates text to length but preserves control symbols in ANSI string.
func Truncate(input string, maxChars int, options ...ParseOption) (string, error) {
parsed, err := Parse(input, options...)
if err != nil {
return "", err
}
charsLeft := maxChars
var result []*StyledText
for _, element := range parsed {
userPerceivedChars := uniseg.GraphemeClusterCount(element.Label)
if userPerceivedChars >= charsLeft {
var newLabel []rune
graphemes := uniseg.NewGraphemes(element.Label)
for graphemes.Next() {
newLabel = append(newLabel, graphemes.Runes()...)
charsLeft--
if charsLeft == 0 {
element.Label = string(newLabel)
result = append(result, element)
return String(result), nil
}
}
}
result = append(result, element)
charsLeft -= userPerceivedChars
}
return String(result), nil
}
// Cleanse removes ANSI control symbols from the string.
func Cleanse(input string, options ...ParseOption) (string, error) {
if input == "" {
return "", nil
}
parsed, err := Parse(input, options...)
if err != nil {
return "", err
}
var result strings.Builder
for _, element := range parsed {
result.WriteString(element.Label)
}
return result.String(), nil
}
// Length calculates count of user-perceived characters in ANSI string.
func Length(input string, options ...ParseOption) (int, error) {
if input == "" {
return 0, nil
}
parsed, err := Parse(input, options...)
if err != nil {
return -1, err
}
var result int
for _, element := range parsed {
userPerceivedChars := uniseg.GraphemeClusterCount(element.Label)
result += userPerceivedChars
}
return result, nil
}