diff --git a/oviewer/convert_es.go b/oviewer/convert_es.go index 610840c..213f9b3 100644 --- a/oviewer/convert_es.go +++ b/oviewer/convert_es.go @@ -1,16 +1,22 @@ package oviewer import ( - "errors" "fmt" "log" "strconv" "strings" "sync" + "unicode" "github.com/gdamore/tcell/v2" ) +// convert_es converts an ANSI escape sequence to an ov representation. +// In ov, it is mainly to convert CSI (Control Sequence Introducer). +// Furthermore, it is to convert SGR (Set Graphics Rendition) in it to ov's style. +// (Some hyperlinks are also interpreted). +// Other than that, it hides them so that they do not interfere. + // The states of the ANSI escape code parser. const ( ansiText = iota @@ -24,7 +30,19 @@ const ( oscURL ) -// escape sequence states. +// FinalByte is a character outside the escape sequence. +// If FinalByte is included, the interpretation of the escape sequence is terminated +// and it is considered an error as it did not terminate correctly. +const FinalByte = 0x40 + +const ( + // Colors256 is the index of the 256 color. 8-bit colors. 0-255. + Colors256 = 5 + // ColorsRGB is the index of the RGB color. 24-bit colors. r:0-255 g:0-255 b:0-255. + ColorsRGB = 2 +) + +// escapeSequence is a structure that holds the escape sequence. type escapeSequence struct { parameter strings.Builder url strings.Builder @@ -40,8 +58,15 @@ func newESConverter() *escapeSequence { } } -// csiCache caches escape sequences. -var csiCache sync.Map +// sgrCache caches SGR escape sequences. +var sgrCache sync.Map + +// sgrParams is a structure that holds the SGR parameters. +type sgrParams struct { + code int + params []string + colonF bool +} // convert parses an escape sequence and changes state. // Returns true if it is an escape sequence and a non-printing character. @@ -50,7 +75,7 @@ func (es *escapeSequence) convert(st *parseState) bool { switch es.state { case ansiEscape: switch mainc { - case '[': // Control Sequence Introducer. + case '[': // CSI(Control Sequence Introducer). es.parameter.Reset() es.state = ansiControlSequence return true @@ -77,13 +102,8 @@ func (es *escapeSequence) convert(st *parseState) bool { return true case ansiControlSequence: switch { - case mainc == 'm': - style, err := csToStyle(st.style, es.parameter.String()) - if err != nil { - es.state = ansiText - return false - } - st.style = style + case mainc == 'm': // SGR(Set Graphics Rendition). + st.style = sgrStyle(st.style, es.parameter.String()) case mainc == 'K': // CSI 0 K or CSI K maintains the style after the newline // (can change the background color of the line). @@ -93,13 +113,11 @@ func (es *escapeSequence) convert(st *parseState) bool { } case mainc >= 'A' && mainc <= 'T': // Ignore. - case mainc >= '0' && mainc <= 'f': - es.parameter.WriteRune(mainc) - return true - case mainc < 0x40: + case mainc < FinalByte: es.parameter.WriteRune(mainc) return true } + // End of escape sequence. es.state = ansiText return true case otherSequence: @@ -169,189 +187,271 @@ func (es *escapeSequence) convert(st *parseState) bool { return false } -// csToStyle returns tcell.Style from the control sequence. -func csToStyle(style tcell.Style, params string) (tcell.Style, error) { - if params == "0" || params == "" || params == ";" { - return tcell.StyleDefault.Normal(), nil +// sgrStyle returns tcell.Style from the SGR control sequence. +func sgrStyle(style tcell.Style, paramStr string) tcell.Style { + switch paramStr { + case "0", "", ";": + return tcell.StyleDefault.Normal() } - if s, ok := csiCache.Load(params); ok { + if s, ok := sgrCache.Load(paramStr); ok { style = applyStyle(style, s.(OVStyle)) - return style, nil + return style } - s, err := parseCSI(params) - if err != nil { - return style, err - } - csiCache.Store(params, s) - return applyStyle(style, s), nil + s := parseSGR(paramStr) + sgrCache.Store(paramStr, s) + return applyStyle(style, s) } -// parseCSI actually parses the style and returns ovStyle. -func parseCSI(params string) (OVStyle, error) { +// parseSGR actually parses the style and returns OVStyle. +func parseSGR(paramStr string) OVStyle { s := OVStyle{} - fields := strings.Split(params, ";") - for index := 0; index < len(fields); index++ { - field := fields[index] - num, err := toESCode(field) + paramList := strings.Split(paramStr, ";") + for index := 0; index < len(paramList); index++ { + sgr, err := toSGRCode(paramList, index) if err != nil { - if errors.Is(err, ErrNotSuuport) { - return s, nil - } - return s, err + return OVStyle{} } - switch num { - case 0: + + switch sgr.code { + case 0: // Reset. s = OVStyle{} - case 1: + case 1: // Bold On s.Bold = true - case 2: + s.UnBold = false + case 2: // Dim On s.Dim = true - case 3: + s.UnDim = false + case 3: // Italic On s.Italic = true - case 4: + s.UnItalic = false + case 4: // Underline On + s.UnUnderline = false s.Underline = true - case 5: - s.Blink = true - case 6: + if len(sgr.params) == 0 { + continue + } + // The parameter is specified(4:). + n, err := sgrNumber(sgr.params[0]) + if err != nil { + return OVStyle{} + } + // Support only Underline Off (4:0). + if n == 0 { + s.Underline = false + s.UnUnderline = true + } + case 5: // Blink On s.Blink = true - case 7: + s.UnBlink = false + case 6: // Rapid Blink On + s.Blink = true // Rapid Blink is the same as Blink. + s.UnBlink = false + case 7: // Reverse On s.Reverse = true - case 8: - // Invisible On (not implemented) - case 9: + s.UnReverse = false + case 8: // Invisible On + // (not implemented) + case 9: // StrikeThrough On s.StrikeThrough = true - case 22: + s.UnStrikeThrough = false + case 21: // Double Underline On + s.Underline = true // Double Underline is the same as Underline. + s.UnUnderline = false + case 22: // Bold Off + s.Bold = false s.UnBold = true - case 23: + case 23: // Italic Off + s.Italic = false s.UnItalic = true - case 24: + case 24: // Underline Off + s.Underline = false s.UnUnderline = true - case 25: + case 25: // Blink Off + s.Blink = false s.UnBlink = true - case 27: + case 27: // Reverse Off + s.Reverse = false s.UnReverse = true - case 28: - // Invisible Off (not implemented) - case 29: + case 28: // Invisible Off + // (not implemented) + case 29: // StrikeThrough Off + s.StrikeThrough = false s.UnStrikeThrough = true - case 30, 31, 32, 33, 34, 35, 36, 37: - s.Foreground = colorName(num - 30) - case 38: - i, color := csColor(fields[index:]) - if i == 0 { - return s, nil + case 30, 31, 32, 33, 34, 35, 36, 37: // Foreground color + s.Foreground = colorName(sgr.code - 30) + case 38: // Foreground color extended + color, i, err := parseSGRColor(sgr) + if err != nil { + return OVStyle{} } index += i s.Foreground = color - case 39: + case 39: // ForegroundColorDefault s.Foreground = "default" - case 40, 41, 42, 43, 44, 45, 46, 47: - s.Background = colorName(num - 40) - case 48: - i, color := csColor(fields[index:]) - if i == 0 { - return s, nil + case 40, 41, 42, 43, 44, 45, 46, 47: // Background color + s.Background = colorName(sgr.code - 40) + case 48: // Background color extended + color, i, err := parseSGRColor(sgr) + if err != nil { + return OVStyle{} } index += i s.Background = color - case 49: + case 49: // BackgroundColorDefault s.Background = "default" - case 53: + case 53: // Overline On s.OverLine = true - case 55: + case 55: // Overline Off s.UnOverLine = true - case 90, 91, 92, 93, 94, 95, 96, 97: - s.Foreground = colorName(num - 82) - case 100, 101, 102, 103, 104, 105, 106, 107: - s.Background = colorName(num - 92) + case 58: // UnderlineColor + // (not implemented). Increase index only. + _, i, err := parseSGRColor(sgr) + if err != nil { + return s + } + index += i + case 59: // UnderlineColorDefault + // (not implemented). + case 73, 74, 75: // VerticalAlignment + // (not implemented). + case 90, 91, 92, 93, 94, 95, 96, 97: // Bright Foreground color + s.Foreground = colorName(sgr.code - 82) + case 100, 101, 102, 103, 104, 105, 106, 107: // Bright Background color + s.Background = colorName(sgr.code - 92) } } - return s, nil + return s } -// toESCode converts a string to an integer. -// If the code is smaller than 0x40 for compatibility, -// return -1 instead of an error. -func toESCode(str string) (int, error) { - num, err := strconv.Atoi(str) +// toSGRCode converts the SGR parameter to a code. +// If there is no (:) separator, use the slice of paramList. +func toSGRCode(paramList []string, index int) (sgrParams, error) { + str := paramList[index] + sgr := sgrParams{} + colonLists := strings.Split(str, ":") + code, err := sgrNumber(colonLists[0]) if err != nil { - for _, char := range str { - if char >= 0x40 { - return 0, ErrInvalidCSI - } + return sgrParams{}, ErrNotSuuport + } + sgr.code = code + + // If the colon parameter is used, interpret the first paramList as the code. + if len(colonLists) > 1 { + sgr.params = colonLists[1:] + sgr.colonF = true + return sgr, nil + } + // If the colon parameter is not used, interpret the following paramList as parameters. + if code == 38 || code == 48 || code == 58 { + if len(paramList) > index+1 { + sgr.params = paramList[index+1:] + sgr.colonF = false } + } + return sgr, nil +} + +// sgrNumber converts a string to a number. +// If the string is empty, it returns 0. +// If the string contains a non-numeric character, it returns an error. +func sgrNumber(str string) (int, error) { + if str == "" { + return 0, nil + } + if containsNonDigit(str) { return 0, ErrNotSuuport } + num, err := strconv.Atoi(str) + if err != nil { + return 0, err + } return num, nil } -// csColor parses 8-bit color and 24-bit color. -func csColor(fields []string) (int, string) { - if len(fields) < 2 { - return 0, "" +// containsNonDigit returns true if the string contains a non-numeric character. +func containsNonDigit(str string) bool { + for _, char := range str { + if !unicode.IsDigit(char) { + return true + } + } + return false +} + +// parseSGRColor parses 256 color or RGB color. +// Returns the color name and increase in the index (the colon does not increase). +func parseSGRColor(sgr sgrParams) (string, int, error) { + color, inc, error := convertSGRColor(sgr) + if sgr.colonF { // In the case of colon, index does not increase. + inc = 0 } - if fields[1] == "" { - return 1, "" + return color, inc, error +} + +// convertSGRColor converts the SGR color to a string that can be used to specify the color of tcell. +// There are three ways to specify the extended color: +// 38;5;n or 38;2;n;n;n semi-colon separated. +// 38:5:n or 38:2:n:n:n colon separated. +// 38:2::n:n:n colon separated with double colon. +// +// The first return value is the color name. +// The second return value is the number of parameter index increments. +// The last return value is an error. +// Illegal characters will result in an error, while others will be ignored without error. +func convertSGRColor(sgr sgrParams) (string, int, error) { + if len(sgr.params) == 0 { + return "", 0, nil } - ex, err := strconv.Atoi(fields[1]) + inc := 1 + ex, err := sgrNumber(sgr.params[0]) if err != nil { - return 1, "" + return "", inc, err } switch ex { - case 5: // 8-bit colors. - if len(fields) < 3 { - return 1, "" + case Colors256: // 38:5:n + if len(sgr.params) < 2 { + return "", inc, nil } - if fields[2] == "" { - return len(fields), "" - } - - color, err := parse8BitColor(fields[2]) + color, err := parse256Color(sgr.params[1]) if err != nil { - return 0, color + return color, inc, err } - return 2, color - case 2: // 24-bit colors. - if len(fields) < 5 { - return len(fields), "" + inc++ + return color, inc, nil + case ColorsRGB: // 38:2:r:g:b + if len(sgr.params) < 4 { + return "", len(sgr.params), nil } - for i := 2; i < 5; i++ { - if fields[i] == "" { - return i, "" - } + rgb := sgr.params[1:4] // 38:2:r:g:b + // The colon(colonF) parameter allows two colons(::) to be done before the RGB is specified. + if sgr.colonF && sgr.params[1] == "" && len(sgr.params) > 4 { + rgb = sgr.params[2:5] // 38:2::r:g:b } - - color, err := parseRGBColor(fields[2:5]) + color, err := parseRGBColor(rgb[0], rgb[1], rgb[2]) if err != nil { - return 0, color + return color, inc, err } - return 4, color + inc += 3 + return color, inc, nil } - return 1, "" + return "", inc, nil } -func parse8BitColor(field string) (string, error) { - c, err := strconv.Atoi(field) +// parse256Color parses the 8-bit color. +func parse256Color(param string) (string, error) { + if param == "" { + return "", nil + } + c, err := sgrNumber(param) if err != nil { - return "", fmt.Errorf("invalid 8-bit color value: %v", field) + return "", err } color := colorName(c) return color, nil } -func parseRGBColor(fields []string) (string, error) { - red, err1 := strconv.Atoi(fields[0]) - green, err2 := strconv.Atoi(fields[1]) - blue, err3 := strconv.Atoi(fields[2]) - if err1 != nil || err2 != nil || err3 != nil { - return "", fmt.Errorf("invalid RGB color values: %v, %v, %v", fields[0], fields[1], fields[2]) - } - color := fmt.Sprintf("#%02x%02x%02x", red, green, blue) - return color, nil -} - // colorName returns a string that can be used to specify the color of tcell. func colorName(colorNumber int) string { if colorNumber < 0 || colorNumber > 255 { @@ -359,3 +459,21 @@ func colorName(colorNumber int) string { } return tcell.PaletteColor(colorNumber).String() } + +// parseRGBColor parses the RGB color. +func parseRGBColor(red string, green string, blue string) (string, error) { + if red == "" || green == "" || blue == "" { + return "", nil + } + r, err1 := sgrNumber(red) + g, err2 := sgrNumber(green) + b, err3 := sgrNumber(blue) + if err1 != nil || err2 != nil || err3 != nil { + return "", fmt.Errorf("invalid RGB color values: %v, %v, %v", red, green, blue) + } + if r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255 { + return "", nil + } + color := fmt.Sprintf("#%02x%02x%02x", r, g, b) + return color, nil +} diff --git a/oviewer/convert_es_test.go b/oviewer/convert_es_test.go index 0037fc3..cb22ce6 100644 --- a/oviewer/convert_es_test.go +++ b/oviewer/convert_es_test.go @@ -166,7 +166,7 @@ func Test_escapeSequence_convert(t *testing.T) { } } -func Test_csToStyle(t *testing.T) { +func Test_sgrStyle(t *testing.T) { t.Parallel() type args struct { style tcell.Style @@ -236,10 +236,7 @@ func Test_csToStyle(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := csToStyle(tt.args.style, tt.args.csiParameter) - if (err != nil) != tt.wantErr { - t.Errorf("csToStyle() error = %v, wantErr %v", err, tt.wantErr) - } + got := sgrStyle(tt.args.style, tt.args.csiParameter) if !reflect.DeepEqual(got, tt.want) { t.Errorf("csToStyle() = %v, want %v", got, tt.want) gfg, gbg, gattr := got.Decompose() @@ -250,7 +247,7 @@ func Test_csToStyle(t *testing.T) { } } -func Test_parseCSI(t *testing.T) { +func Test_parseSGR(t *testing.T) { type args struct { params string } @@ -343,12 +340,120 @@ func Test_parseCSI(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := parseCSI(tt.args.params) - if (err != nil) != tt.wantErr { - t.Errorf("parseCSI() error = %v, wantErr %v", err, tt.wantErr) + if got := parseSGR(tt.args.params); !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseSGI() = %v, want %v", got, tt.want) } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseCSI() = %v, want %v", got, tt.want) + }) + } +} + +func Test_parseSGR2(t *testing.T) { + type args struct { + params string + } + tests := []struct { + name string + args args + want OVStyle + }{ + { + name: "test-Colon1", + args: args{ + params: "38:5:1", + }, + want: OVStyle{ + Foreground: "maroon", + }, + }, + { + name: "test-Colon2", + args: args{ + params: "48:2:255:0:0", + }, + want: OVStyle{ + Background: "#ff0000", + }, + }, + { + name: "test-Colon3", + args: args{ + params: "48:2::255:0:0", + }, + want: OVStyle{ + Background: "#ff0000", + }, + }, + { + name: "test-Underline-colon", + args: args{ + params: "4:0", + }, + want: OVStyle{ + Underline: false, + UnUnderline: true, + }, + }, + { + name: "test-invalid1", + args: args{ + params: "38:5:-", + }, + want: OVStyle{}, + }, + { + name: "test-invalid2", + args: args{ + params: "38:5:999", + }, + want: OVStyle{}, + }, + { + name: "test-invalid3", + args: args{ + params: "38:5", + }, + want: OVStyle{}, + }, + { + name: "test-valid", + args: args{ + params: "38:5:0", + }, + want: OVStyle{ + Foreground: "black", + }, + }, + { + name: "test-rgb-valid", + args: args{ + params: "4;38:2:255:0:0", + }, + want: OVStyle{ + Underline: true, + Foreground: "#ff0000", + }, + }, + { + name: "test-rgb-invalid", + args: args{ + params: "4;38:2:255:0:-", + }, + want: OVStyle{}, + }, + { + name: "test-rgb-over", + args: args{ + params: "4;38:2:255:0:999", + }, + want: OVStyle{ + Underline: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseSGR(tt.args.params); !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseSGR() = %#v, want %#v", got, tt.want) } }) } diff --git a/oviewer/oviewer.go b/oviewer/oviewer.go index 21efa62..803ade9 100644 --- a/oviewer/oviewer.go +++ b/oviewer/oviewer.go @@ -426,8 +426,8 @@ var ( ErrNotAlignMode = errors.New("not an align mode") // ErrNoColumnSelected indicates that no column is selected. ErrNoColumnSelected = errors.New("no column selected") - // ErrInvalidCSI indicates that the CSI is invalid. - ErrInvalidCSI = errors.New("invalid CSI") + // ErrInvalidSGR indicates that the SGR is invalid. + ErrInvalidSGR = errors.New("invalid SGR") // ErrNotSuuport indicates that it is not supported. ErrNotSuuport = errors.New("not support") )