You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

ircfmt.go 10KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. // written by Daniel Oaks <daniel@danieloaks.net>
  2. // released under the ISC license
  3. package ircfmt
  4. import (
  5. "regexp"
  6. "strconv"
  7. "strings"
  8. )
  9. const (
  10. // raw bytes and strings to do replacing with
  11. bold string = "\x02"
  12. colour string = "\x03"
  13. monospace string = "\x11"
  14. reverseColour string = "\x16"
  15. italic string = "\x1d"
  16. strikethrough string = "\x1e"
  17. underline string = "\x1f"
  18. reset string = "\x0f"
  19. metacharacters = (bold + colour + monospace + reverseColour + italic + strikethrough + underline + reset)
  20. )
  21. // ColorCode is a normalized representation of an IRC color code,
  22. // as per this de facto specification: https://modern.ircdocs.horse/formatting.html#color
  23. // The zero value of the type represents a default or unset color,
  24. // whereas ColorCode{true, 0} represents the color white.
  25. type ColorCode struct {
  26. IsSet bool
  27. Value uint8
  28. }
  29. // ParseColor converts a string representation of an IRC color code, e.g. "04",
  30. // into a normalized ColorCode, e.g. ColorCode{true, 4}.
  31. func ParseColor(str string) (color ColorCode) {
  32. // "99 - Default Foreground/Background - Not universally supported."
  33. // normalize 99 to ColorCode{} meaning "unset":
  34. if code, err := strconv.ParseUint(str, 10, 8); err == nil && code < 99 {
  35. color.IsSet = true
  36. color.Value = uint8(code)
  37. }
  38. return
  39. }
  40. // FormattedSubstring represents a section of an IRC message with associated
  41. // formatting data.
  42. type FormattedSubstring struct {
  43. Content string
  44. ForegroundColor ColorCode
  45. BackgroundColor ColorCode
  46. Bold bool
  47. Monospace bool
  48. Strikethrough bool
  49. Underline bool
  50. Italic bool
  51. ReverseColor bool
  52. }
  53. // IsFormatted returns whether the section has any formatting flags switched on.
  54. func (f *FormattedSubstring) IsFormatted() bool {
  55. // could rely on value receiver but if this is to be a public API,
  56. // let's make it a pointer receiver
  57. g := *f
  58. g.Content = ""
  59. return g != FormattedSubstring{}
  60. }
  61. var (
  62. // "If there are two ASCII digits available where a <COLOR> is allowed,
  63. // then two characters MUST always be read for it and displayed as described below."
  64. // we rely on greedy matching to implement this for both forms:
  65. // (\x03)00,01
  66. colorForeBackRe = regexp.MustCompile(`^([0-9]{1,2}),([0-9]{1,2})`)
  67. // (\x03)00
  68. colorForeRe = regexp.MustCompile(`^([0-9]{1,2})`)
  69. )
  70. // Split takes an IRC message (typically a PRIVMSG or NOTICE final parameter)
  71. // containing IRC formatting control codes, and splits it into substrings with
  72. // associated formatting information.
  73. func Split(raw string) (result []FormattedSubstring) {
  74. var chunk FormattedSubstring
  75. for {
  76. // skip to the next metacharacter, or the end of the string
  77. if idx := strings.IndexAny(raw, metacharacters); idx != 0 {
  78. if idx == -1 {
  79. idx = len(raw)
  80. }
  81. chunk.Content = raw[:idx]
  82. if len(chunk.Content) != 0 {
  83. result = append(result, chunk)
  84. }
  85. raw = raw[idx:]
  86. }
  87. if len(raw) == 0 {
  88. return
  89. }
  90. // we're at a metacharacter. by default, all previous formatting carries over
  91. metacharacter := raw[0]
  92. raw = raw[1:]
  93. switch metacharacter {
  94. case bold[0]:
  95. chunk.Bold = !chunk.Bold
  96. case monospace[0]:
  97. chunk.Monospace = !chunk.Monospace
  98. case strikethrough[0]:
  99. chunk.Strikethrough = !chunk.Strikethrough
  100. case underline[0]:
  101. chunk.Underline = !chunk.Underline
  102. case italic[0]:
  103. chunk.Italic = !chunk.Italic
  104. case reverseColour[0]:
  105. chunk.ReverseColor = !chunk.ReverseColor
  106. case reset[0]:
  107. chunk = FormattedSubstring{}
  108. case colour[0]:
  109. // preferentially match the "\x0399,01" form, then "\x0399";
  110. // if neither of those matches, then it's a reset
  111. if matches := colorForeBackRe.FindStringSubmatch(raw); len(matches) != 0 {
  112. chunk.ForegroundColor = ParseColor(matches[1])
  113. chunk.BackgroundColor = ParseColor(matches[2])
  114. raw = raw[len(matches[0]):]
  115. } else if matches := colorForeRe.FindStringSubmatch(raw); len(matches) != 0 {
  116. chunk.ForegroundColor = ParseColor(matches[1])
  117. raw = raw[len(matches[0]):]
  118. } else {
  119. chunk.ForegroundColor = ColorCode{}
  120. chunk.BackgroundColor = ColorCode{}
  121. }
  122. default:
  123. // should be impossible, but just ignore it
  124. }
  125. }
  126. }
  127. var (
  128. // valtoescape replaces most of IRC characters with our escapes.
  129. valtoescape = strings.NewReplacer("$", "$$", colour, "$c", reverseColour, "$v", bold, "$b", italic, "$i", strikethrough, "$s", underline, "$u", monospace, "$m", reset, "$r")
  130. // escapetoval contains most of our escapes and how they map to real IRC characters.
  131. // intentionally skips colour, since that's handled elsewhere.
  132. escapetoval = map[rune]string{
  133. '$': "$",
  134. 'b': bold,
  135. 'i': italic,
  136. 'v': reverseColour,
  137. 's': strikethrough,
  138. 'u': underline,
  139. 'm': monospace,
  140. 'r': reset,
  141. }
  142. // valid colour codes
  143. numtocolour = map[string]string{
  144. "99": "default",
  145. "15": "light grey",
  146. "14": "grey",
  147. "13": "pink",
  148. "12": "light blue",
  149. "11": "light cyan",
  150. "10": "cyan",
  151. "09": "light green",
  152. "08": "yellow",
  153. "07": "orange",
  154. "06": "magenta",
  155. "05": "brown",
  156. "04": "red",
  157. "03": "green",
  158. "02": "blue",
  159. "01": "black",
  160. "00": "white",
  161. "9": "light green",
  162. "8": "yellow",
  163. "7": "orange",
  164. "6": "magenta",
  165. "5": "brown",
  166. "4": "red",
  167. "3": "green",
  168. "2": "blue",
  169. "1": "black",
  170. "0": "white",
  171. }
  172. colourcodesTruncated = map[string]string{
  173. "white": "0",
  174. "black": "1",
  175. "blue": "2",
  176. "green": "3",
  177. "red": "4",
  178. "brown": "5",
  179. "magenta": "6",
  180. "orange": "7",
  181. "yellow": "8",
  182. "light green": "9",
  183. "cyan": "10",
  184. "light cyan": "11",
  185. "light blue": "12",
  186. "pink": "13",
  187. "grey": "14",
  188. "gray": "14",
  189. "light grey": "15",
  190. "light gray": "15",
  191. "default": "99",
  192. }
  193. bracketedExpr = regexp.MustCompile(`^\[.*?\]`)
  194. colourDigits = regexp.MustCompile(`^[0-9]{1,2}$`)
  195. )
  196. // Escape takes a raw IRC string and returns it with our escapes.
  197. //
  198. // IE, it turns this: "This is a \x02cool\x02, \x034red\x0f message!"
  199. // into: "This is a $bcool$b, $c[red]red$r message!"
  200. func Escape(in string) string {
  201. // replace all our usual escapes
  202. in = valtoescape.Replace(in)
  203. inRunes := []rune(in)
  204. //var out string
  205. out := strings.Builder{}
  206. for 0 < len(inRunes) {
  207. if 1 < len(inRunes) && inRunes[0] == '$' && inRunes[1] == 'c' {
  208. // handle colours
  209. out.WriteString("$c")
  210. inRunes = inRunes[2:] // strip colour code chars
  211. if len(inRunes) < 1 || !isDigit(inRunes[0]) {
  212. out.WriteString("[]")
  213. continue
  214. }
  215. var foreBuffer, backBuffer string
  216. foreBuffer += string(inRunes[0])
  217. inRunes = inRunes[1:]
  218. if 0 < len(inRunes) && isDigit(inRunes[0]) {
  219. foreBuffer += string(inRunes[0])
  220. inRunes = inRunes[1:]
  221. }
  222. if 1 < len(inRunes) && inRunes[0] == ',' && isDigit(inRunes[1]) {
  223. backBuffer += string(inRunes[1])
  224. inRunes = inRunes[2:]
  225. if 0 < len(inRunes) && isDigit(inRunes[1]) {
  226. backBuffer += string(inRunes[0])
  227. inRunes = inRunes[1:]
  228. }
  229. }
  230. foreName, exists := numtocolour[foreBuffer]
  231. if !exists {
  232. foreName = foreBuffer
  233. }
  234. backName, exists := numtocolour[backBuffer]
  235. if !exists {
  236. backName = backBuffer
  237. }
  238. out.WriteRune('[')
  239. out.WriteString(foreName)
  240. if backName != "" {
  241. out.WriteRune(',')
  242. out.WriteString(backName)
  243. }
  244. out.WriteRune(']')
  245. } else {
  246. // special case for $$c
  247. if len(inRunes) > 2 && inRunes[0] == '$' && inRunes[1] == '$' && inRunes[2] == 'c' {
  248. out.WriteRune(inRunes[0])
  249. out.WriteRune(inRunes[1])
  250. out.WriteRune(inRunes[2])
  251. inRunes = inRunes[3:]
  252. } else {
  253. out.WriteRune(inRunes[0])
  254. inRunes = inRunes[1:]
  255. }
  256. }
  257. }
  258. return out.String()
  259. }
  260. func isDigit(r rune) bool {
  261. return '0' <= r && r <= '9' // don't use unicode.IsDigit, it includes non-ASCII numerals
  262. }
  263. // Strip takes a raw IRC string and removes it with all formatting codes removed
  264. // IE, it turns this: "This is a \x02cool\x02, \x034red\x0f message!"
  265. // into: "This is a cool, red message!"
  266. func Strip(in string) string {
  267. splitChunks := Split(in)
  268. if len(splitChunks) == 0 {
  269. return ""
  270. } else if len(splitChunks) == 1 {
  271. return splitChunks[0].Content
  272. } else {
  273. var buf strings.Builder
  274. buf.Grow(len(in))
  275. for _, chunk := range splitChunks {
  276. buf.WriteString(chunk.Content)
  277. }
  278. return buf.String()
  279. }
  280. }
  281. // resolve "light blue" to "12", "12" to "12", "asdf" to "", etc.
  282. func resolveToColourCode(str string) (result string) {
  283. str = strings.ToLower(strings.TrimSpace(str))
  284. if colourDigits.MatchString(str) {
  285. return str
  286. }
  287. return colourcodesTruncated[str]
  288. }
  289. // resolve "[light blue, black]" to ("13, "1")
  290. func resolveToColourCodes(namedColors string) (foreground, background string) {
  291. // cut off the brackets
  292. namedColors = strings.TrimPrefix(namedColors, "[")
  293. namedColors = strings.TrimSuffix(namedColors, "]")
  294. var foregroundStr, backgroundStr string
  295. commaIdx := strings.IndexByte(namedColors, ',')
  296. if commaIdx != -1 {
  297. foregroundStr = namedColors[:commaIdx]
  298. backgroundStr = namedColors[commaIdx+1:]
  299. } else {
  300. foregroundStr = namedColors
  301. }
  302. return resolveToColourCode(foregroundStr), resolveToColourCode(backgroundStr)
  303. }
  304. // Unescape takes our escaped string and returns a raw IRC string.
  305. //
  306. // IE, it turns this: "This is a $bcool$b, $c[red]red$r message!"
  307. // into this: "This is a \x02cool\x02, \x034red\x0f message!"
  308. func Unescape(in string) string {
  309. var out strings.Builder
  310. remaining := in
  311. for len(remaining) != 0 {
  312. char := remaining[0]
  313. remaining = remaining[1:]
  314. if char != '$' || len(remaining) == 0 {
  315. // not an escape
  316. out.WriteByte(char)
  317. continue
  318. }
  319. // ingest the next character of the escape
  320. char = remaining[0]
  321. remaining = remaining[1:]
  322. if char == 'c' {
  323. out.WriteString(colour)
  324. namedColors := bracketedExpr.FindString(remaining)
  325. if namedColors == "" {
  326. // for a non-bracketed color code, output the following characters directly,
  327. // e.g., `$c1,8` will become `\x031,8`
  328. continue
  329. }
  330. // process bracketed color codes:
  331. remaining = remaining[len(namedColors):]
  332. followedByDigit := len(remaining) != 0 && ('0' <= remaining[0] && remaining[0] <= '9')
  333. foreground, background := resolveToColourCodes(namedColors)
  334. if foreground != "" {
  335. if len(foreground) == 1 && background == "" && followedByDigit {
  336. out.WriteByte('0')
  337. }
  338. out.WriteString(foreground)
  339. if background != "" {
  340. out.WriteByte(',')
  341. if len(background) == 1 && followedByDigit {
  342. out.WriteByte('0')
  343. }
  344. out.WriteString(background)
  345. }
  346. }
  347. } else {
  348. val, exists := escapetoval[rune(char)]
  349. if exists {
  350. out.WriteString(val)
  351. } else {
  352. // invalid escape, use the raw char
  353. out.WriteByte(char)
  354. }
  355. }
  356. }
  357. return out.String()
  358. }