diff --git a/lib.go b/lib.go index fdb7148..930cefe 100644 --- a/lib.go +++ b/lib.go @@ -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,12 +105,56 @@ func Cat(t ...Txt) Txt { return Txt{cat: t} } -func Title(level int, text string) Entry { - return Entry{ +func Title(level int, text string, manInfo ...TitleInfo) Entry { + if level != 0 { + return Entry{ + typ: title, + titleLevel: level, + text: Text(text), + } + } + + 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 { @@ -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 } diff --git a/runoff.go b/runoff.go new file mode 100644 index 0000000..b4b5161 --- /dev/null +++ b/runoff.go @@ -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 +} diff --git a/teletype.go b/teletype.go index bbeffde..57b1fc4 100644 --- a/teletype.go +++ b/teletype.go @@ -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, + p := itemToParagraph( + e, + definition.value, + padRight(fmt.Sprintf("- %s:", names[i]), maxNameLength + 3), ) + + renderTTYParagraph(w, p) } } -func renderTTYNumberedDefinitions(w *writer, e Entry) { - const ( - dot = ". " - sep = ": " - ) - - names, values, err := definitionNamesValues(e.definitions) +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") diff --git a/teletype_test.go b/teletype_test.go index f8a8113..0519bea 100644 --- a/teletype_test.go +++ b/teletype_test.go @@ -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,20 +1696,63 @@ and silver birch | their canopies creating | and shadow on the }) t.Run("code", func(t *testing.T) { - const code = `func() textfmt.Document { + t.Run("unindented", 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.CodeBlock(code))); err != nil { - t.Fatal(err) - } + var b bytes.Buffer + if err := textfmt.Teletype(&b, textfmt.Doc(textfmt.CodeBlock(code))); err != nil { + t.Fatal(err) + } - if b.String() != code+"\n" { - t.Fatal(b.String()) - } + if b.String() != code+"\n" { + t.Fatal(b.String()) + } + }) + + 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) { diff --git a/text.go b/text.go index 2cd3932..0e0ed0d 100644 --- a/text.go +++ b/text.go @@ -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") - } - - ind := indentFirst - if i > 0 { - ind = indent - } - - w.write(timesn(" ", ind)) - w.write(l) +func textToString(t Txt) string { + if len(t.cat) == 0 && t.link == "" { + return trim(t.text) } + + if len(t.cat) == 0 && t.text == "" { + return trim(t.link) + } + + 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()) } diff --git a/wrap.go b/wrap.go index 45b2851..9d79292 100644 --- a/wrap.go +++ b/wrap.go @@ -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, " ")) diff --git a/write.go b/write.go new file mode 100644 index 0000000..08136fc --- /dev/null +++ b/write.go @@ -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) + } +} diff --git a/writer.go b/writer.go deleted file mode 100644 index eb94aa9..0000000 --- a/writer.go +++ /dev/null @@ -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 - } - } -}