From 232bf7c90915a95ce1065795ccedc9b5e5a64e85 Mon Sep 17 00:00:00 2001 From: Naveen Mahalingam Date: Sun, 1 Sep 2024 15:24:47 -0700 Subject: [PATCH] text: fix parsing escape sequences while wrapping; fixes #330 --- text/escape_sequences.go | 84 +++++++++++++++++++++++++++++ text/escape_sequences_test.go | 36 +++++++++++++ text/wrap.go | 99 ++++++++++++++++++----------------- text/wrap_test.go | 25 +++++++-- 4 files changed, 193 insertions(+), 51 deletions(-) create mode 100644 text/escape_sequences.go create mode 100644 text/escape_sequences_test.go diff --git a/text/escape_sequences.go b/text/escape_sequences.go new file mode 100644 index 0000000..a39c2cc --- /dev/null +++ b/text/escape_sequences.go @@ -0,0 +1,84 @@ +package text + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +type escSeqParser struct { + openSeq map[int]bool +} + +func (s *escSeqParser) Codes() []int { + codes := make([]int, 0) + for code, val := range s.openSeq { + if val { + codes = append(codes, code) + } + } + sort.Ints(codes) + return codes +} + +func (s *escSeqParser) Extract(str string) string { + escapeSeq, inEscSeq := "", false + for _, char := range str { + if char == EscapeStartRune { + inEscSeq = true + escapeSeq = "" + } + if inEscSeq { + escapeSeq += string(char) + } + if char == EscapeStopRune { + inEscSeq = false + s.Parse(escapeSeq) + } + } + return s.Sequence() +} + +func (s *escSeqParser) IsOpen() bool { + return len(s.openSeq) > 0 +} + +func (s *escSeqParser) Sequence() string { + out := strings.Builder{} + if s.IsOpen() { + out.WriteString(EscapeStart) + for idx, code := range s.Codes() { + if idx > 0 { + out.WriteRune(';') + } + out.WriteString(fmt.Sprint(code)) + } + out.WriteString(EscapeStop) + } + + return out.String() +} + +func (s *escSeqParser) Parse(seq string) { + if s.openSeq == nil { + s.openSeq = make(map[int]bool) + } + + seq = strings.Replace(seq, EscapeStart, "", 1) + seq = strings.Replace(seq, EscapeStop, "", 1) + codes := strings.Split(seq, ";") + for _, code := range codes { + code = strings.TrimSpace(code) + if codeNum, err := strconv.Atoi(code); err == nil { + switch codeNum { + case 0: // reset + s.openSeq = make(map[int]bool) // clear everything + case 22: // un-bold + delete(s.openSeq, 1) // remove bold + default: + s.openSeq[codeNum] = true + } + } + } +} diff --git a/text/escape_sequences_test.go b/text/escape_sequences_test.go new file mode 100644 index 0000000..cd5a1b7 --- /dev/null +++ b/text/escape_sequences_test.go @@ -0,0 +1,36 @@ +package text + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_escSeqParser(t *testing.T) { + t.Run("extract", func(t *testing.T) { + es := escSeqParser{} + + assert.Equal(t, "\x1b[1;91m", es.Extract("\x1b[91m\x1b[1m Bold text")) + assert.Equal(t, "\x1b[91m", es.Extract("\x1b[22m Regular text")) + assert.Equal(t, "", es.Extract("\x1b[0m Resetted")) + }) + + t.Run("parse", func(t *testing.T) { + es := escSeqParser{} + + es.Parse("\x1b[91m") // color + es.Parse("\x1b[1m") // bold + assert.Len(t, es.Codes(), 2) + assert.True(t, es.IsOpen()) + assert.Equal(t, "\x1b[1;91m", es.Sequence()) + + es.Parse("\x1b[22m") // un-bold + assert.Len(t, es.Codes(), 1) + assert.True(t, es.IsOpen()) + assert.Equal(t, "\x1b[91m", es.Sequence()) + + es.Parse("\x1b[0m") // reset + assert.Empty(t, es.Codes()) + assert.False(t, es.IsOpen()) + assert.Empty(t, es.Sequence()) + }) +} diff --git a/text/wrap.go b/text/wrap.go index a55cb51..27e7175 100644 --- a/text/wrap.go +++ b/text/wrap.go @@ -69,33 +69,56 @@ func WrapText(str string, wrapLen int) string { if wrapLen <= 0 { return "" } - - var out strings.Builder + str = strings.Replace(str, "\t", " ", -1) sLen := utf8.RuneCountInString(str) - out.Grow(sLen + (sLen / wrapLen)) - lineIdx, isEscSeq, lastEscSeq := 0, false, "" - for _, char := range str { - if char == EscapeStartRune { - isEscSeq = true - lastEscSeq = "" - } - if isEscSeq { - lastEscSeq += string(char) - } - - appendChar(char, wrapLen, &lineIdx, isEscSeq, lastEscSeq, &out) + if sLen <= wrapLen { + return str + } - if isEscSeq && char == EscapeStopRune { - isEscSeq = false - } - if lastEscSeq == EscapeReset { - lastEscSeq = "" + out := &strings.Builder{} + out.Grow(sLen + (sLen / wrapLen)) + for idx, line := range strings.Split(str, "\n") { + if idx > 0 { + out.WriteString("\n") } + wrapHard(line, wrapLen, out) } - if lastEscSeq != "" && lastEscSeq != EscapeReset { - out.WriteString(EscapeReset) - } + return out.String() + + //if wrapLen <= 0 { + // return "" + //} + // + //var out strings.Builder + //sLen := utf8.RuneCountInString(str) + //out.Grow(sLen + (sLen / wrapLen)) + // + //esp := escSeqParser{} + //lineIdx, isEscSeq, lastEscSeq := 0, false, "" + //for _, char := range str { + // if char == EscapeStartRune { + // isEscSeq = true + // lastEscSeq = "" + // } + // if isEscSeq { + // lastEscSeq += string(char) + // } + // + // appendChar(char, wrapLen, &lineIdx, isEscSeq, lastEscSeq, &out) + // + // if isEscSeq && char == EscapeStopRune { + // isEscSeq = false + // esp.Parse(lastEscSeq) + // } + // if lastEscSeq == EscapeReset { + // lastEscSeq = "" + // } + //} + //if lastEscSeq != "" && lastEscSeq != EscapeReset { + // out.WriteString(EscapeReset) + //} + //return out.String() } func appendChar(char rune, wrapLen int, lineLen *int, inEscSeq bool, lastSeenEscSeq string, out *strings.Builder) { @@ -149,26 +172,6 @@ func appendWord(word string, lineIdx *int, lastSeenEscSeq string, wrapLen int, o } } -func extractOpenEscapeSeq(str string) string { - escapeSeq, inEscSeq := "", false - for _, char := range str { - if char == EscapeStartRune { - inEscSeq = true - escapeSeq = "" - } - if inEscSeq { - escapeSeq += string(char) - } - if char == EscapeStopRune { - inEscSeq = false - } - } - if escapeSeq == EscapeReset { - escapeSeq = "" - } - return escapeSeq -} - func terminateLine(wrapLen int, lineLen *int, lastSeenEscSeq string, out *strings.Builder) { if *lineLen < wrapLen { out.WriteString(strings.Repeat(" ", wrapLen-*lineLen)) @@ -189,12 +192,12 @@ func terminateOutput(lastSeenEscSeq string, out *strings.Builder) { } func wrapHard(paragraph string, wrapLen int, out *strings.Builder) { + esp := escSeqParser{} lineLen, lastSeenEscSeq := 0, "" words := strings.Fields(paragraph) for wordIdx, word := range words { - escSeq := extractOpenEscapeSeq(word) - if escSeq != "" { - lastSeenEscSeq = escSeq + if openEscSeq := esp.Extract(word); openEscSeq != "" { + lastSeenEscSeq = openEscSeq } if lineLen > 0 { out.WriteRune(' ') @@ -218,12 +221,12 @@ func wrapHard(paragraph string, wrapLen int, out *strings.Builder) { } func wrapSoft(paragraph string, wrapLen int, out *strings.Builder) { + esp := escSeqParser{} lineLen, lastSeenEscSeq := 0, "" words := strings.Fields(paragraph) for wordIdx, word := range words { - escSeq := extractOpenEscapeSeq(word) - if escSeq != "" { - lastSeenEscSeq = escSeq + if openEscSeq := esp.Extract(word); openEscSeq != "" { + lastSeenEscSeq = openEscSeq } spacing, spacingLen := wrapSoftSpacing(lineLen) diff --git a/text/wrap_test.go b/text/wrap_test.go index cca619d..94d1c55 100644 --- a/text/wrap_test.go +++ b/text/wrap_test.go @@ -50,6 +50,15 @@ func TestWrapHard(t *testing.T) { complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+" assert.Equal(t, complexIn, WrapHard(complexIn, 27)) + + // colored text with nested escape codes: "{red}{bold}...{un-bold}...{reset}" + textUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red Text\x1b[0m" + expectedUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red \x1b[0m\n\x1b[91mText\x1b[0m" + assert.Equal(t, expectedUnBold, WrapHard(textUnBold, 23)) +} + +func TestFoo(t *testing.T) { + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapHard("\x1b[33mJon Snow\x1b[0m", 3)) } func ExampleWrapSoft() { @@ -100,6 +109,11 @@ func TestWrapSoft(t *testing.T) { assert.Equal(t, "\x1b[33mJon \x1b[0m\n\x1b[33mSnow\x1b[0m", WrapSoft("\x1b[33mJon Snow\x1b[0m", 4)) assert.Equal(t, "\x1b[33mJon \x1b[0m\n\x1b[33mSnow\x1b[0m\n\x1b[33m???\x1b[0m", WrapSoft("\x1b[33mJon Snow???\x1b[0m", 4)) + + // colored text with nested escape codes: "{red}{bold}...{un-bold}...{reset}" + textUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red Text\x1b[0m" + expectedUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red \x1b[0m\n\x1b[91mText\x1b[0m" + assert.Equal(t, expectedUnBold, WrapSoft(textUnBold, 23)) } func ExampleWrapText() { @@ -138,10 +152,15 @@ func TestWrapText(t *testing.T) { assert.Equal(t, "Jon\nSno\nw\n", WrapText("Jon\nSnow\n", 3)) assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapText("\x1b[33mJon\x1b[0m\nSnow", 3)) assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw\n", WrapText("\x1b[33mJon\x1b[0m\nSnow\n", 3)) - assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m", WrapText("\x1b[33mJon Snow\x1b[0m", 3)) - assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m\n\x1b[33m\x1b[0m", WrapText("\x1b[33mJon Snow\n", 3)) - assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33m Sn\x1b[0m\n\x1b[33mow\x1b[0m\n\x1b[33m\x1b[0m", WrapText("\x1b[33mJon Snow\n\x1b[0m", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapText("\x1b[33mJon Snow\x1b[0m", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m\n", WrapText("\x1b[33mJon Snow\n", 3)) + assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m\n\x1b[0m", WrapText("\x1b[33mJon Snow\n\x1b[0m", 3)) complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+" assert.Equal(t, complexIn, WrapText(complexIn, 27)) + + // colored text with nested escape codes: "{red}{bold}...{un-bold}...{reset}" + textUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red Text\x1b[0m" + expectedUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red \x1b[0m\n\x1b[91mText\x1b[0m" + assert.Equal(t, expectedUnBold, WrapText(textUnBold, 23)) }