Skip to content

Commit c4081bb

Browse files
authored
text: fix parsing escape sequences while wrapping; fixes #330 (#333)
1 parent c078fb8 commit c4081bb

File tree

4 files changed

+176
-51
lines changed

4 files changed

+176
-51
lines changed

text/escape_sequences.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package text
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strconv"
7+
"strings"
8+
)
9+
10+
type escSeqParser struct {
11+
openSeq map[int]bool
12+
}
13+
14+
func (s *escSeqParser) Codes() []int {
15+
codes := make([]int, 0)
16+
for code, val := range s.openSeq {
17+
if val {
18+
codes = append(codes, code)
19+
}
20+
}
21+
sort.Ints(codes)
22+
return codes
23+
}
24+
25+
func (s *escSeqParser) Extract(str string) string {
26+
escapeSeq, inEscSeq := "", false
27+
for _, char := range str {
28+
if char == EscapeStartRune {
29+
inEscSeq = true
30+
escapeSeq = ""
31+
}
32+
if inEscSeq {
33+
escapeSeq += string(char)
34+
}
35+
if char == EscapeStopRune {
36+
inEscSeq = false
37+
s.Parse(escapeSeq)
38+
}
39+
}
40+
return s.Sequence()
41+
}
42+
43+
func (s *escSeqParser) IsOpen() bool {
44+
return len(s.openSeq) > 0
45+
}
46+
47+
func (s *escSeqParser) Sequence() string {
48+
out := strings.Builder{}
49+
if s.IsOpen() {
50+
out.WriteString(EscapeStart)
51+
for idx, code := range s.Codes() {
52+
if idx > 0 {
53+
out.WriteRune(';')
54+
}
55+
out.WriteString(fmt.Sprint(code))
56+
}
57+
out.WriteString(EscapeStop)
58+
}
59+
60+
return out.String()
61+
}
62+
63+
func (s *escSeqParser) Parse(seq string) {
64+
if s.openSeq == nil {
65+
s.openSeq = make(map[int]bool)
66+
}
67+
68+
seq = strings.Replace(seq, EscapeStart, "", 1)
69+
seq = strings.Replace(seq, EscapeStop, "", 1)
70+
codes := strings.Split(seq, ";")
71+
for _, code := range codes {
72+
code = strings.TrimSpace(code)
73+
if codeNum, err := strconv.Atoi(code); err == nil {
74+
switch codeNum {
75+
case 0: // reset
76+
s.openSeq = make(map[int]bool) // clear everything
77+
case 22: // reset intensity
78+
delete(s.openSeq, 1) // remove bold
79+
delete(s.openSeq, 2) // remove faint
80+
case 23: // not italic
81+
delete(s.openSeq, 3) // remove italic
82+
case 24: // not underlined
83+
delete(s.openSeq, 4) // remove underline
84+
case 25: // not blinking
85+
delete(s.openSeq, 5) // remove slow blink
86+
delete(s.openSeq, 6) // remove rapid blink
87+
case 27: // not reversed
88+
delete(s.openSeq, 7) // remove reverse
89+
case 29: // not crossed-out
90+
delete(s.openSeq, 9) // remove crossed-out
91+
default:
92+
s.openSeq[codeNum] = true
93+
}
94+
}
95+
}
96+
}

text/escape_sequences_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package text
2+
3+
import (
4+
"github.com/stretchr/testify/assert"
5+
"testing"
6+
)
7+
8+
func Test_escSeqParser(t *testing.T) {
9+
t.Run("extract", func(t *testing.T) {
10+
es := escSeqParser{}
11+
12+
assert.Equal(t, "\x1b[1;3;4;5;7;9;91m", es.Extract("\x1b[91m\x1b[1m\x1b[3m\x1b[4m\x1b[5m\x1b[7m\x1b[9m Spicy"))
13+
assert.Equal(t, "\x1b[3;4;5;7;9;91m", es.Extract("\x1b[22m No Bold"))
14+
assert.Equal(t, "\x1b[4;5;7;9;91m", es.Extract("\x1b[23m No Italic"))
15+
assert.Equal(t, "\x1b[5;7;9;91m", es.Extract("\x1b[24m No Underline"))
16+
assert.Equal(t, "\x1b[7;9;91m", es.Extract("\x1b[25m No Blink"))
17+
assert.Equal(t, "\x1b[9;91m", es.Extract("\x1b[27m No Reverse"))
18+
assert.Equal(t, "\x1b[91m", es.Extract("\x1b[29m No Crossed-Out"))
19+
assert.Equal(t, "", es.Extract("\x1b[0m Resetted"))
20+
})
21+
22+
t.Run("parse", func(t *testing.T) {
23+
es := escSeqParser{}
24+
25+
es.Parse("\x1b[91m") // color
26+
es.Parse("\x1b[1m") // bold
27+
assert.Len(t, es.Codes(), 2)
28+
assert.True(t, es.IsOpen())
29+
assert.Equal(t, "\x1b[1;91m", es.Sequence())
30+
31+
es.Parse("\x1b[22m") // un-bold
32+
assert.Len(t, es.Codes(), 1)
33+
assert.True(t, es.IsOpen())
34+
assert.Equal(t, "\x1b[91m", es.Sequence())
35+
36+
es.Parse("\x1b[0m") // reset
37+
assert.Empty(t, es.Codes())
38+
assert.False(t, es.IsOpen())
39+
assert.Empty(t, es.Sequence())
40+
})
41+
}

text/wrap.go

Lines changed: 17 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -69,32 +69,21 @@ func WrapText(str string, wrapLen int) string {
6969
if wrapLen <= 0 {
7070
return ""
7171
}
72-
73-
var out strings.Builder
72+
str = strings.Replace(str, "\t", " ", -1)
7473
sLen := utf8.RuneCountInString(str)
75-
out.Grow(sLen + (sLen / wrapLen))
76-
lineIdx, isEscSeq, lastEscSeq := 0, false, ""
77-
for _, char := range str {
78-
if char == EscapeStartRune {
79-
isEscSeq = true
80-
lastEscSeq = ""
81-
}
82-
if isEscSeq {
83-
lastEscSeq += string(char)
84-
}
85-
86-
appendChar(char, wrapLen, &lineIdx, isEscSeq, lastEscSeq, &out)
74+
if sLen <= wrapLen {
75+
return str
76+
}
8777

88-
if isEscSeq && char == EscapeStopRune {
89-
isEscSeq = false
90-
}
91-
if lastEscSeq == EscapeReset {
92-
lastEscSeq = ""
78+
out := &strings.Builder{}
79+
out.Grow(sLen + (sLen / wrapLen))
80+
for idx, line := range strings.Split(str, "\n") {
81+
if idx > 0 {
82+
out.WriteString("\n")
9383
}
84+
wrapHard(line, wrapLen, out)
9485
}
95-
if lastEscSeq != "" && lastEscSeq != EscapeReset {
96-
out.WriteString(EscapeReset)
97-
}
86+
9887
return out.String()
9988
}
10089

@@ -149,26 +138,6 @@ func appendWord(word string, lineIdx *int, lastSeenEscSeq string, wrapLen int, o
149138
}
150139
}
151140

152-
func extractOpenEscapeSeq(str string) string {
153-
escapeSeq, inEscSeq := "", false
154-
for _, char := range str {
155-
if char == EscapeStartRune {
156-
inEscSeq = true
157-
escapeSeq = ""
158-
}
159-
if inEscSeq {
160-
escapeSeq += string(char)
161-
}
162-
if char == EscapeStopRune {
163-
inEscSeq = false
164-
}
165-
}
166-
if escapeSeq == EscapeReset {
167-
escapeSeq = ""
168-
}
169-
return escapeSeq
170-
}
171-
172141
func terminateLine(wrapLen int, lineLen *int, lastSeenEscSeq string, out *strings.Builder) {
173142
if *lineLen < wrapLen {
174143
out.WriteString(strings.Repeat(" ", wrapLen-*lineLen))
@@ -189,12 +158,12 @@ func terminateOutput(lastSeenEscSeq string, out *strings.Builder) {
189158
}
190159

191160
func wrapHard(paragraph string, wrapLen int, out *strings.Builder) {
161+
esp := escSeqParser{}
192162
lineLen, lastSeenEscSeq := 0, ""
193163
words := strings.Fields(paragraph)
194164
for wordIdx, word := range words {
195-
escSeq := extractOpenEscapeSeq(word)
196-
if escSeq != "" {
197-
lastSeenEscSeq = escSeq
165+
if openEscSeq := esp.Extract(word); openEscSeq != "" {
166+
lastSeenEscSeq = openEscSeq
198167
}
199168
if lineLen > 0 {
200169
out.WriteRune(' ')
@@ -218,12 +187,12 @@ func wrapHard(paragraph string, wrapLen int, out *strings.Builder) {
218187
}
219188

220189
func wrapSoft(paragraph string, wrapLen int, out *strings.Builder) {
190+
esp := escSeqParser{}
221191
lineLen, lastSeenEscSeq := 0, ""
222192
words := strings.Fields(paragraph)
223193
for wordIdx, word := range words {
224-
escSeq := extractOpenEscapeSeq(word)
225-
if escSeq != "" {
226-
lastSeenEscSeq = escSeq
194+
if openEscSeq := esp.Extract(word); openEscSeq != "" {
195+
lastSeenEscSeq = openEscSeq
227196
}
228197

229198
spacing, spacingLen := wrapSoftSpacing(lineLen)

text/wrap_test.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ func TestWrapHard(t *testing.T) {
5050

5151
complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+"
5252
assert.Equal(t, complexIn, WrapHard(complexIn, 27))
53+
54+
// colored text with nested escape codes: "{red}{bold}...{un-bold}...{reset}"
55+
textUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red Text\x1b[0m"
56+
expectedUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red \x1b[0m\n\x1b[91mText\x1b[0m"
57+
assert.Equal(t, expectedUnBold, WrapHard(textUnBold, 23))
58+
}
59+
60+
func TestFoo(t *testing.T) {
61+
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapHard("\x1b[33mJon Snow\x1b[0m", 3))
5362
}
5463

5564
func ExampleWrapSoft() {
@@ -100,6 +109,11 @@ func TestWrapSoft(t *testing.T) {
100109

101110
assert.Equal(t, "\x1b[33mJon \x1b[0m\n\x1b[33mSnow\x1b[0m", WrapSoft("\x1b[33mJon Snow\x1b[0m", 4))
102111
assert.Equal(t, "\x1b[33mJon \x1b[0m\n\x1b[33mSnow\x1b[0m\n\x1b[33m???\x1b[0m", WrapSoft("\x1b[33mJon Snow???\x1b[0m", 4))
112+
113+
// colored text with nested escape codes: "{red}{bold}...{un-bold}...{reset}"
114+
textUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red Text\x1b[0m"
115+
expectedUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red \x1b[0m\n\x1b[91mText\x1b[0m"
116+
assert.Equal(t, expectedUnBold, WrapSoft(textUnBold, 23))
103117
}
104118

105119
func ExampleWrapText() {
@@ -138,10 +152,15 @@ func TestWrapText(t *testing.T) {
138152
assert.Equal(t, "Jon\nSno\nw\n", WrapText("Jon\nSnow\n", 3))
139153
assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw", WrapText("\x1b[33mJon\x1b[0m\nSnow", 3))
140154
assert.Equal(t, "\x1b[33mJon\x1b[0m\nSno\nw\n", WrapText("\x1b[33mJon\x1b[0m\nSnow\n", 3))
141-
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))
142-
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))
143-
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))
155+
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapText("\x1b[33mJon Snow\x1b[0m", 3))
156+
assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m\n", WrapText("\x1b[33mJon Snow\n", 3))
157+
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))
144158

145159
complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+"
146160
assert.Equal(t, complexIn, WrapText(complexIn, 27))
161+
162+
// colored text with nested escape codes: "{red}{bold}...{un-bold}...{reset}"
163+
textUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red Text\x1b[0m"
164+
expectedUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red \x1b[0m\n\x1b[91mText\x1b[0m"
165+
assert.Equal(t, expectedUnBold, WrapText(textUnBold, 23))
147166
}

0 commit comments

Comments
 (0)