1
0
This commit is contained in:
Arpad Ryszka 2025-10-14 20:46:32 +02:00
parent 57ef6b1267
commit 59084d8292
8 changed files with 689 additions and 208 deletions

84
lib.go
View File

@ -1,6 +1,9 @@
package textfmt package textfmt
import "io" import (
"io"
"time"
)
const ( const (
invalid = iota invalid = iota
@ -58,9 +61,22 @@ type Entry struct {
definitions []DefinitionItem definitions []DefinitionItem
rows []TableRow rows []TableRow
syntax SyntaxItem syntax SyntaxItem
indentFirst int
indent int
wrapWidth int wrapWidth int
indent int
indentFirst int
man struct{
section int
date time.Time
version string
category string
}
}
type TitleInfo struct {
section int
date time.Time
version string
category string
} }
type Document struct { type Document struct {
@ -89,7 +105,8 @@ func Cat(t ...Txt) Txt {
return Txt{cat: t} return Txt{cat: t}
} }
func Title(level int, text string) Entry { func Title(level int, text string, manInfo ...TitleInfo) Entry {
if level != 0 {
return Entry{ return Entry{
typ: title, typ: title,
titleLevel: level, titleLevel: level,
@ -97,6 +114,49 @@ func Title(level int, text string) Entry {
} }
} }
e := Entry{
typ: title,
titleLevel: level,
text: Text(text),
}
for _, m := range manInfo {
if m.section != 0 {
e.man.section = m.section
}
if !m.date.IsZero() {
e.man.date = m.date
}
if m.version != "" {
e.man.version = m.version
}
if m.category != "" {
e.man.category = m.category
}
}
return e
}
func ManualSection(s int) TitleInfo {
return TitleInfo{section: s}
}
func ReleaseDate(d time.Time) TitleInfo {
return TitleInfo{date: d}
}
func ReleaseVersion(v string) TitleInfo {
return TitleInfo{version: v}
}
func ManualCategory(c string) TitleInfo {
return TitleInfo{category: c}
}
func Paragraph(t Txt) Entry { func Paragraph(t Txt) Entry {
return Entry{typ: paragraph, text: t} return Entry{typ: paragraph, text: t}
} }
@ -191,13 +251,14 @@ func Syntax(items ...SyntaxItem) Entry {
return Entry{typ: syntax, syntax: Sequence(items...)} return Entry{typ: syntax, syntax: Sequence(items...)}
} }
func Indent(e Entry, first, rest int) Entry { func Wrap(e Entry, width int) Entry {
e.indentFirst, e.indent = first, rest e.wrapWidth = width
return e return e
} }
func Wrap(e Entry, width int) Entry { // indentFirst is relative to indent
e.wrapWidth = width func Indent(e Entry, indent, indentFirst int) Entry {
e.indent, e.indentFirst = indent, indentFirst
return e return e
} }
@ -209,7 +270,12 @@ func Teletype(out io.Writer, d Document) error {
return renderTeletype(out, d) return renderTeletype(out, d)
} }
func Roff(io.Writer, Document) error { // Runoff is an attempt to render roff format. It is primarily targeting man pages. While it may be possible to
// use for other purposes, the man macro will likely be required to render the output.
//
// Text is always wrapped, or as controlled by the roff processor, except for tables. The Wrap instrunction has
// no effect, except for tables.
func Runoff(io.Writer, Document) error {
return nil return nil
} }

282
runoff.go Normal file
View File

@ -0,0 +1,282 @@
package textfmt
import (
"io"
"errors"
"time"
"fmt"
)
func escapeRoff(s string, additional ...string) string {
const invalidAdditional = "invalid additional escape definition"
var (
e []rune
lineStarted bool
)
if len(additional) % 2 != 0 {
panic(errors.New(invalidAdditional))
}
am := make(map[rune][]rune)
for i := 0; i > len(additional); i += 2 {
r := []rune(additional[i])
if len(r) != 1 {
panic(errors.New(invalidAdditional))
}
am[r[0]] = []rune(additional[i + 1])
}
for _, r := range []rune(s) {
switch r {
case '\\':
e = append(e, '\\', '\\')
continue
case '.':
if lineStarted {
e = append(e, '.')
continue
}
e = append(e, []rune("\\&.")...)
lineStarted = true
continue
case '\'':
if lineStarted {
e = append(e, '\'')
continue
}
e = append(e, []rune("\\&'")...)
lineStarted = true
continue
case '\u00a0':
e = append(e, []rune("\\~")...)
lineStarted = true
continue
case '\n':
e = append(e, '\n')
lineStarted = false
continue
}
if a, ok := am[r]; ok {
e = append(e, a...)
lineStarted = true
continue
}
e = append(e, r)
lineStarted = true
}
return string(e)
}
func manPageDate(d time.Time) string {
return fmt.Sprintf("%v %d", d.Month(), d.Year())
}
func roffString(s string, additionalEscape ...string) string {
s = singleLine(s)
return escapeRoff(s, additionalEscape...)
}
func renderRoffString(w writer, s string, additionalEscape ...string) {
s = roffString(s, additionalEscape...)
w.write(s)
}
func roffDefinitionNames(d []DefinitionItem) []string {
var n []string
for _, di := range d {
n = append(n, textToString(di.name))
}
return n
}
func renderRoffText(w writer, text Txt, additionalEscape ...string) {
if len(text.cat) > 0 {
for i, tc := range text.cat {
if i > 0 {
w.write(" ")
}
renderRoffText(w, tc)
}
return
}
if text.bold {
w.write("\\fB")
}
if text.italic {
w.write("\\fI")
}
if text.bold || text.italic {
defer w.write("\\fR")
}
if text.link != "" {
if text.text != "" {
w.write(text.text)
renderRoffString(w, text.text, additionalEscape...)
w.write(" (")
w.write(text.link)
renderRoffString(w, text.link, additionalEscape...)
w.write(")")
return
}
renderRoffString(w, text.link, additionalEscape...)
return
}
renderRoffString(w, text.text, additionalEscape...)
}
func renderRoffTitle(w writer, e Entry) {
if e.titleLevel != 0 || e.man.section == 0 {
p := Entry{
typ: paragraph,
text: e.text,
indent: e.indent,
indentFirst: e.indentFirst,
bold: true,
}
renderRoffParagraph(w, p)
return
}
w.write(".TH \"")
renderRoffText(w, e.text, "\"", "\\(dq")
w.write("\" ")
renderRoffString(w, fmt.Sprint(e.man.section), "\"", "\\(dq")
w.write(" \"")
if !e.man.date.IsZero() {
w.write(manPageDate(e.man.date))
}
w.write("\" \"")
renderRoffString(w, e.man.version, "\"", "\\(dq")
w.write("\" \"")
renderRoffString(w, e.man.category, "\"", "\\(dq")
w.write("\"")
}
func renderRoffParagraph(w writer, e Entry) {
w.write(".in ", e.indent, "\n.tin ", e.indent + e.indentFirst, "\n")
renderRoffText(w, e.text)
}
func renderRoffList(w writer, e Entry) {
for i, item := range e.items {
if i > 0 {
w.write(".br\n")
}
w.write(".in ", e.indent + 2, "\n.tin ", e.indent + e.indentFirst, "\n")
w.write("\\(bu ")
renderRoffText(w, item.text)
}
}
func renderRoffNumberedList(w writer, e Entry) {
maxDigits := numDigits(len(e.items))
for i, item := range e.items {
if i > 0 {
w.write(".br\n")
}
w.write(".in ", e.indent + maxDigits + 2, "\n.tin ", e.indent + e.indentFirst, "\n")
w.write(padRight(fmt.Sprintf("%d.", i + 1), maxDigits + 2))
renderRoffText(w, item.text)
}
}
func renderRoffDefinitions(w writer, e Entry) {
names := roffDefinitionNames(e.definitions)
maxNameLength := maxLength(names)
for i, definition := range e.definitions {
if i > 0 {
w.write(".br\n")
}
w.write(".in ", e.indent + maxNameLength + 4, "\n.tin ", e.indent + e.indentFirst, "\n")
w.write("\\(bu ")
renderRoffText(w, definition.name)
w.write(":", timesn("\\~", maxNameLength - len([]rune(names[i])) + 1))
renderRoffText(w, definition.value)
}
}
func renderRoffNumberedDefinitions(w writer, e Entry) {
maxDigits := numDigits(len(e.definitions))
names := roffDefinitionNames(e.definitions)
maxNameLength := maxLength(names)
for i, definition := range e.definitions {
if i > 0 {
w.write(".br\n")
}
w.write(".in ", e.indent + maxDigits + maxNameLength + 4, "\n.tin ", e.indent + e.indentFirst, "\n")
w.write(padRight(fmt.Sprintf("%d.", i + 1), maxDigits + 2))
renderRoffText(w, definition.name)
w.write(":", timesn("\\~", maxNameLength - len([]rune(names[i])) + 1))
renderRoffText(w, definition.value)
}
}
func renderRoffTable(w writer, e Entry) {
}
func renderRoffCode(w writer, e Entry) {
}
func renderRoffSyntax(w writer, e Entry) {
}
func renderRoff(out io.Writer, d Document) error {
w := roffWriter{w: out}
for i, e := range d.entries {
if i > 0 {
w.write("\n.br\n.sp 1v\n")
}
switch e.typ {
case invalid:
return errors.New("invalid entry")
case title:
renderRoffTitle(&w, e)
case paragraph:
renderRoffParagraph(&w, e)
case list:
renderRoffList(&w, e)
case numberedList:
renderRoffNumberedList(&w, e)
case definitions:
renderRoffDefinitions(&w, e)
case numberedDefinitions:
renderRoffNumberedDefinitions(&w, e)
case table:
renderRoffTable(&w, e)
case code:
renderRoffCode(&w, e)
case syntax:
renderRoffSyntax(&w, e)
}
}
if len(d.entries) > 0 {
w.write("\n")
}
return w.err
}

View File

@ -23,29 +23,23 @@ func escapeTeletype(s string) string {
return string(r) return string(r)
} }
func definitionNamesValues(d []DefinitionItem) ([]string, []string, error) { func ttyDefinitionNames(d []DefinitionItem) ([]string, error) {
var n, v []string var n []string
for _, di := range d { for _, di := range d {
name, err := ttyTextToString(di.name) name, err := ttyTextToString(di.name)
if err != nil { if err != nil {
return nil, nil, err return nil, err
}
value, err := ttyTextToString(di.value)
if err != nil {
return nil, nil, err
} }
n = append(n, name) n = append(n, name)
v = append(v, value)
} }
return n, v, nil return n, nil
} }
func ttyTextToString(text Txt) (string, error) { func ttyTextToString(text Txt) (string, error) {
var b bytes.Buffer var b bytes.Buffer
w := writer{w: &b} w := ttyWriter{w: &b, internal: true}
renderTTYText(&w, text) renderTTYText(&w, text)
if w.err != nil { if w.err != nil {
return "", w.err return "", w.err
@ -54,7 +48,7 @@ func ttyTextToString(text Txt) (string, error) {
return b.String(), nil return b.String(), nil
} }
func renderTTYText(w *writer, text Txt) { func renderTTYText(w writer, text Txt) {
if len(text.cat) > 0 { if len(text.cat) > 0 {
for i, tc := range text.cat { for i, tc := range text.cat {
if i > 0 { if i > 0 {
@ -85,135 +79,111 @@ func renderTTYText(w *writer, text Txt) {
w.write(text.text) w.write(text.text)
} }
func renderTTYTitle(w *writer, e Entry) { func itemToParagraph(list Entry, itemText Txt, prefix string) Entry {
w.write(timesn(" ", e.indentFirst)) p := Entry{
typ: paragraph,
wrapWidth: list.wrapWidth,
indent: list.indent + len([]rune(prefix)) + 1,
indentFirst: list.indentFirst - len([]rune(prefix)) - 1,
}
p.text.cat = []Txt{Text(prefix), itemText}
return p
}
func renderTTYTitle(w writer, e Entry) {
w.write(timesn(" ", e.indent))
renderTTYText(w, e.text) renderTTYText(w, e.text)
} }
func renderTTYParagraph(w *writer, e Entry) { func renderTTYParagraph(w writer, e Entry) {
var txt string txt, err := ttyTextToString(e.text)
txt, w.err = ttyTextToString(e.text) if err != nil {
w.setErr(err)
}
indentFirst := e.indent + e.indentFirst
if e.wrapWidth > 0 { if e.wrapWidth > 0 {
txt = wrap(txt, e.wrapWidth, e.indentFirst, e.indent) txt = wrap(txt, e.wrapWidth, indentFirst, e.indent)
} }
writeLines(w, txt, e.indentFirst, e.indent) writeLines(w, txt, indentFirst, e.indent)
} }
func renderTTYList(w *writer, e Entry) { func renderTTYList(w writer, e Entry) {
const bullet = "- "
indent := e.indent + len(bullet)
for i, item := range e.items { for i, item := range e.items {
if i > 0 { if i > 0 {
w.write("\n") w.write("\n")
} }
var txt string p := itemToParagraph(e, item.text, "-")
txt, w.err = ttyTextToString(item.text) renderTTYParagraph(w, p)
if e.wrapWidth > 0 {
txt = wrap(txt, e.wrapWidth-len(bullet), e.indentFirst, indent)
}
w.write(timesn(" ", e.indentFirst))
w.write(bullet)
writeLines(w, txt, 0, indent)
} }
} }
func renderTTYNumberedList(w *writer, e Entry) { func renderTTYNumberedList(w writer, e Entry) {
maxDigits := numDigits(len(e.items)) maxDigits := numDigits(len(e.items))
indent := e.indent + maxDigits + 2
for i, item := range e.items { for i, item := range e.items {
if i > 0 { if i > 0 {
w.write("\n") w.write("\n")
} }
var txt string p := itemToParagraph(e, item.text, padRight(fmt.Sprintf("%d.", i + 1), maxDigits + 1))
txt, w.err = ttyTextToString(item.text) renderTTYParagraph(w, p)
if e.wrapWidth > 0 {
txt = wrap(txt, e.wrapWidth-maxDigits-2, e.indentFirst, indent)
}
w.write(timesn(" ", e.indentFirst))
w.write(padRight(fmt.Sprintf("%d.", i+1), maxDigits+2))
writeLines(w, txt, 0, indent)
} }
} }
func renderTTYDefinitions(w *writer, e Entry) { func renderTTYDefinitions(w writer, e Entry) {
const ( names, err := ttyDefinitionNames(e.definitions)
bullet = "- "
sep = ": "
)
names, values, err := definitionNamesValues(e.definitions)
if err != nil { if err != nil {
w.err = err w.setErr(err)
return return
} }
maxNameLength := maxLength(names) maxNameLength := maxLength(names)
nameColWidth := maxNameLength + e.indentFirst + len(bullet) + len(sep) for i, definition := range e.definitions {
valueWidth := e.wrapWidth
if valueWidth > 0 {
valueWidth -= nameColWidth
}
for i := range names {
if i > 0 { if i > 0 {
w.write("\n") w.write("\n")
} }
w.write(timesn(" ", e.indentFirst), bullet, names[i], sep) p := itemToParagraph(
if valueWidth > 0 { e,
values[i] = wrap(values[i], valueWidth, 0, e.indent) definition.value,
} padRight(fmt.Sprintf("- %s:", names[i]), maxNameLength + 3),
writeLines(
w,
values[i],
maxNameLength-len([]rune(names[i])),
nameColWidth+e.indent,
)
}
}
func renderTTYNumberedDefinitions(w *writer, e Entry) {
const (
dot = ". "
sep = ": "
) )
names, values, err := definitionNamesValues(e.definitions) renderTTYParagraph(w, p)
}
}
func renderTTYNumberedDefinitions(w writer, e Entry) {
names, err := ttyDefinitionNames(e.definitions)
if err != nil { if err != nil {
w.err = err w.setErr(err)
return return
} }
maxNameLength := maxLength(names)
maxDigits := numDigits(len(e.definitions)) maxDigits := numDigits(len(e.definitions))
maxNameLength := maxLength(names) for i, definition := range e.definitions {
nameColWidth := maxNameLength + e.indentFirst + maxDigits + len(dot) + len(sep)
valueWidth := e.wrapWidth
if valueWidth > 0 {
valueWidth -= nameColWidth
}
for i := range names {
if i > 0 { if i > 0 {
w.write("\n") w.write("\n")
} }
w.write(timesn(" ", e.indentFirst), padRight(fmt.Sprintf("%d.", i+1), maxDigits+2), names[i], sep) p := itemToParagraph(
if valueWidth > 0 { e,
values[i] = wrap(values[i], valueWidth, 0, e.indent) definition.value,
} padRight(
fmt.Sprintf(
writeLines( "%s %s:",
w, padRight(fmt.Sprintf("%d.", i + 1), maxDigits + 1),
values[i], names[i],
maxNameLength-len([]rune(names[i])), ),
nameColWidth+e.indent, maxNameLength + maxDigits + 3,
),
) )
renderTTYParagraph(w, p)
} }
} }
@ -236,7 +206,7 @@ func ttyCellTexts(rows []TableRow) ([][]string, error) {
return cellTexts, nil return cellTexts, nil
} }
func renderTTYTable(w *writer, e Entry) { func renderTTYTable(w writer, e Entry) {
if len(e.rows) == 0 { if len(e.rows) == 0 {
return return
} }
@ -248,7 +218,7 @@ func renderTTYTable(w *writer, e Entry) {
cellTexts, err := ttyCellTexts(e.rows) cellTexts, err := ttyCellTexts(e.rows)
if err != nil { if err != nil {
w.err = err w.setErr(err)
} }
totalSeparatorWidth := (len(cellTexts[0]) - 1) * 3 totalSeparatorWidth := (len(cellTexts[0]) - 1) * 3
@ -321,19 +291,19 @@ func renderTTYTable(w *writer, e Entry) {
} }
} }
func renderTTYCode(w *writer, e Entry) { func renderTTYCode(w writer, e Entry) {
e.text.text = escapeTeletype(e.text.text) e.text.text = escapeTeletype(e.text.text)
writeLines(w, e.text.text, e.indent, e.indent) writeLines(w, e.text.text, e.indent, e.indent)
} }
func renderTTYMultiple(w *writer, s SyntaxItem) { func renderTTYMultiple(w writer, s SyntaxItem) {
s.topLevel = false s.topLevel = false
s.multiple = false s.multiple = false
renderTTYSyntaxItem(w, s) renderTTYSyntaxItem(w, s)
w.write("...") w.write("...")
} }
func renderTTYRequired(w *writer, s SyntaxItem) { func renderTTYRequired(w writer, s SyntaxItem) {
s.delimited = true s.delimited = true
s.topLevel = false s.topLevel = false
s.required = false s.required = false
@ -342,7 +312,7 @@ func renderTTYRequired(w *writer, s SyntaxItem) {
w.write(">") w.write(">")
} }
func renderTTYOptional(w *writer, s SyntaxItem) { func renderTTYOptional(w writer, s SyntaxItem) {
s.delimited = true s.delimited = true
s.topLevel = false s.topLevel = false
s.optional = false s.optional = false
@ -351,7 +321,7 @@ func renderTTYOptional(w *writer, s SyntaxItem) {
w.write("]") w.write("]")
} }
func renderTTYSequence(w *writer, s SyntaxItem) { func renderTTYSequence(w writer, s SyntaxItem) {
if !s.delimited && !s.topLevel { if !s.delimited && !s.topLevel {
w.write("(") w.write("(")
} }
@ -370,7 +340,7 @@ func renderTTYSequence(w *writer, s SyntaxItem) {
} }
} }
func renderTTYChoice(w *writer, s SyntaxItem) { func renderTTYChoice(w writer, s SyntaxItem) {
if !s.delimited && !s.topLevel { if !s.delimited && !s.topLevel {
w.write("(") w.write("(")
} }
@ -394,11 +364,11 @@ func renderTTYChoice(w *writer, s SyntaxItem) {
} }
} }
func renderTTYSymbol(w *writer, s SyntaxItem) { func renderTTYSymbol(w writer, s SyntaxItem) {
w.write(escapeTeletype(s.symbol)) w.write(escapeTeletype(s.symbol))
} }
func renderTTYSyntaxItem(w *writer, s SyntaxItem) { func renderTTYSyntaxItem(w writer, s SyntaxItem) {
switch { switch {
// foo... // foo...
@ -427,14 +397,14 @@ func renderTTYSyntaxItem(w *writer, s SyntaxItem) {
} }
} }
func renderTTYSyntax(w *writer, e Entry) { func renderTTYSyntax(w writer, e Entry) {
s := e.syntax s := e.syntax
s.topLevel = true s.topLevel = true
renderTTYSyntaxItem(w, s) renderTTYSyntaxItem(w, s)
} }
func renderTeletype(out io.Writer, d Document) error { func renderTeletype(out io.Writer, d Document) error {
w := writer{w: out} w := ttyWriter{w: out}
for i, e := range d.entries { for i, e := range d.entries {
if i > 0 { if i > 0 {
w.write("\n\n") w.write("\n\n")

View File

@ -32,8 +32,8 @@ func TestTeletype(t *testing.T) {
textfmt.Wrap( textfmt.Wrap(
textfmt.Indent( textfmt.Indent(
textfmt.Paragraph(textfmt.Text("Below you can find some test text, with various text items.")), textfmt.Paragraph(textfmt.Text("Below you can find some test text, with various text items.")),
8,
0, 0,
8,
), ),
30, 30,
), ),
@ -47,8 +47,8 @@ func TestTeletype(t *testing.T) {
textfmt.ZeroOrMore(textfmt.Symbol("Entry")), textfmt.ZeroOrMore(textfmt.Symbol("Entry")),
textfmt.Symbol(")"), textfmt.Symbol(")"),
), ),
8,
0, 0,
8,
), ),
textfmt.Title(1, "Entries:"), textfmt.Title(1, "Entries:"),
@ -234,7 +234,7 @@ Entry explanations:
doc := textfmt.Doc( doc := textfmt.Doc(
textfmt.Indent( textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\n on multiple lines.")), 15), textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\n on multiple lines.")), 15),
4, 2,
2, 2,
), ),
) )
@ -257,7 +257,7 @@ Entry explanations:
textfmt.Indent( textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\non multiple lines.")), 15), textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\non multiple lines.")), 15),
2, 2,
2, 0,
), ),
) )
@ -275,7 +275,7 @@ Entry explanations:
doc := textfmt.Doc( doc := textfmt.Doc(
textfmt.Indent( textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\non multiple lines.")), 15), textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\non multiple lines.")), 15),
4, 2,
2, 2,
), ),
) )
@ -294,8 +294,8 @@ Entry explanations:
doc := textfmt.Doc( doc := textfmt.Doc(
textfmt.Indent( textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\non multiple lines.")), 15), textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\non multiple lines.")), 15),
0,
2, 2,
-2,
), ),
) )
@ -365,6 +365,18 @@ Entry explanations:
t.Fatal(b.String()) t.Fatal(b.String())
} }
}) })
t.Run("newline in link", func(t *testing.T) {
var b bytes.Buffer
doc := textfmt.Doc(textfmt.Paragraph(textfmt.Link("a\nlink", "https://sqrndfst.org\n/foo")))
if err := textfmt.Teletype(&b, doc); err != nil {
t.Fatal(err)
}
if b.String() != "a link (https://sqrndfst.org\n/foo)\n" {
t.Fatal(b.String())
}
})
}) })
t.Run("title", func(t *testing.T) { t.Run("title", func(t *testing.T) {
@ -410,7 +422,7 @@ Entry explanations:
t.Run("indent without wrapping", func(t *testing.T) { t.Run("indent without wrapping", func(t *testing.T) {
var b bytes.Buffer var b bytes.Buffer
doc := textfmt.Doc( doc := textfmt.Doc(
textfmt.Indent(textfmt.Paragraph(textfmt.Text("This is a paragraph.")), 4, 0), textfmt.Indent(textfmt.Paragraph(textfmt.Text("This is a paragraph.")), 0, 4),
) )
if err := textfmt.Teletype(&b, doc); err != nil { if err := textfmt.Teletype(&b, doc); err != nil {
@ -427,8 +439,8 @@ Entry explanations:
doc := textfmt.Doc( doc := textfmt.Doc(
textfmt.Indent( textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("This is a paragraph.")), 12), textfmt.Wrap(textfmt.Paragraph(textfmt.Text("This is a paragraph.")), 12),
4,
0, 0,
4,
), ),
) )
@ -446,8 +458,8 @@ Entry explanations:
doc := textfmt.Doc( doc := textfmt.Doc(
textfmt.Indent( textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("This is a paragraph.")), 12), textfmt.Wrap(textfmt.Paragraph(textfmt.Text("This is a paragraph.")), 12),
0,
4, 4,
-4,
), ),
) )
@ -509,8 +521,7 @@ Entry explanations:
another another
item item
- this is a - this is a
third third item
item
` `
if b.String() != expect { if b.String() != expect {
@ -526,8 +537,8 @@ Entry explanations:
textfmt.Item(textfmt.Text("this is another item")), textfmt.Item(textfmt.Text("this is another item")),
textfmt.Item(textfmt.Text("this is a third item")), textfmt.Item(textfmt.Text("this is a third item")),
), ),
4,
0, 0,
4,
), ),
) )
@ -557,8 +568,8 @@ Entry explanations:
), ),
18, 18,
), ),
4,
-2, -2,
6,
), ),
) )
@ -591,8 +602,8 @@ third item
), ),
18, 18,
), ),
0,
2, 2,
-2,
), ),
) )
@ -659,11 +670,9 @@ third item
const expect = `1. this is an const expect = `1. this is an
item item
2. this is 2. this is
another another item
item
3. this is a 3. this is a
third third item
item
` `
if b.String() != expect { if b.String() != expect {
@ -679,8 +688,8 @@ third item
textfmt.Item(textfmt.Text("this is another item")), textfmt.Item(textfmt.Text("this is another item")),
textfmt.Item(textfmt.Text("this is a third item")), textfmt.Item(textfmt.Text("this is a third item")),
), ),
4,
0, 0,
4,
), ),
) )
@ -710,8 +719,8 @@ third item
), ),
21, 21,
), ),
4,
-3, -3,
7,
), ),
) )
@ -744,8 +753,8 @@ third item
), ),
21, 21,
), ),
0,
3, 3,
-3,
), ),
) )
@ -871,8 +880,8 @@ third item
textfmt.Definition(textfmt.Text("green"), textfmt.Text("looks like grass")), textfmt.Definition(textfmt.Text("green"), textfmt.Text("looks like grass")),
textfmt.Definition(textfmt.Text("blue"), textfmt.Text("looks like sky")), textfmt.Definition(textfmt.Text("blue"), textfmt.Text("looks like sky")),
), ),
4,
0, 0,
4,
), ),
) )
@ -902,8 +911,8 @@ third item
), ),
21, 21,
), ),
3, -6,
-9, 9,
), ),
) )
@ -912,7 +921,8 @@ third item
t.Fatal(err) t.Fatal(err)
} }
const expect = ` - red: looks const expect = `
- red: looks
like strawberry like strawberry
- green: looks - green: looks
like grass like grass
@ -920,8 +930,8 @@ third item
like sky like sky
` `
if b.String() != expect { if "\n" + b.String() != expect {
t.Fatal(b.String()) t.Fatal("\n" + b.String())
} }
}) })
@ -936,8 +946,8 @@ third item
), ),
21, 21,
), ),
0,
4, 4,
-4,
), ),
) )
@ -946,7 +956,8 @@ third item
t.Fatal(err) t.Fatal(err)
} }
const expect = `- red: looks like const expect = `
- red: looks like
strawberry strawberry
- green: looks like - green: looks like
grass grass
@ -954,8 +965,8 @@ third item
sky sky
` `
if b.String() != expect { if "\n" + b.String() != expect {
t.Fatal(b.String()) t.Fatal("\n" + b.String())
} }
}) })
}) })
@ -1022,8 +1033,8 @@ third item
textfmt.Definition(textfmt.Text("green"), textfmt.Text("looks like grass")), textfmt.Definition(textfmt.Text("green"), textfmt.Text("looks like grass")),
textfmt.Definition(textfmt.Text("blue"), textfmt.Text("looks like sky")), textfmt.Definition(textfmt.Text("blue"), textfmt.Text("looks like sky")),
), ),
4,
0, 0,
4,
), ),
) )
@ -1053,8 +1064,8 @@ third item
), ),
21, 21,
), ),
3, -6,
-9, 9,
), ),
) )
@ -1063,7 +1074,8 @@ third item
t.Fatal(err) t.Fatal(err)
} }
const expect = ` 1. red: looks const expect = `
1. red: looks
like strawberry like strawberry
2. green: looks 2. green: looks
like grass like grass
@ -1071,8 +1083,8 @@ third item
like sky like sky
` `
if b.String() != expect { if "\n" + b.String() != expect {
t.Fatal(b.String()) t.Fatal("\n" + b.String())
} }
}) })
@ -1087,8 +1099,8 @@ third item
), ),
21, 21,
), ),
0,
4, 4,
-4,
), ),
) )
@ -1097,7 +1109,8 @@ third item
t.Fatal(err) t.Fatal(err)
} }
const expect = `1. red: looks like const expect = `
1. red: looks like
strawberry strawberry
2. green: looks like 2. green: looks like
grass grass
@ -1105,8 +1118,8 @@ third item
sky sky
` `
if b.String() != expect { if "\n" + b.String() != expect {
t.Fatal(b.String()) t.Fatal("\n" + b.String())
} }
}) })
@ -1556,8 +1569,8 @@ Walking through the mixed forests of Brandenburg in early autumn, one notices th
textfmt.Cell(textfmt.Text("and shadow on the forest floor")), textfmt.Cell(textfmt.Text("and shadow on the forest floor")),
), ),
), ),
0,
4, 4,
0,
), ),
) )
@ -1652,8 +1665,8 @@ and silver birch | their canopies creating | and shadow on the
), ),
72, 72,
), ),
0,
4, 4,
0,
), ),
) )
@ -1683,6 +1696,7 @@ and silver birch | their canopies creating | and shadow on the
}) })
t.Run("code", func(t *testing.T) { t.Run("code", func(t *testing.T) {
t.Run("unindented", func(t *testing.T) {
const code = `func() textfmt.Document { const code = `func() textfmt.Document {
return textfmt.Document( return textfmt.Document(
textfmt.Paragraph(textfmt.Text("Hello, world!")), textfmt.Paragraph(textfmt.Text("Hello, world!")),
@ -1699,6 +1713,48 @@ and silver birch | their canopies creating | and shadow on the
} }
}) })
t.Run("indented", func(t *testing.T) {
const code = `func() textfmt.Document {
return textfmt.Document(
textfmt.Paragraph(textfmt.Text("Hello, world!")),
)
}`
var b bytes.Buffer
if err := textfmt.Teletype(&b, textfmt.Doc(textfmt.Indent(textfmt.CodeBlock(code), 4, 0))); err != nil {
t.Fatal(err)
}
const expect = ` func() textfmt.Document {
return textfmt.Document(
textfmt.Paragraph(textfmt.Text("Hello, world!")),
)
}
`
if b.String() != expect {
t.Fatal(b.String())
}
})
t.Run("wrap has no effect", func(t *testing.T) {
const code = `func() textfmt.Document {
return textfmt.Document(
textfmt.Paragraph(textfmt.Text("Hello, world!")),
)
}`
var b bytes.Buffer
if err := textfmt.Teletype(&b, textfmt.Doc(textfmt.Wrap(textfmt.CodeBlock(code), 12))); err != nil {
t.Fatal(err)
}
if b.String() != code+"\n" {
t.Fatal(b.String())
}
})
})
t.Run("syntax", func(t *testing.T) { t.Run("syntax", func(t *testing.T) {
t.Run("symbol", func(t *testing.T) { t.Run("symbol", func(t *testing.T) {
doc := textfmt.Doc(textfmt.Syntax(textfmt.Symbol("foo"))) doc := textfmt.Doc(textfmt.Syntax(textfmt.Symbol("foo")))

51
text.go
View File

@ -1,12 +1,20 @@
package textfmt package textfmt
import "strings" import (
"strings"
"unicode"
)
func timesn(s string, n int) string { func timesn(s string, n int) string {
if n < 0 {
return ""
}
ss := make([]string, n+1) ss := make([]string, n+1)
return strings.Join(ss, s) return strings.Join(ss, s)
} }
// non-negative numbers only
func numDigits(n int) int { func numDigits(n int) int {
if n == 0 { if n == 0 {
return 1 return 1
@ -33,19 +41,26 @@ func maxLength(names []string) int {
} }
func padRight(s string, n int) string { func padRight(s string, n int) string {
if len(s) >= n { if len([]rune(s)) >= n {
return s return s
} }
n -= len([]rune(s)) n -= len([]rune(s))
return s + timesn(" ", n) return s + timesn("\u00a0", n)
}
func trim(s string) string {
return strings.TrimFunc(
s,
func(r rune) bool { return r != '\u00a0' && unicode.IsSpace(r) },
)
} }
func singleLine(text string) string { func singleLine(text string) string {
var l []string var l []string
p := strings.Split(text, "\n") p := strings.Split(text, "\n")
for _, part := range p { for _, part := range p {
part = strings.TrimSpace(part) part = trim(part)
if part == "" { if part == "" {
continue continue
} }
@ -56,19 +71,27 @@ func singleLine(text string) string {
return strings.Join(l, " ") return strings.Join(l, " ")
} }
func writeLines(w *writer, txt string, indentFirst, indent int) { func textToString(t Txt) string {
lines := strings.Split(txt, "\n") if len(t.cat) == 0 && t.link == "" {
for i, l := range lines { return trim(t.text)
if i > 0 {
w.write("\n")
} }
ind := indentFirst if len(t.cat) == 0 && t.text == "" {
if i > 0 { return trim(t.link)
ind = indent
} }
w.write(timesn(" ", ind)) if len(t.cat) == 0 {
w.write(l) return fmt.Sprintf("%s (%s)", t.text, t.link)
} }
b := bytes.NewBuffer(nil)
for i := range t.cat {
if i > 0 {
b.WriteRune(' ')
}
b.WriteString(textToString(t.cat[i]))
}
return singleLine(b.String())
} }

View File

@ -27,7 +27,7 @@ func wrap(text string, width, firstIndent, restIndent int) string {
for _, w := range words { for _, w := range words {
if len(currentLine) == 0 { if len(currentLine) == 0 {
currentLine = []string{w} currentLine = []string{w}
lineLen = len(w) lineLen = len([]rune(w))
continue continue
} }
@ -36,15 +36,15 @@ func wrap(text string, width, firstIndent, restIndent int) string {
maxw = width - firstIndent maxw = width - firstIndent
} }
if lineLen+1+len(w) > maxw { if lineLen+1+len([]rune(w)) > maxw {
lines = append(lines, strings.Join(currentLine, " ")) lines = append(lines, strings.Join(currentLine, " "))
currentLine = []string{w} currentLine = []string{w}
lineLen = len(w) lineLen = len([]rune(w))
continue continue
} }
currentLine = append(currentLine, w) currentLine = append(currentLine, w)
lineLen += 1 + len(w) lineLen += 1 + len([]rune(w))
} }
lines = append(lines, strings.Join(currentLine, " ")) lines = append(lines, strings.Join(currentLine, " "))

104
write.go Normal file
View File

@ -0,0 +1,104 @@
package textfmt
import (
"io"
"fmt"
"strings"
)
type writer interface {
write(...any)
error() error
setErr(error)
}
type ttyWriter struct {
w io.Writer
internal bool
err error
}
type roffWriter struct {
w *bufio.Writer
internal bool
err error
}
func (w *ttyWriter) write(a ...any) {
for _, ai := range a {
if w.err != nil {
return
}
s := fmt.Sprint(ai)
r := []rune(s)
if !w.internal {
for i := range r {
if r[i] == '\u00a0' {
r[i] = ' '
}
}
}
if _, err := w.w.Write([]byte(string(r))); err != nil {
w.err = err
}
}
}
func (w *ttyWriter) error() error {
return w.err
}
func (w *ttyWriter) setErr(err error) {
w.err = err
}
func (w *roffWriter) write(a ...any) {
for _, ai := range a {
if w.err != nil {
return
}
var rr []rune
s := fmt.Sprint(ai)
r := []rune(s)
for i := range r {
if r[i] == '\u00a0' {
rr = append(rr, []rune("\\~")...)
continue
}
rr = append(rr, r[i])
}
if _, err := w.w.Write([]byte(string(rr))); err != nil {
w.err = err
}
}
}
func (w *roffWriter) error() error {
return w.err
}
func (w *roffWriter) setErr(err error) {
w.err = err
}
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)
}
}

View File

@ -1,20 +0,0 @@
package textfmt
import "io"
type writer struct {
w io.Writer
err error
}
func (w *writer) write(s ...string) {
for _, si := range s {
if w.err != nil {
return
}
if _, err := w.w.Write([]byte(si)); err != nil {
w.err = err
}
}
}