File: ncol.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright (c) 2026 pacman64
   5 
   6 Permission is hereby granted, free of charge, to any person obtaining a copy of
   7 this software and associated documentation files (the "Software"), to deal
   8 in the Software without restriction, including without limitation the rights to
   9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
  10 of the Software, and to permit persons to whom the Software is furnished to do
  11 so, subject to the following conditions:
  12 
  13 The above copyright notice and this permission notice shall be included in all
  14 copies or substantial portions of the Software.
  15 
  16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22 SOFTWARE.
  23 */
  24 
  25 /*
  26 To compile a smaller-sized command-line app, you can use the `go` command as
  27 follows:
  28 
  29 go build -ldflags "-s -w" -trimpath ncol.go
  30 */
  31 
  32 package main
  33 
  34 import (
  35     "bufio"
  36     "errors"
  37     "io"
  38     "os"
  39     "strconv"
  40     "strings"
  41     "unicode/utf8"
  42 )
  43 
  44 const info = `
  45 ncol [options...] [filenames...]
  46 
  47 
  48 Nice COLumns realigns and styles data tables using ANSI color sequences. In
  49 particular, all auto-detected numbers are styled so they're easier to read
  50 at a glance. Input tables can be either lines of space-separated values or
  51 tab-separated values, and are auto-detected using the first non-empty line.
  52 
  53 When not given filepaths to read data from, this tool reads from standard
  54 input by default.
  55 
  56 The options are, available both in single and double-dash versions
  57 
  58     -h, -help              show this help message
  59     -m, -max-columns       use the row with the most items for the item-count
  60     -no-sums, -unsummed    avoid showing a final row with column sums
  61     -no-tiles, -untiled    avoid showing color-coded tiles
  62     -s, -simple            avoid showing color-coded tiles and sums
  63 `
  64 
  65 const columnGap = 2
  66 
  67 // altDigitStyle is used to make 4+ digit-runs easier to read
  68 const altDigitStyle = "\x1b[38;2;168;168;168m"
  69 
  70 func main() {
  71     sums := true
  72     tiles := true
  73     maxCols := false
  74     args := os.Args[1:]
  75 
  76     for len(args) > 0 {
  77         switch args[0] {
  78         case `-h`, `--h`, `-help`, `--help`:
  79             os.Stdout.WriteString(info[1:])
  80             return
  81 
  82         case
  83             `-m`, `--m`,
  84             `-maxcols`, `--maxcols`,
  85             `-max-columns`, `--max-columns`:
  86             maxCols = true
  87             args = args[1:]
  88             continue
  89 
  90         case
  91             `-no-sums`, `--no-sums`, `-no-totals`, `--no-totals`,
  92             `-unsummed`, `--unsummed`, `-untotaled`, `--untotaled`,
  93             `-untotalled`, `--untotalled`:
  94             sums = false
  95             args = args[1:]
  96             continue
  97 
  98         case `-no-tiles`, `--no-tiles`, `-untiled`, `--untiled`:
  99             tiles = false
 100             args = args[1:]
 101             continue
 102 
 103         case `-s`, `--s`, `-simple`, `--simple`:
 104             sums = false
 105             tiles = false
 106             args = args[1:]
 107             continue
 108         }
 109 
 110         break
 111     }
 112 
 113     if len(args) > 0 && args[0] == `--` {
 114         args = args[1:]
 115     }
 116 
 117     var res table
 118     res.MaxColumns = maxCols
 119     res.ShowTiles = tiles
 120     res.ShowSums = sums
 121 
 122     if err := run(args, &res); err != nil {
 123         os.Stderr.WriteString(err.Error())
 124         os.Stderr.WriteString("\n")
 125         os.Exit(1)
 126     }
 127 }
 128 
 129 // table has all summary info gathered from the data, along with the row
 130 // themselves, stored as lines/strings
 131 type table struct {
 132     Columns int
 133 
 134     Rows []string
 135 
 136     MaxWidth []int
 137 
 138     MaxDotDecimals []int
 139 
 140     Numeric []int
 141 
 142     Sums []float64
 143 
 144     LoopItems func(line string, items int, t *table, f itemFunc) int
 145 
 146     sb strings.Builder
 147 
 148     MaxColumns bool
 149 
 150     ShowTiles bool
 151 
 152     ShowSums bool
 153 }
 154 
 155 type itemFunc func(i int, s string, t *table)
 156 
 157 func run(paths []string, res *table) error {
 158     for _, p := range paths {
 159         if err := handleFile(res, p); err != nil {
 160             return err
 161         }
 162     }
 163 
 164     if len(paths) == 0 {
 165         if err := handleReader(res, os.Stdin); err != nil {
 166             return err
 167         }
 168     }
 169 
 170     bw := bufio.NewWriterSize(os.Stdout, 32*1024)
 171     defer bw.Flush()
 172     realign(bw, res)
 173     return nil
 174 }
 175 
 176 func handleFile(res *table, path string) error {
 177     f, err := os.Open(path)
 178     if err != nil {
 179         // on windows, file-not-found error messages may mention `CreateFile`,
 180         // even when trying to open files in read-only mode
 181         return errors.New(`can't open file named ` + path)
 182     }
 183     defer f.Close()
 184     return handleReader(res, f)
 185 }
 186 
 187 func handleReader(t *table, r io.Reader) error {
 188     const gb = 1024 * 1024 * 1024
 189     sc := bufio.NewScanner(r)
 190     sc.Buffer(nil, 8*gb)
 191 
 192     const maxInt = int(^uint(0) >> 1)
 193     maxCols := maxInt
 194 
 195     for i := 0; sc.Scan(); i++ {
 196         s := sc.Text()
 197         if i == 0 && strings.HasPrefix(s, "\xef\xbb\xbf") {
 198             s = s[3:]
 199         }
 200 
 201         if len(s) == 0 {
 202             continue
 203         }
 204 
 205         t.Rows = append(t.Rows, s)
 206 
 207         if t.Columns == 0 {
 208             if t.LoopItems == nil {
 209                 if strings.IndexByte(s, '\t') >= 0 {
 210                     t.LoopItems = loopItemsTSV
 211                 } else {
 212                     t.LoopItems = loopItemsSSV
 213                 }
 214             }
 215 
 216             if !t.MaxColumns {
 217                 t.Columns = t.LoopItems(s, maxCols, t, doNothing)
 218                 maxCols = t.Columns
 219             }
 220         }
 221 
 222         t.LoopItems(s, maxCols, t, updateItem)
 223     }
 224 
 225     return sc.Err()
 226 }
 227 
 228 // doNothing is given to LoopItems to count items, while doing nothing else
 229 func doNothing(i int, s string, t *table) {}
 230 
 231 func updateItem(i int, s string, t *table) {
 232     // ensure column-info-slices have enough room
 233     if i >= len(t.MaxWidth) {
 234         // update column-count if in max-columns mode
 235         if t.MaxColumns {
 236             t.Columns = i + 1
 237         }
 238         t.MaxWidth = append(t.MaxWidth, 0)
 239         t.MaxDotDecimals = append(t.MaxDotDecimals, 0)
 240         t.Numeric = append(t.Numeric, 0)
 241         t.Sums = append(t.Sums, 0)
 242     }
 243 
 244     // keep track of widest rune-counts for each column
 245     w := countWidth(s)
 246     if t.MaxWidth[i] < w {
 247         t.MaxWidth[i] = w
 248     }
 249 
 250     // update stats for numeric items
 251     if isNumeric(s, &(t.sb)) {
 252         dd := countDotDecimals(s)
 253         if t.MaxDotDecimals[i] < dd {
 254             t.MaxDotDecimals[i] = dd
 255         }
 256 
 257         t.Numeric[i]++
 258         f, _ := strconv.ParseFloat(t.sb.String(), 64)
 259         t.Sums[i] += f
 260     }
 261 }
 262 
 263 // loopItemsSSV loops over a line's items, allocation-free style; when given
 264 // empty strings, the callback func is never called
 265 func loopItemsSSV(s string, max int, t *table, f itemFunc) int {
 266     i := 0
 267     s = trimTrailingSpaces(s)
 268 
 269     for {
 270         s = trimLeadingSpaces(s)
 271         if len(s) == 0 {
 272             return i
 273         }
 274 
 275         if i+1 == max {
 276             f(i, s, t)
 277             return i + 1
 278         }
 279 
 280         j := strings.IndexByte(s, ' ')
 281         if j < 0 {
 282             f(i, s, t)
 283             return i + 1
 284         }
 285 
 286         f(i, s[:j], t)
 287         s = s[j+1:]
 288         i++
 289     }
 290 }
 291 
 292 func trimLeadingSpaces(s string) string {
 293     for len(s) > 0 && s[0] == ' ' {
 294         s = s[1:]
 295     }
 296     return s
 297 }
 298 
 299 func trimTrailingSpaces(s string) string {
 300     for len(s) > 0 && s[len(s)-1] == ' ' {
 301         s = s[:len(s)-1]
 302     }
 303     return s
 304 }
 305 
 306 // loopItemsTSV loops over a line's tab-separated items, allocation-free style;
 307 // when given empty strings, the callback func is never called
 308 func loopItemsTSV(s string, max int, t *table, f itemFunc) int {
 309     if len(s) == 0 {
 310         return 0
 311     }
 312 
 313     i := 0
 314 
 315     for {
 316         if i+1 == max {
 317             f(i, s, t)
 318             return i + 1
 319         }
 320 
 321         j := strings.IndexByte(s, '\t')
 322         if j < 0 {
 323             f(i, s, t)
 324             return i + 1
 325         }
 326 
 327         f(i, s[:j], t)
 328         s = s[j+1:]
 329         i++
 330     }
 331 }
 332 
 333 func skipLeadingEscapeSequences(s string) string {
 334     for len(s) >= 2 {
 335         if s[0] != '\x1b' {
 336             return s
 337         }
 338 
 339         switch s[1] {
 340         case '[':
 341             s = skipSingleLeadingANSI(s[2:])
 342 
 343         case ']':
 344             if len(s) < 3 || s[2] != '8' {
 345                 return s
 346             }
 347             s = skipSingleLeadingOSC(s[3:])
 348 
 349         default:
 350             return s
 351         }
 352     }
 353 
 354     return s
 355 }
 356 
 357 func skipSingleLeadingANSI(s string) string {
 358     for len(s) > 0 {
 359         upper := s[0] &^ 32
 360         s = s[1:]
 361         if 'A' <= upper && upper <= 'Z' {
 362             break
 363         }
 364     }
 365 
 366     return s
 367 }
 368 
 369 func skipSingleLeadingOSC(s string) string {
 370     var prev byte
 371 
 372     for len(s) > 0 {
 373         b := s[0]
 374         s = s[1:]
 375         if prev == '\x1b' && b == '\\' {
 376             break
 377         }
 378         prev = b
 379     }
 380 
 381     return s
 382 }
 383 
 384 // isNumeric checks if a string is valid/useable as a number
 385 func isNumeric(s string, sb *strings.Builder) bool {
 386     if len(s) == 0 {
 387         return false
 388     }
 389 
 390     sb.Reset()
 391 
 392     s = skipLeadingEscapeSequences(s)
 393     if len(s) > 0 && (s[0] == '+' || s[0] == '-') {
 394         sb.WriteByte(s[0])
 395         s = s[1:]
 396     }
 397 
 398     s = skipLeadingEscapeSequences(s)
 399     if len(s) == 0 {
 400         return false
 401     }
 402     if b := s[0]; b == '.' {
 403         sb.WriteByte(b)
 404         return isDigits(s[1:], sb)
 405     }
 406 
 407     digits := 0
 408 
 409     for {
 410         s = skipLeadingEscapeSequences(s)
 411         if len(s) == 0 {
 412             break
 413         }
 414 
 415         b := s[0]
 416         sb.WriteByte(b)
 417 
 418         if b == '.' {
 419             return isDigits(s[1:], sb)
 420         }
 421 
 422         if !('0' <= b && b <= '9') {
 423             return false
 424         }
 425 
 426         digits++
 427         s = s[1:]
 428     }
 429 
 430     s = skipLeadingEscapeSequences(s)
 431     return len(s) == 0 && digits > 0
 432 }
 433 
 434 func isDigits(s string, sb *strings.Builder) bool {
 435     if len(s) == 0 {
 436         return false
 437     }
 438 
 439     digits := 0
 440 
 441     for {
 442         s = skipLeadingEscapeSequences(s)
 443         if len(s) == 0 {
 444             break
 445         }
 446 
 447         if b := s[0]; '0' <= b && b <= '9' {
 448             sb.WriteByte(b)
 449             s = s[1:]
 450             digits++
 451         } else {
 452             return false
 453         }
 454     }
 455 
 456     s = skipLeadingEscapeSequences(s)
 457     return len(s) == 0 && digits > 0
 458 }
 459 
 460 // countDecimals counts decimal digits from the string given, assuming it
 461 // represents a valid/useable float64, when parsed
 462 func countDecimals(s string) int {
 463     dot := strings.IndexByte(s, '.')
 464     if dot < 0 {
 465         return 0
 466     }
 467 
 468     decs := 0
 469     s = s[dot+1:]
 470 
 471     for len(s) > 0 {
 472         s = skipLeadingEscapeSequences(s)
 473         if len(s) == 0 {
 474             break
 475         }
 476         if '0' <= s[0] && s[0] <= '9' {
 477             decs++
 478         }
 479         s = s[1:]
 480     }
 481 
 482     return decs
 483 }
 484 
 485 // countDotDecimals is like func countDecimals, but this one also includes
 486 // the dot, when any decimals are present, else the count stays at 0
 487 func countDotDecimals(s string) int {
 488     decs := countDecimals(s)
 489     if decs > 0 {
 490         return decs + 1
 491     }
 492     return decs
 493 }
 494 
 495 func countWidth(s string) int {
 496     width := 0
 497 
 498     for len(s) > 0 {
 499         i, j := indexEscapeSequence(s)
 500         if i < 0 {
 501             break
 502         }
 503         if j < 0 {
 504             j = len(s)
 505         }
 506 
 507         width += utf8.RuneCountInString(s[:i])
 508         s = s[j:]
 509     }
 510 
 511     // count trailing/all runes in strings which don't end with ANSI-sequences
 512     width += utf8.RuneCountInString(s)
 513     return width
 514 }
 515 
 516 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
 517 // the multi-byte sequences starting with ESC[; the result is a pair of slice
 518 // indices which can be independently negative when either the start/end of
 519 // a sequence isn't found; given their fairly-common use, even the hyperlink
 520 // ESC]8 sequences are supported
 521 func indexEscapeSequence(s string) (int, int) {
 522     var prev byte
 523 
 524     for i := range s {
 525         b := s[i]
 526 
 527         if prev == '\x1b' && b == '[' {
 528             j := indexLetter(s[i+1:])
 529             if j < 0 {
 530                 return i, -1
 531             }
 532             return i - 1, i + 1 + j + 1
 533         }
 534 
 535         if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
 536             j := indexPair(s[i+1:], '\x1b', '\\')
 537             if j < 0 {
 538                 return i, -1
 539             }
 540             return i - 1, i + 1 + j + 2
 541         }
 542 
 543         prev = b
 544     }
 545 
 546     return -1, -1
 547 }
 548 
 549 func indexLetter(s string) int {
 550     for i, b := range s {
 551         upper := b &^ 32
 552         if 'A' <= upper && upper <= 'Z' {
 553             return i
 554         }
 555     }
 556 
 557     return -1
 558 }
 559 
 560 func indexPair(s string, x byte, y byte) int {
 561     var prev byte
 562 
 563     for i := range s {
 564         b := s[i]
 565         if prev == x && b == y && i > 0 {
 566             return i
 567         }
 568         prev = b
 569     }
 570 
 571     return -1
 572 }
 573 
 574 func realign(w *bufio.Writer, t *table) {
 575     // make sums row first, as final alignments are usually affected by these
 576     var sums []string
 577     if t.ShowSums {
 578         sums = make([]string, 0, t.Columns)
 579 
 580         for i := 0; i < t.Columns; i++ {
 581             if t.Numeric[i] == 0 {
 582                 sums = append(sums, `-`)
 583                 if t.MaxWidth[i] < 1 {
 584                     t.MaxWidth[i] = 1
 585                 }
 586                 continue
 587             }
 588 
 589             decs := t.MaxDotDecimals[i]
 590             if decs > 0 {
 591                 decs--
 592             }
 593 
 594             var buf [64]byte
 595             s := strconv.AppendFloat(buf[:0], t.Sums[i], 'f', decs, 64)
 596             sums = append(sums, string(s))
 597             if t.MaxWidth[i] < len(s) {
 598                 t.MaxWidth[i] = len(s)
 599             }
 600         }
 601     }
 602 
 603     // due keeps track of how many spaces are due, when separating realigned
 604     // items from their immediate predecessor on the same row; this counter
 605     // is also used to right-pad numbers with decimals, as such items can be
 606     // padded with spaces from either side
 607     due := 0
 608 
 609     showItem := func(i int, s string, t *table) {
 610         if i > 0 {
 611             due += columnGap
 612         }
 613 
 614         if isNumeric(s, &(t.sb)) {
 615             dd := countDotDecimals(s)
 616             rpad := t.MaxDotDecimals[i] - dd
 617             width := countWidth(s)
 618             lpad := t.MaxWidth[i] - (width + rpad) + due
 619             writeSpaces(w, lpad)
 620             f, _ := strconv.ParseFloat(t.sb.String(), 64)
 621             writeNumericItem(w, s, numericStyle(f))
 622             due = rpad
 623             return
 624         }
 625 
 626         writeSpaces(w, due)
 627         w.WriteString(s)
 628         due = t.MaxWidth[i] - countWidth(s)
 629     }
 630 
 631     writeTile := func(i int, s string, t *table) {
 632         // make empty items stand out
 633         if len(s) == 0 {
 634             w.WriteString("\x1b[0m○")
 635             return
 636         }
 637 
 638         if isNumeric(s, &(t.sb)) {
 639             f, _ := strconv.ParseFloat(t.sb.String(), 64)
 640             w.WriteString(numericStyle(f))
 641             w.WriteString("")
 642             return
 643         }
 644 
 645         // make padded items stand out: these items have spaces at either end
 646         if s[0] == ' ' || s[len(s)-1] == ' ' {
 647             w.WriteString("\x1b[38;2;196;160;0m■")
 648             return
 649         }
 650 
 651         w.WriteString("\x1b[38;2;128;128;128m■")
 652     }
 653 
 654     // show realigned rows
 655 
 656     for _, line := range t.Rows {
 657         due = 0
 658 
 659         if t.ShowTiles {
 660             end := t.LoopItems(line, t.Columns, t, writeTile)
 661             if end < len(t.MaxWidth)-1 {
 662                 w.WriteString("\x1b[0m")
 663             }
 664             // make rows with missing trailing items stand out
 665             for i := end; i < len(t.MaxWidth); i++ {
 666                 w.WriteString("×")
 667             }
 668             w.WriteString("\x1b[0m")
 669             due += columnGap
 670         }
 671 
 672         t.LoopItems(line, t.Columns, t, showItem)
 673         if w.WriteByte('\n') != nil {
 674             return
 675         }
 676     }
 677 
 678     if t.Columns > 0 && t.ShowSums {
 679         realignSums(w, t, sums)
 680     }
 681 }
 682 
 683 func realignSums(w *bufio.Writer, t *table, sums []string) {
 684     due := 0
 685     if t.ShowTiles {
 686         due += t.Columns + columnGap
 687     }
 688 
 689     for i, s := range sums {
 690         if i > 0 {
 691             due += columnGap
 692         }
 693 
 694         if t.Numeric[i] == 0 {
 695             writeSpaces(w, due)
 696             w.WriteString(s)
 697             due = t.MaxWidth[i] - countWidth(s)
 698             continue
 699         }
 700 
 701         lpad := t.MaxWidth[i] - len(s) + due
 702         writeSpaces(w, lpad)
 703         writeNumericItem(w, s, numericStyle(t.Sums[i]))
 704         due = 0
 705     }
 706 
 707     w.WriteByte('\n')
 708 }
 709 
 710 // writeSpaces does what it says, minimizing calls to write-like funcs
 711 func writeSpaces(w *bufio.Writer, n int) {
 712     const spaces = `                                `
 713     if n < 1 {
 714         return
 715     }
 716 
 717     for n >= len(spaces) {
 718         w.WriteString(spaces)
 719         n -= len(spaces)
 720     }
 721     w.WriteString(spaces[:n])
 722 }
 723 
 724 func writeRowTiles(w *bufio.Writer, s string, t *table, writeTile itemFunc) {
 725     end := t.LoopItems(s, t.Columns, t, writeTile)
 726 
 727     if end < len(t.MaxWidth)-1 {
 728         w.WriteString("\x1b[0m")
 729     }
 730     for i := end + 1; i < len(t.MaxWidth); i++ {
 731         w.WriteString("×")
 732     }
 733     w.WriteString("\x1b[0m")
 734 }
 735 
 736 func numericStyle(f float64) string {
 737     if f > 0 {
 738         if float64(int64(f)) == f {
 739             return "\x1b[38;2;0;135;0m"
 740         }
 741         return "\x1b[38;2;0;155;95m"
 742     }
 743     if f < 0 {
 744         if float64(int64(f)) == f {
 745             return "\x1b[38;2;204;0;0m"
 746         }
 747         return "\x1b[38;2;215;95;95m"
 748     }
 749     if f == 0 {
 750         return "\x1b[38;2;0;95;215m"
 751     }
 752     return "\x1b[38;2;128;128;128m"
 753 }
 754 
 755 func writeNumericItem(w *bufio.Writer, s string, startStyle string) {
 756     w.WriteString(startStyle)
 757     if len(s) > 0 && (s[0] == '-' || s[0] == '+') {
 758         w.WriteByte(s[0])
 759         s = s[1:]
 760     }
 761 
 762     dot := strings.IndexByte(s, '.')
 763     if dot < 0 {
 764         restyleDigits(w, s, altDigitStyle)
 765         w.WriteString("\x1b[0m")
 766         return
 767     }
 768 
 769     if len(s[:dot]) > 3 {
 770         restyleDigits(w, s[:dot], altDigitStyle)
 771         w.WriteString("\x1b[0m")
 772         w.WriteString(startStyle)
 773         w.WriteByte('.')
 774     } else {
 775         w.WriteString(s[:dot])
 776         w.WriteByte('.')
 777     }
 778 
 779     rest := s[dot+1:]
 780     restyleDigits(w, rest, altDigitStyle)
 781     if len(rest) < 4 {
 782         w.WriteString("\x1b[0m")
 783     }
 784 }
 785 
 786 // restyleDigits renders a run of digits as alternating styled/unstyled runs
 787 // of 3 digits, which greatly improves readability, and is the only purpose
 788 // of this app; string is assumed to be all decimal digits
 789 func restyleDigits(w *bufio.Writer, digits string, altStyle string) {
 790     if len(digits) < 4 {
 791         // digit sequence is short, so emit it as is
 792         w.WriteString(digits)
 793         return
 794     }
 795 
 796     // separate leading 0..2 digits which don't align with the 3-digit groups
 797     i := len(digits) % 3
 798     // emit leading digits unstyled, if there are any
 799     w.WriteString(digits[:i])
 800     // the rest is guaranteed to have a length which is a multiple of 3
 801     digits = digits[i:]
 802 
 803     // start by styling, unless there were no leading digits
 804     style := i != 0
 805 
 806     for len(digits) > 0 {
 807         if style {
 808             w.WriteString(altStyle)
 809             w.WriteString(digits[:3])
 810             w.WriteString("\x1b[0m")
 811         } else {
 812             w.WriteString(digits[:3])
 813         }
 814 
 815         // advance to the next triple: the start of this func is supposed
 816         // to guarantee this step always works
 817         digits = digits[3:]
 818 
 819         // alternate between styled and unstyled 3-digit groups
 820         style = !style
 821     }
 822 }