From dda5dc6884687667dedb1f3c3fad9cd041f5f36d Mon Sep 17 00:00:00 2001 From: Arpad Ryszka Date: Thu, 11 Sep 2025 21:16:09 +0200 Subject: [PATCH] wip --- Makefile | 9 ++ go.mod | 3 + lib.go | 224 ++++++++++++++++++++++++++ table.go | 78 ++++++++++ teletype.go | 440 ++++++++++++++++++++++++++++++++++++++++++++++++++++ text.go | 38 +++++ wrap.go | 75 +++++++++ writer.go | 18 +++ 8 files changed, 885 insertions(+) create mode 100644 Makefile create mode 100644 go.mod create mode 100644 lib.go create mode 100644 table.go create mode 100644 teletype.go create mode 100644 text.go create mode 100644 wrap.go create mode 100644 writer.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8c7cbe3 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +SOURCES = $(shell find . -name "*.go") + +default: build + +build: $(SOURCES) + go build + +fmt: $(SOURCES) + go fmt diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e0efcfa --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.squareroundforest.org/arpio/textfmt + +go 1.25.0 diff --git a/lib.go b/lib.go new file mode 100644 index 0000000..ccc0ce2 --- /dev/null +++ b/lib.go @@ -0,0 +1,224 @@ +package textfmt + +import "io" + +const ( + invalid = iota + title + paragraph + list + numberedList + definitions + numberedDefinitions + table + code + syntax +) + +type Txt struct { + text string + link string + bold, italic bool + cat []Txt +} + +type ListItem struct { + text Txt +} + +type DefinitionItem struct { + name, value Txt +} + +type TableCell struct { + text Txt +} + +type TableRow struct { + cells []TableCell + header bool +} + +type SyntaxItem struct { + symbol string + multiple bool + required bool + optional bool + sequence []SyntaxItem + choice []SyntaxItem + topLevel bool + delimited bool +} + +type Entry struct { + typ int + text Txt + titleLevel int + items []ListItem + definitions []DefinitionItem + rows []TableRow + syntax SyntaxItem + indentFirst int + indentRest int + wrapWidth int +} + +type Document struct { + entries []Entry +} + +func Text(text string) Txt { + return Txt{text: text} +} + +func Link(title, uri string) Txt { + return Txt{text: title, link: uri} +} + +func Bold(t Txt) Txt { + t.bold = true + return t +} + +func Italic(t Txt) Txt { + t.italic = true + return t +} + +func Cat(t ...Txt) Txt { + return Txt{cat: t} +} + +func Title(level int, text string) Entry { + return Entry{ + typ: title, + titleLevel: level, + text: Text(text), + } +} + +func Paragraph(t Txt) Entry { + return Entry{typ: paragraph, text: t} +} + +func Item(text Txt) ListItem { + return ListItem{text: text} +} + +func List(items ...ListItem) Entry { + return Entry{typ: list, items: items} +} + +func NumberedList(items ...ListItem) Entry { + return Entry{typ: numberedList, items: items} +} + +func Definition(name, value Txt) DefinitionItem { + return DefinitionItem{name: name, value: value} +} + +func DefinitionList(items ...DefinitionItem) Entry { + return Entry{typ: definitions, definitions: items} +} + +func NumberedDefinitionList(items ...DefinitionItem) Entry { + return Entry{typ: numberedDefinitions, definitions: items} +} + +func Cell(text Txt) TableCell { + return TableCell{text: text} +} + +func Header(cells ...TableCell) TableRow { + return TableRow{cells: cells, header: true} +} + +func Row(cells ...TableCell) TableRow { + return TableRow{cells: cells} +} + +func Table(rows ...TableRow) Entry { + return Entry{typ: table, rows: rows} +} + +func CodeBlock(codeBlock string) Entry { + return Entry{typ: code, text: Text(codeBlock)} +} + +func Symbol(text string) SyntaxItem { + return SyntaxItem{symbol: text} +} + +func OneOrMore(item SyntaxItem) SyntaxItem { + item.required = true + item.optional = false + item.multiple = true + return item +} + +func ZeroOrMore(item SyntaxItem) SyntaxItem { + item.required = false + item.optional = true + item.multiple = true + return item +} + +func Required(item SyntaxItem) SyntaxItem { + item.required = true + item.optional = false + return item +} + +func Optional(item SyntaxItem) SyntaxItem { + item.required = false + item.optional = true + return item +} + +func Sequence(items ...SyntaxItem) SyntaxItem { + return SyntaxItem{sequence: items} +} + +func Choice(items ...SyntaxItem) SyntaxItem { + return SyntaxItem{choice: items} +} + +func Syntax(items ...SyntaxItem) Entry { + return Entry{typ: syntax, syntax: Sequence(items...)} +} + +func Indent(e Entry, first, rest int) Entry { + e.indentFirst, e.indentRest = first, rest + return e +} + +func Wrap(e Entry, width int) Entry { + e.wrapWidth = width + return e +} + +func Doc(e ...Entry) Document { + return Document{entries: e} +} + +func Teletype(out io.Writer, d Document) error { + return renderTeletype(out, d) +} + +func Roff(io.Writer, Document) error { + return nil +} + +func Markdown(io.Writer, Document) error { + return nil +} + +func HTML(io.Writer, Document) error { + // with the won HTML library + return nil +} + +func HTMLFragment(io.Writer, Document) error { + // with the won HTML library + return nil +} diff --git a/table.go b/table.go new file mode 100644 index 0000000..201a315 --- /dev/null +++ b/table.go @@ -0,0 +1,78 @@ +package textfmt + +import "strings" + +func normalizeTable(rows []TableRow) []TableRow { + var maxColumns int + for _, row := range rows { + if len(row.cells) > maxColumns { + maxColumns = len(row.cells) + } + } + + var normalized []TableRow + for _, row := range rows { + row.cells = append( + row.cells, + make([]TableCell, maxColumns-len(row.cells))..., + ) + + normalized = append(normalized, row) + } + + return normalized +} + +func columnWeights(cells [][]string) []int { + if len(cells) == 0 { + return nil + } + + w := make([]int, len(cells[0])) + for _, row := range cells { + for i, cell := range row { + w[i] += len([]rune(cell)) + } + } + + return w +} + +func targetColumnWidths(tableWidth int, weights []int) []int { + var weightSum int + for _, w := range weights { + weightSum += w + } + + widths := make([]int, len(weights)) + for i := range weights { + widths[i] = (weights[i] * tableWidth) / weightSum + } + + return widths +} + +func columnWidths(rows [][]string) []int { + if len(rows) == 0 { + return nil + } + + if len(rows[0]) == 0 { + return nil + } + + widths := make([]int, len(rows[0])) + for i := range rows { + for j := range rows[i] { + l := strings.Split(rows[i][j], "\n") + for k := range l { + lk := len([]rune(l[k])) + if lk > widths[j] { + widths[j] = lk + } + } + } + } + + return widths +} diff --git a/teletype.go b/teletype.go new file mode 100644 index 0000000..cf2523f --- /dev/null +++ b/teletype.go @@ -0,0 +1,440 @@ +package textfmt + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" +) + +func escapeTeletype(s string) string { + r := []rune(s) + for i := range r { + if r[i] >= 0x00 && r[i] <= 0x1f && r[i] != '\n' && r[i] != '\t' { + r[i] = 0xb7 + } + + if r[i] >= 0x7f && r[i] <= 0x9f { + r[i] = 0xb7 + } + } + + return string(r) +} + +func writeLines(w *writer, txt string, indentFirst, indentRest int) { + lines := strings.Split(txt, "\n") + for i, l := range lines { + if i > 0 { + w.write("\n") + } + + indent := indentFirst + if i > 0 { + indent = indentRest + } + + w.write(timesn(" ", indent)) + w.write(l) + } +} + +func renderTTYText(w *writer, text Txt) { + if len(text.cat) > 0 { + for i, tc := range text.cat { + if i > 0 { + w.write(" ") + } + + renderTTYText(w, tc) + } + + return + } + + text.text = singleLine(text.text) + text.text = escapeTeletype(text.text) + if text.link != "" { + if text.text != "" { + w.write(text.text) + w.write(" (") + w.write(text.link) + w.write(")") + return + } + + w.write(text.link) + return + } + + w.write(text.text) +} + +func ttyTextToString(text Txt) (string, error) { + var b bytes.Buffer + w := writer{w: &b} + renderTTYText(&w, text) + if w.err != nil { + return "", w.err + } + + return b.String(), nil +} + +func definitionNames(d []DefinitionItem) ([]string, error) { + var n []string + for _, di := range d { + name, err := ttyTextToString(di.name) + if err != nil { + return nil, err + } + + n = append(n, name) + } + + return n, nil +} + +func renderTTYTitle(w *writer, e Entry) { + w.write(timesn(" ", e.indentFirst)) + renderTTYText(w, e.text) +} + +func renderTTYParagraph(w *writer, e Entry) { + var txt string + txt, w.err = ttyTextToString(e.text) + if e.wrapWidth > 0 { + txt = wrap(txt, e.wrapWidth, e.indentFirst, e.indentRest) + } + + writeLines(w, txt, e.indentFirst, e.indentRest) +} + +func renderTTYList(w *writer, e Entry) { + const bullet = "- " + indentFirst := e.indentFirst + indentRest := e.indentRest + len(bullet) + for i, item := range e.items { + if i > 0 { + w.write("\n") + } + + var txt string + txt, w.err = ttyTextToString(item.text) + if e.wrapWidth > 0 { + txt = wrap(txt, e.wrapWidth-len(bullet), indentFirst, indentRest) + } + + w.write(timesn(" ", indentFirst)) + w.write(bullet) + writeLines(w, txt, 0, indentRest) + } +} + +func renderTTYNumberedList(w *writer, e Entry) { + maxDigits := maxDigits(len(e.items)) + indentFirst := e.indentFirst + indentRest := e.indentRest + maxDigits + 2 + for i, item := range e.items { + if i > 0 { + w.write("\n") + } + + var txt string + txt, w.err = ttyTextToString(item.text) + if e.wrapWidth > 0 { + txt = wrap(txt, e.wrapWidth-maxDigits-2, indentFirst, indentRest) + } + + w.write(timesn(" ", indentFirst)) + w.write(fmt.Sprintf("%d. ", i)) + writeLines(w, txt, 0, indentRest) + } +} + +func renderTTYDefinitions(w *writer, e Entry) { + names, err := definitionNames(e.definitions) + if err != nil { + w.err = err + return + } + + maxLength := maxLength(names) + indentFirst := e.indentFirst + indentRest := e.indentRest + maxLength + 4 + for i, def := range e.definitions { + if i > 0 { + w.write("\n") + } + + var value string + value, w.err = ttyTextToString(def.value) + if e.wrapWidth > 0 { + value = wrap(value, e.wrapWidth-maxLength-4, indentFirst, indentRest) + } + + name := names[i] + w.write("- ") + w.write(name) + w.write(": ") + writeLines(w, value, 0, indentRest) + } +} + +func renderTTYNumberedDefinitions(w *writer, e Entry) { + maxDigits := maxDigits(len(e.definitions)) + names, err := definitionNames(e.definitions) + if err != nil { + w.err = err + return + } + + maxLength := maxLength(names) + indentFirst := e.indentFirst + indentRest := e.indentRest + maxLength + maxDigits + 4 + for i, def := range e.definitions { + if i > 0 { + w.write("\n") + } + + var value string + value, w.err = ttyTextToString(def.value) + if e.wrapWidth > 0 { + value = wrap(value, e.wrapWidth-maxLength-4, indentFirst, indentRest) + } + + name := names[i] + w.write(fmt.Sprintf("%d. ", i)) + w.write(name) + w.write(": ") + writeLines(w, value, 0, indentRest) + } +} + +func ttyCellTexts(rows []TableRow) ([][]string, error) { + var cellTexts [][]string + for _, row := range rows { + var c []string + for _, cell := range row.cells { + txt, err := ttyTextToString(cell.text) + if err != nil { + return nil, err + } + + c = append(c, txt) + } + + cellTexts = append(cellTexts, c) + } + + return cellTexts, nil +} + +func renderTTYTable(w *writer, e Entry) { + if len(e.rows) == 0 { + return + } + + e.rows = normalizeTable(e.rows) + if len(e.rows[0].cells) == 0 { + return + } + + cellTexts, err := ttyCellTexts(e.rows) + if err != nil { + w.err = err + } + + totalSeparatorWidth := (len(cellTexts[0]) - 1) * 3 + if e.wrapWidth > 0 { + allocatedWidth := e.wrapWidth - e.indentFirst - totalSeparatorWidth + columnWeights := columnWeights(cellTexts) + targetColumnWidths := targetColumnWidths(allocatedWidth, columnWeights) + for i := range cellTexts { + for j := range cellTexts[i] { + cellTexts[i][j] = wrap(cellTexts[i][j], targetColumnWidths[j], 0, 0) + } + } + } + + columnWidths := columnWidths(cellTexts) + totalWidth := totalSeparatorWidth + for i := range columnWidths { + totalWidth += columnWidths[i] + } + + hasHeader := e.rows[0].header + for i := range cellTexts { + if i > 0 { + sep := "-" + if hasHeader && i == 1 { + sep = "=" + } + + w.write("\n") + w.write(timesn(sep, totalWidth)) + w.write("\n") + } + + for j := range cellTexts[i] { + if j > 0 { + w.write(" | ") + } + + w.write(padRight(cellTexts[i][j], columnWidths[j])) + } + } + + if hasHeader && len(cellTexts) == 1 { + w.write(timesn("=", totalWidth)) + } +} + +func renderTTYCode(w *writer, e Entry) { + var txt string + txt, w.err = ttyTextToString(e.text) + writeLines(w, txt, e.indentFirst, e.indentFirst) +} + +func renderTTYMultiple(w *writer, s SyntaxItem) { + s.topLevel = false + s.multiple = false + renderTTYSyntaxItem(w, s) + w.write("...") +} + +func renderTTYRequired(w *writer, s SyntaxItem) { + s.delimited = true + s.topLevel = false + s.required = false + w.write("<") + renderTTYSyntaxItem(w, s) + w.write(">") +} + +func renderTTYOptional(w *writer, s SyntaxItem) { + s.delimited = true + s.topLevel = false + s.optional = false + w.write("[") + renderTTYSyntaxItem(w, s) + w.write("]") +} + +func renderTTYSequence(w *writer, s SyntaxItem) { + if !s.delimited && !s.topLevel { + w.write("(") + } + + for i, item := range s.sequence { + if i > 0 { + w.write(" ") + } + + item.delimited = false + renderTTYSyntaxItem(w, item) + } + + if !s.delimited && !s.topLevel { + w.write(")") + } +} + +func renderTTYChoice(w *writer, s SyntaxItem) { + if !s.delimited && !s.topLevel { + w.write("(") + } + + for i, item := range s.sequence { + if i > 0 { + separator := "|" + if s.topLevel { + separator = "\n" + } + + w.write(separator) + } + + item.delimited = false + renderTTYSyntaxItem(w, item) + } + + if !s.delimited && !s.topLevel { + w.write(")") + } +} + +func renderTTYSymbol(w *writer, s SyntaxItem) { + w.write(escapeTeletype(s.symbol)) +} + +func renderTTYSyntaxItem(w *writer, s SyntaxItem) { + switch { + + // foo... + case s.multiple: + renderTTYMultiple(w, s) + + // + case s.required: + renderTTYRequired(w, s) + + // [foo] + case s.optional: + renderTTYOptional(w, s) + + // foo bar baz or (foo bar baz) + case len(s.sequence) > 0: + renderTTYSequence(w, s) + + // foo|bar|baz or (foo|bar|baz) + case len(s.choice) > 0: + renderTTYChoice(w, s) + + // foo + default: + renderTTYSymbol(w, s) + } +} + +func renderTTYSyntax(w *writer, e Entry) { + s := e.syntax + s.topLevel = true + renderTTYSyntaxItem(w, s) +} + +func renderTeletype(out io.Writer, d Document) error { + w := writer{w: out} + for i, e := range d.entries { + if i > 0 { + w.write("\n\n") + } + + switch e.typ { + case invalid: + return errors.New("invalid entry") + case title: + renderTTYTitle(&w, e) + case paragraph: + renderTTYParagraph(&w, e) + case list: + renderTTYList(&w, e) + case numberedList: + renderTTYNumberedList(&w, e) + case definitions: + renderTTYDefinitions(&w, e) + case numberedDefinitions: + renderTTYNumberedDefinitions(&w, e) + case table: + renderTTYTable(&w, e) + case code: + renderTTYCode(&w, e) + case syntax: + renderTTYSyntax(&w, e) + } + } + + w.write("\n") + return w.err +} diff --git a/text.go b/text.go new file mode 100644 index 0000000..3d1807a --- /dev/null +++ b/text.go @@ -0,0 +1,38 @@ +package textfmt + +import "strings" + +func timesn(s string, n int) string { + ss := make([]string, n+1) + return strings.Join(ss, s) +} + +func maxDigits(n int) int { + if n == 0 { + return 1 + } + + var d int + for n > 0 { + d++ + n /= 10 + } + + return d +} + +func maxLength(names []string) int { + var m int + for _, n := range names { + if len([]rune(n)) > m { + m = len([]rune(n)) + } + } + + return m +} + +func padRight(s string, n int) string { + n -= len([]rune(s)) + return s + timesn(" ", n) +} diff --git a/wrap.go b/wrap.go new file mode 100644 index 0000000..03c268c --- /dev/null +++ b/wrap.go @@ -0,0 +1,75 @@ +package textfmt + +import "strings" + +func getWords(text string) []string { + var words []string + raw := strings.Split(text, " ") + for _, r := range raw { + if r == "" { + continue + } + + words = append(words, r) + } + + return words +} + +func lineLength(words []string) int { + if len(words) == 0 { + return 0 + } + + var l int + for _, w := range words { + r := []rune(w) + l += len(r) + } + + return l + len(words) - 1 +} + +func singleLine(text string) string { + var l []string + p := strings.Split(text, "\n") + for _, part := range p { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + l = append(l, part) + } + + return strings.Join(l, " ") +} + +func wrap(text string, width, firstIndent, restIndent int) string { + var ( + lines []string + currentLine []string + currentLen int + ) + + words := getWords(text) + for _, w := range words { + maxw := width - restIndent + if len(lines) == 0 { + maxw = width - firstIndent + } + + currentLine = append(currentLine, w) + if lineLength(currentLine) > maxw { + currentLine = currentLine[:len(currentLine)-1] + lines = append(lines, strings.Join(currentLine, " ")) + currentLine = []string{w} + } + } + + if len(currentLine) > 0 { + lines = append(lines, strings.Join(currentLine, " ")) + } + + return strings.Join(lines, "\n") +} diff --git a/writer.go b/writer.go new file mode 100644 index 0000000..357882d --- /dev/null +++ b/writer.go @@ -0,0 +1,18 @@ +package textfmt + +import "io" + +type writer struct { + w io.Writer + err error +} + +func (w *writer) write(s string) { + if w.err != nil { + return + } + + if _, err := w.w.Write([]byte(s)); err != nil { + w.err = err + } +}