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
import "io"
import (
"io"
"time"
)
const (
invalid = iota
@ -58,9 +61,22 @@ type Entry struct {
definitions []DefinitionItem
rows []TableRow
syntax SyntaxItem
indentFirst int
indent 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 {
@ -89,7 +105,8 @@ func Cat(t ...Txt) Txt {
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{
typ: title,
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 {
return Entry{typ: paragraph, text: t}
}
@ -191,13 +251,14 @@ func Syntax(items ...SyntaxItem) Entry {
return Entry{typ: syntax, syntax: Sequence(items...)}
}
func Indent(e Entry, first, rest int) Entry {
e.indentFirst, e.indent = first, rest
func Wrap(e Entry, width int) Entry {
e.wrapWidth = width
return e
}
func Wrap(e Entry, width int) Entry {
e.wrapWidth = width
// indentFirst is relative to indent
func Indent(e Entry, indent, indentFirst int) Entry {
e.indent, e.indentFirst = indent, indentFirst
return e
}
@ -209,7 +270,12 @@ func Teletype(out io.Writer, d Document) error {
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
}

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

View File

@ -32,8 +32,8 @@ func TestTeletype(t *testing.T) {
textfmt.Wrap(
textfmt.Indent(
textfmt.Paragraph(textfmt.Text("Below you can find some test text, with various text items.")),
8,
0,
8,
),
30,
),
@ -47,8 +47,8 @@ func TestTeletype(t *testing.T) {
textfmt.ZeroOrMore(textfmt.Symbol("Entry")),
textfmt.Symbol(")"),
),
8,
0,
8,
),
textfmt.Title(1, "Entries:"),
@ -234,7 +234,7 @@ Entry explanations:
doc := textfmt.Doc(
textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\n on multiple lines.")), 15),
4,
2,
2,
),
)
@ -257,7 +257,7 @@ Entry explanations:
textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\non multiple lines.")), 15),
2,
2,
0,
),
)
@ -275,7 +275,7 @@ Entry explanations:
doc := textfmt.Doc(
textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\non multiple lines.")), 15),
4,
2,
2,
),
)
@ -294,8 +294,8 @@ Entry explanations:
doc := textfmt.Doc(
textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("Some sample text...\non multiple lines.")), 15),
0,
2,
-2,
),
)
@ -365,6 +365,18 @@ Entry explanations:
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) {
@ -410,7 +422,7 @@ Entry explanations:
t.Run("indent without wrapping", func(t *testing.T) {
var b bytes.Buffer
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 {
@ -427,8 +439,8 @@ Entry explanations:
doc := textfmt.Doc(
textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("This is a paragraph.")), 12),
4,
0,
4,
),
)
@ -446,8 +458,8 @@ Entry explanations:
doc := textfmt.Doc(
textfmt.Indent(
textfmt.Wrap(textfmt.Paragraph(textfmt.Text("This is a paragraph.")), 12),
0,
4,
-4,
),
)
@ -509,8 +521,7 @@ Entry explanations:
another
item
- this is a
third
item
third item
`
if b.String() != expect {
@ -526,8 +537,8 @@ Entry explanations:
textfmt.Item(textfmt.Text("this is another item")),
textfmt.Item(textfmt.Text("this is a third item")),
),
4,
0,
4,
),
)
@ -557,8 +568,8 @@ Entry explanations:
),
18,
),
4,
-2,
6,
),
)
@ -591,8 +602,8 @@ third item
),
18,
),
0,
2,
-2,
),
)
@ -659,11 +670,9 @@ third item
const expect = `1. this is an
item
2. this is
another
item
another item
3. this is a
third
item
third item
`
if b.String() != expect {
@ -679,8 +688,8 @@ third item
textfmt.Item(textfmt.Text("this is another item")),
textfmt.Item(textfmt.Text("this is a third item")),
),
4,
0,
4,
),
)
@ -710,8 +719,8 @@ third item
),
21,
),
4,
-3,
7,
),
)
@ -744,8 +753,8 @@ third item
),
21,
),
0,
3,
-3,
),
)
@ -871,8 +880,8 @@ third item
textfmt.Definition(textfmt.Text("green"), textfmt.Text("looks like grass")),
textfmt.Definition(textfmt.Text("blue"), textfmt.Text("looks like sky")),
),
4,
0,
4,
),
)
@ -902,8 +911,8 @@ third item
),
21,
),
3,
-9,
-6,
9,
),
)
@ -912,7 +921,8 @@ third item
t.Fatal(err)
}
const expect = ` - red: looks
const expect = `
- red: looks
like strawberry
- green: looks
like grass
@ -920,8 +930,8 @@ third item
like sky
`
if b.String() != expect {
t.Fatal(b.String())
if "\n" + b.String() != expect {
t.Fatal("\n" + b.String())
}
})
@ -936,8 +946,8 @@ third item
),
21,
),
0,
4,
-4,
),
)
@ -946,7 +956,8 @@ third item
t.Fatal(err)
}
const expect = `- red: looks like
const expect = `
- red: looks like
strawberry
- green: looks like
grass
@ -954,8 +965,8 @@ third item
sky
`
if b.String() != expect {
t.Fatal(b.String())
if "\n" + b.String() != expect {
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("blue"), textfmt.Text("looks like sky")),
),
4,
0,
4,
),
)
@ -1053,8 +1064,8 @@ third item
),
21,
),
3,
-9,
-6,
9,
),
)
@ -1063,7 +1074,8 @@ third item
t.Fatal(err)
}
const expect = ` 1. red: looks
const expect = `
1. red: looks
like strawberry
2. green: looks
like grass
@ -1071,8 +1083,8 @@ third item
like sky
`
if b.String() != expect {
t.Fatal(b.String())
if "\n" + b.String() != expect {
t.Fatal("\n" + b.String())
}
})
@ -1087,8 +1099,8 @@ third item
),
21,
),
0,
4,
-4,
),
)
@ -1097,7 +1109,8 @@ third item
t.Fatal(err)
}
const expect = `1. red: looks like
const expect = `
1. red: looks like
strawberry
2. green: looks like
grass
@ -1105,8 +1118,8 @@ third item
sky
`
if b.String() != expect {
t.Fatal(b.String())
if "\n" + b.String() != expect {
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")),
),
),
0,
4,
0,
),
)
@ -1652,8 +1665,8 @@ and silver birch | their canopies creating | and shadow on the
),
72,
),
0,
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("unindented", func(t *testing.T) {
const code = `func() textfmt.Document {
return textfmt.Document(
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("symbol", func(t *testing.T) {
doc := textfmt.Doc(textfmt.Syntax(textfmt.Symbol("foo")))

51
text.go
View File

@ -1,12 +1,20 @@
package textfmt
import "strings"
import (
"strings"
"unicode"
)
func timesn(s string, n int) string {
if n < 0 {
return ""
}
ss := make([]string, n+1)
return strings.Join(ss, s)
}
// non-negative numbers only
func numDigits(n int) int {
if n == 0 {
return 1
@ -33,19 +41,26 @@ func maxLength(names []string) int {
}
func padRight(s string, n int) string {
if len(s) >= n {
if len([]rune(s)) >= n {
return 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 {
var l []string
p := strings.Split(text, "\n")
for _, part := range p {
part = strings.TrimSpace(part)
part = trim(part)
if part == "" {
continue
}
@ -56,19 +71,27 @@ func singleLine(text string) string {
return strings.Join(l, " ")
}
func writeLines(w *writer, txt string, indentFirst, indent int) {
lines := strings.Split(txt, "\n")
for i, l := range lines {
if i > 0 {
w.write("\n")
func textToString(t Txt) string {
if len(t.cat) == 0 && t.link == "" {
return trim(t.text)
}
ind := indentFirst
if i > 0 {
ind = indent
if len(t.cat) == 0 && t.text == "" {
return trim(t.link)
}
w.write(timesn(" ", ind))
w.write(l)
if len(t.cat) == 0 {
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 {
if len(currentLine) == 0 {
currentLine = []string{w}
lineLen = len(w)
lineLen = len([]rune(w))
continue
}
@ -36,15 +36,15 @@ func wrap(text string, width, firstIndent, restIndent int) string {
maxw = width - firstIndent
}
if lineLen+1+len(w) > maxw {
if lineLen+1+len([]rune(w)) > maxw {
lines = append(lines, strings.Join(currentLine, " "))
currentLine = []string{w}
lineLen = len(w)
lineLen = len([]rune(w))
continue
}
currentLine = append(currentLine, w)
lineLen += 1 + len(w)
lineLen += 1 + len([]rune(w))
}
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
}
}
}