commit dda5dc6884687667dedb1f3c3fad9cd041f5f36d Author: Arpad Ryszka Date: Thu Sep 11 21:16:09 2025 +0200 wip 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 + } +}