1
0
This commit is contained in:
Arpad Ryszka 2025-09-11 21:16:09 +02:00
commit dda5dc6884
8 changed files with 885 additions and 0 deletions

9
Makefile Normal file
View File

@ -0,0 +1,9 @@
SOURCES = $(shell find . -name "*.go")
default: build
build: $(SOURCES)
go build
fmt: $(SOURCES)
go fmt

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module code.squareroundforest.org/arpio/textfmt
go 1.25.0

224
lib.go Normal file
View File

@ -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
}

78
table.go Normal file
View File

@ -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
}

440
teletype.go Normal file
View File

@ -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)
// <foo>
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
}

38
text.go Normal file
View File

@ -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)
}

75
wrap.go Normal file
View File

@ -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")
}

18
writer.go Normal file
View File

@ -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
}
}