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         return
 127     }
 128 }
 129 
 130 // table has all summary info gathered from the data, along with the row
 131 // themselves, stored as lines/strings
 132 type table struct {
 133     Columns int
 134 
 135     Rows []string
 136 
 137     MaxWidth []int
 138 
 139     MaxDotDecimals []int
 140 
 141     Numeric []int
 142 
 143     Sums []float64
 144 
 145     LoopItems func(line string, items int, t *table, f itemFunc) int
 146 
 147     sb strings.Builder
 148 
 149     MaxColumns bool
 150 
 151     ShowTiles bool
 152 
 153     ShowSums bool
 154 }
 155 
 156 type itemFunc func(i int, s string, t *table)
 157 
 158 func run(paths []string, res *table) error {
 159     for _, p := range paths {
 160         if err := handleFile(res, p); err != nil {
 161             return err
 162         }
 163     }
 164 
 165     if len(paths) == 0 {
 166         if err := handleReader(res, os.Stdin); err != nil {
 167             return err
 168         }
 169     }
 170 
 171     bw := bufio.NewWriterSize(os.Stdout, 32*1024)
 172     defer bw.Flush()
 173     realign(bw, res)
 174     return nil
 175 }
 176 
 177 func handleFile(res *table, path string) error {
 178     f, err := os.Open(path)
 179     if err != nil {
 180         // on windows, file-not-found error messages may mention `CreateFile`,
 181         // even when trying to open files in read-only mode
 182         return errors.New(`can't open file named ` + path)
 183     }
 184     defer f.Close()
 185     return handleReader(res, f)
 186 }
 187 
 188 func handleReader(t *table, r io.Reader) error {
 189     const gb = 1024 * 1024 * 1024
 190     sc := bufio.NewScanner(r)
 191     sc.Buffer(nil, 8*gb)
 192 
 193     const maxInt = int(^uint(0) >> 1)
 194     maxCols := maxInt
 195 
 196     for i := 0; sc.Scan(); i++ {
 197         s := sc.Text()
 198         if i == 0 && strings.HasPrefix(s, "\xef\xbb\xbf") {
 199             s = s[3:]
 200         }
 201 
 202         if len(s) == 0 {
 203             continue
 204         }
 205 
 206         t.Rows = append(t.Rows, s)
 207 
 208         if t.Columns == 0 {
 209             if t.LoopItems == nil {
 210                 if strings.IndexByte(s, '\t') >= 0 {
 211                     t.LoopItems = loopItemsTSV
 212                 } else {
 213                     t.LoopItems = loopItemsSSV
 214                 }
 215             }
 216 
 217             if !t.MaxColumns {
 218                 t.Columns = t.LoopItems(s, maxCols, t, doNothing)
 219                 maxCols = t.Columns
 220             }
 221         }
 222 
 223         t.LoopItems(s, maxCols, t, updateItem)
 224     }
 225 
 226     return sc.Err()
 227 }
 228 
 229 // doNothing is given to LoopItems to count items, while doing nothing else
 230 func doNothing(i int, s string, t *table) {}
 231 
 232 func updateItem(i int, s string, t *table) {
 233     // ensure column-info-slices have enough room
 234     if i >= len(t.MaxWidth) {
 235         // update column-count if in max-columns mode
 236         if t.MaxColumns {
 237             t.Columns = i + 1
 238         }
 239         t.MaxWidth = append(t.MaxWidth, 0)
 240         t.MaxDotDecimals = append(t.MaxDotDecimals, 0)
 241         t.Numeric = append(t.Numeric, 0)
 242         t.Sums = append(t.Sums, 0)
 243     }
 244 
 245     // keep track of widest rune-counts for each column
 246     w := countWidth(s)
 247     if t.MaxWidth[i] < w {
 248         t.MaxWidth[i] = w
 249     }
 250 
 251     // update stats for numeric items
 252     if isNumeric(s, &(t.sb)) {
 253         dd := countDotDecimals(s)
 254         if t.MaxDotDecimals[i] < dd {
 255             t.MaxDotDecimals[i] = dd
 256         }
 257 
 258         t.Numeric[i]++
 259         f, _ := strconv.ParseFloat(t.sb.String(), 64)
 260         t.Sums[i] += f
 261     }
 262 }
 263 
 264 // loopItemsSSV loops over a line's items, allocation-free style; when given
 265 // empty strings, the callback func is never called
 266 func loopItemsSSV(s string, max int, t *table, f itemFunc) int {
 267     i := 0
 268     s = trimTrailingSpaces(s)
 269 
 270     for {
 271         s = trimLeadingSpaces(s)
 272         if len(s) == 0 {
 273             return i
 274         }
 275 
 276         if i+1 == max {
 277             f(i, s, t)
 278             return i + 1
 279         }
 280 
 281         j := strings.IndexByte(s, ' ')
 282         if j < 0 {
 283             f(i, s, t)
 284             return i + 1
 285         }
 286 
 287         f(i, s[:j], t)
 288         s = s[j+1:]
 289         i++
 290     }
 291 }
 292 
 293 func trimLeadingSpaces(s string) string {
 294     for len(s) > 0 && s[0] == ' ' {
 295         s = s[1:]
 296     }
 297     return s
 298 }
 299 
 300 func trimTrailingSpaces(s string) string {
 301     for len(s) > 0 && s[len(s)-1] == ' ' {
 302         s = s[:len(s)-1]
 303     }
 304     return s
 305 }
 306 
 307 // loopItemsTSV loops over a line's tab-separated items, allocation-free style;
 308 // when given empty strings, the callback func is never called
 309 func loopItemsTSV(s string, max int, t *table, f itemFunc) int {
 310     if len(s) == 0 {
 311         return 0
 312     }
 313 
 314     i := 0
 315 
 316     for {
 317         if i+1 == max {
 318             f(i, s, t)
 319             return i + 1
 320         }
 321 
 322         j := strings.IndexByte(s, '\t')
 323         if j < 0 {
 324             f(i, s, t)
 325             return i + 1
 326         }
 327 
 328         f(i, s[:j], t)
 329         s = s[j+1:]
 330         i++
 331     }
 332 }
 333 
 334 func skipLeadingEscapeSequences(s string) string {
 335     for len(s) >= 2 {
 336         if s[0] != '\x1b' {
 337             return s
 338         }
 339 
 340         switch s[1] {
 341         case '[':
 342             s = skipSingleLeadingANSI(s[2:])
 343 
 344         case ']':
 345             if len(s) < 3 || s[2] != '8' {
 346                 return s
 347             }
 348             s = skipSingleLeadingOSC(s[3:])
 349 
 350         default:
 351             return s
 352         }
 353     }
 354 
 355     return s
 356 }
 357 
 358 func skipSingleLeadingANSI(s string) string {
 359     for len(s) > 0 {
 360         upper := s[0] &^ 32
 361         s = s[1:]
 362         if 'A' <= upper && upper <= 'Z' {
 363             break
 364         }
 365     }
 366 
 367     return s
 368 }
 369 
 370 func skipSingleLeadingOSC(s string) string {
 371     var prev byte
 372 
 373     for len(s) > 0 {
 374         b := s[0]
 375         s = s[1:]
 376         if prev == '\x1b' && b == '\\' {
 377             break
 378         }
 379         prev = b
 380     }
 381 
 382     return s
 383 }
 384 
 385 // isNumeric checks if a string is valid/useable as a number
 386 func isNumeric(s string, sb *strings.Builder) bool {
 387     if len(s) == 0 {
 388         return false
 389     }
 390 
 391     sb.Reset()
 392 
 393     s = skipLeadingEscapeSequences(s)
 394     if len(s) > 0 && (s[0] == '+' || s[0] == '-') {
 395         sb.WriteByte(s[0])
 396         s = s[1:]
 397     }
 398 
 399     s = skipLeadingEscapeSequences(s)
 400     if len(s) == 0 {
 401         return false
 402     }
 403     if b := s[0]; b == '.' {
 404         sb.WriteByte(b)
 405         return isDigits(s[1:], sb)
 406     }
 407 
 408     digits := 0
 409 
 410     for {
 411         s = skipLeadingEscapeSequences(s)
 412         if len(s) == 0 {
 413             break
 414         }
 415 
 416         b := s[0]
 417         sb.WriteByte(b)
 418 
 419         if b == '.' {
 420             return isDigits(s[1:], sb)
 421         }
 422 
 423         if !('0' <= b && b <= '9') {
 424             return false
 425         }
 426 
 427         digits++
 428         s = s[1:]
 429     }
 430 
 431     s = skipLeadingEscapeSequences(s)
 432     return len(s) == 0 && digits > 0
 433 }
 434 
 435 func isDigits(s string, sb *strings.Builder) bool {
 436     if len(s) == 0 {
 437         return false
 438     }
 439 
 440     digits := 0
 441 
 442     for {
 443         s = skipLeadingEscapeSequences(s)
 444         if len(s) == 0 {
 445             break
 446         }
 447 
 448         if b := s[0]; '0' <= b && b <= '9' {
 449             sb.WriteByte(b)
 450             s = s[1:]
 451             digits++
 452         } else {
 453             return false
 454         }
 455     }
 456 
 457     s = skipLeadingEscapeSequences(s)
 458     return len(s) == 0 && digits > 0
 459 }
 460 
 461 // countDecimals counts decimal digits from the string given, assuming it
 462 // represents a valid/useable float64, when parsed
 463 func countDecimals(s string) int {
 464     dot := strings.IndexByte(s, '.')
 465     if dot < 0 {
 466         return 0
 467     }
 468 
 469     decs := 0
 470     s = s[dot+1:]
 471 
 472     for len(s) > 0 {
 473         s = skipLeadingEscapeSequences(s)
 474         if len(s) == 0 {
 475             break
 476         }
 477         if '0' <= s[0] && s[0] <= '9' {
 478             decs++
 479         }
 480         s = s[1:]
 481     }
 482 
 483     return decs
 484 }
 485 
 486 // countDotDecimals is like func countDecimals, but this one also includes
 487 // the dot, when any decimals are present, else the count stays at 0
 488 func countDotDecimals(s string) int {
 489     decs := countDecimals(s)
 490     if decs > 0 {
 491         return decs + 1
 492     }
 493     return decs
 494 }
 495 
 496 func countWidth(s string) int {
 497     width := 0
 498 
 499     for len(s) > 0 {
 500         i, j := indexEscapeSequence(s)
 501         if i < 0 {
 502             break
 503         }
 504         if j < 0 {
 505             j = len(s)
 506         }
 507 
 508         width += utf8.RuneCountInString(s[:i])
 509         s = s[j:]
 510     }
 511 
 512     // count trailing/all runes in strings which don't end with ANSI-sequences
 513     width += utf8.RuneCountInString(s)
 514     return width
 515 }
 516 
 517 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
 518 // the multi-byte sequences starting with ESC[; the result is a pair of slice
 519 // indices which can be independently negative when either the start/end of
 520 // a sequence isn't found; given their fairly-common use, even the hyperlink
 521 // ESC]8 sequences are supported
 522 func indexEscapeSequence(s string) (int, int) {
 523     var prev byte
 524 
 525     for i := range s {
 526         b := s[i]
 527 
 528         if prev == '\x1b' && b == '[' {
 529             j := indexLetter(s[i+1:])
 530             if j < 0 {
 531                 return i, -1
 532             }
 533             return i - 1, i + 1 + j + 1
 534         }
 535 
 536         if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
 537             j := indexPair(s[i+1:], '\x1b', '\\')
 538             if j < 0 {
 539                 return i, -1
 540             }
 541             return i - 1, i + 1 + j + 2
 542         }
 543 
 544         prev = b
 545     }
 546 
 547     return -1, -1
 548 }
 549 
 550 func indexLetter(s string) int {
 551     for i, b := range s {
 552         upper := b &^ 32
 553         if 'A' <= upper && upper <= 'Z' {
 554             return i
 555         }
 556     }
 557 
 558     return -1
 559 }
 560 
 561 func indexPair(s string, x byte, y byte) int {
 562     var prev byte
 563 
 564     for i := range s {
 565         b := s[i]
 566         if prev == x && b == y && i > 0 {
 567             return i
 568         }
 569         prev = b
 570     }
 571 
 572     return -1
 573 }
 574 
 575 func realign(w *bufio.Writer, t *table) {
 576     // make sums row first, as final alignments are usually affected by these
 577     var sums []string
 578     if t.ShowSums {
 579         sums = make([]string, 0, t.Columns)
 580 
 581         for i := 0; i < t.Columns; i++ {
 582             if t.Numeric[i] == 0 {
 583                 sums = append(sums, `-`)
 584                 if t.MaxWidth[i] < 1 {
 585                     t.MaxWidth[i] = 1
 586                 }
 587                 continue
 588             }
 589 
 590             decs := t.MaxDotDecimals[i]
 591             if decs > 0 {
 592                 decs--
 593             }
 594 
 595             var buf [64]byte
 596             s := strconv.AppendFloat(buf[:0], t.Sums[i], 'f', decs, 64)
 597             sums = append(sums, string(s))
 598             if t.MaxWidth[i] < len(s) {
 599                 t.MaxWidth[i] = len(s)
 600             }
 601         }
 602     }
 603 
 604     // due keeps track of how many spaces are due, when separating realigned
 605     // items from their immediate predecessor on the same row; this counter
 606     // is also used to right-pad numbers with decimals, as such items can be
 607     // padded with spaces from either side
 608     due := 0
 609 
 610     showItem := func(i int, s string, t *table) {
 611         if i > 0 {
 612             due += columnGap
 613         }
 614 
 615         if isNumeric(s, &(t.sb)) {
 616             dd := countDotDecimals(s)
 617             rpad := t.MaxDotDecimals[i] - dd
 618             width := countWidth(s)
 619             lpad := t.MaxWidth[i] - (width + rpad) + due
 620             writeSpaces(w, lpad)
 621             f, _ := strconv.ParseFloat(t.sb.String(), 64)
 622             writeNumericItem(w, s, numericStyle(f))
 623             due = rpad
 624             return
 625         }
 626 
 627         writeSpaces(w, due)
 628         w.WriteString(s)
 629         due = t.MaxWidth[i] - countWidth(s)
 630     }
 631 
 632     writeTile := func(i int, s string, t *table) {
 633         // make empty items stand out
 634         if len(s) == 0 {
 635             w.WriteString("\x1b[0m○")
 636             return
 637         }
 638 
 639         if isNumeric(s, &(t.sb)) {
 640             f, _ := strconv.ParseFloat(t.sb.String(), 64)
 641             w.WriteString(numericStyle(f))
 642             w.WriteString("")
 643             return
 644         }
 645 
 646         // make padded items stand out: these items have spaces at either end
 647         if s[0] == ' ' || s[len(s)-1] == ' ' {
 648             w.WriteString("\x1b[38;2;196;160;0m■")
 649             return
 650         }
 651 
 652         w.WriteString("\x1b[38;2;128;128;128m■")
 653     }
 654 
 655     // show realigned rows
 656 
 657     for _, line := range t.Rows {
 658         due = 0
 659 
 660         if t.ShowTiles {
 661             end := t.LoopItems(line, t.Columns, t, writeTile)
 662             if end < len(t.MaxWidth)-1 {
 663                 w.WriteString("\x1b[0m")
 664             }
 665             // make rows with missing trailing items stand out
 666             for i := end; i < len(t.MaxWidth); i++ {
 667                 w.WriteString("×")
 668             }
 669             w.WriteString("\x1b[0m")
 670             due += columnGap
 671         }
 672 
 673         t.LoopItems(line, t.Columns, t, showItem)
 674         if w.WriteByte('\n') != nil {
 675             return
 676         }
 677     }
 678 
 679     if t.Columns > 0 && t.ShowSums {
 680         realignSums(w, t, sums)
 681     }
 682 }
 683 
 684 func realignSums(w *bufio.Writer, t *table, sums []string) {
 685     due := 0
 686     if t.ShowTiles {
 687         due += t.Columns + columnGap
 688     }
 689 
 690     for i, s := range sums {
 691         if i > 0 {
 692             due += columnGap
 693         }
 694 
 695         if t.Numeric[i] == 0 {
 696             writeSpaces(w, due)
 697             w.WriteString(s)
 698             due = t.MaxWidth[i] - countWidth(s)
 699             continue
 700         }
 701 
 702         lpad := t.MaxWidth[i] - len(s) + due
 703         writeSpaces(w, lpad)
 704         writeNumericItem(w, s, numericStyle(t.Sums[i]))
 705         due = 0
 706     }
 707 
 708     w.WriteByte('\n')
 709 }
 710 
 711 // writeSpaces does what it says, minimizing calls to write-like funcs
 712 func writeSpaces(w *bufio.Writer, n int) {
 713     const spaces = `                                `
 714     if n < 1 {
 715         return
 716     }
 717 
 718     for n >= len(spaces) {
 719         w.WriteString(spaces)
 720         n -= len(spaces)
 721     }
 722     w.WriteString(spaces[:n])
 723 }
 724 
 725 func writeRowTiles(w *bufio.Writer, s string, t *table, writeTile itemFunc) {
 726     end := t.LoopItems(s, t.Columns, t, writeTile)
 727 
 728     if end < len(t.MaxWidth)-1 {
 729         w.WriteString("\x1b[0m")
 730     }
 731     for i := end + 1; i < len(t.MaxWidth); i++ {
 732         w.WriteString("×")
 733     }
 734     w.WriteString("\x1b[0m")
 735 }
 736 
 737 func numericStyle(f float64) string {
 738     if f > 0 {
 739         if float64(int64(f)) == f {
 740             return "\x1b[38;2;0;135;0m"
 741         }
 742         return "\x1b[38;2;0;155;95m"
 743     }
 744     if f < 0 {
 745         if float64(int64(f)) == f {
 746             return "\x1b[38;2;204;0;0m"
 747         }
 748         return "\x1b[38;2;215;95;95m"
 749     }
 750     if f == 0 {
 751         return "\x1b[38;2;0;95;215m"
 752     }
 753     return "\x1b[38;2;128;128;128m"
 754 }
 755 
 756 func writeNumericItem(w *bufio.Writer, s string, startStyle string) {
 757     w.WriteString(startStyle)
 758     if len(s) > 0 && (s[0] == '-' || s[0] == '+') {
 759         w.WriteByte(s[0])
 760         s = s[1:]
 761     }
 762 
 763     dot := strings.IndexByte(s, '.')
 764     if dot < 0 {
 765         restyleDigits(w, s, altDigitStyle)
 766         w.WriteString("\x1b[0m")
 767         return
 768     }
 769 
 770     if len(s[:dot]) > 3 {
 771         restyleDigits(w, s[:dot], altDigitStyle)
 772         w.WriteString("\x1b[0m")
 773         w.WriteString(startStyle)
 774         w.WriteByte('.')
 775     } else {
 776         w.WriteString(s[:dot])
 777         w.WriteByte('.')
 778     }
 779 
 780     rest := s[dot+1:]
 781     restyleDigits(w, rest, altDigitStyle)
 782     if len(rest) < 4 {
 783         w.WriteString("\x1b[0m")
 784     }
 785 }
 786 
 787 // restyleDigits renders a run of digits as alternating styled/unstyled runs
 788 // of 3 digits, which greatly improves readability, and is the only purpose
 789 // of this app; string is assumed to be all decimal digits
 790 func restyleDigits(w *bufio.Writer, digits string, altStyle string) {
 791     if len(digits) < 4 {
 792         // digit sequence is short, so emit it as is
 793         w.WriteString(digits)
 794         return
 795     }
 796 
 797     // separate leading 0..2 digits which don't align with the 3-digit groups
 798     i := len(digits) % 3
 799     // emit leading digits unstyled, if there are any
 800     w.WriteString(digits[:i])
 801     // the rest is guaranteed to have a length which is a multiple of 3
 802     digits = digits[i:]
 803 
 804     // start by styling, unless there were no leading digits
 805     style := i != 0
 806 
 807     for len(digits) > 0 {
 808         if style {
 809             w.WriteString(altStyle)
 810             w.WriteString(digits[:3])
 811             w.WriteString("\x1b[0m")
 812         } else {
 813             w.WriteString(digits[:3])
 814         }
 815 
 816         // advance to the next triple: the start of this func is supposed
 817         // to guarantee this step always works
 818         digits = digits[3:]
 819 
 820         // alternate between styled and unstyled 3-digit groups
 821         style = !style
 822     }
 823 }