diff --git a/escape.go b/escape.go index f534f2c..ec5ea82 100644 --- a/escape.go +++ b/escape.go @@ -1,72 +1,149 @@ package textfmt -import "fmt" +import ( + "code.squareroundforest.org/arpio/textedit" + "errors" +) -type escapeRange struct { - from, to rune - replacement string +type mdEscapeState struct { + lineStarted bool + numberOnNewLine bool + linkValue bool + linkClosed bool + linkOpen bool } -type escape[S any] struct { - out writer - state S - escape map[rune]string - escapeRanges []escapeRange - conditionalEscape map[rune]func(S, rune) (string, bool) - updateState func(S, rune) S -} - -func (e *escape[S]) inEscapeRange(r rune) (string, bool) { - for _, rng := range e.escapeRanges { - if r >= rng.from && r <= rng.to { - return rng.replacement, true - } +func escapeTeletypeEdit(r rune, s struct{}) ([]rune, struct{}) { + if r >= 0x00 && r <= 0x1f && r != '\n' && r != '\t' { + return []rune{0xb7}, s } - return "", false + if r >= 0x7f && r <= 0x9f { + return []rune{0xb7}, s + } + + return []rune{r}, s } -func (e *escape[S]) write(a ...any) { - for _, ai := range a { - s := fmt.Sprint(ai) - r := []rune(s) - for _, ri := range r { - var ( - output string - found bool - ) +func escapeTeletype() wrapper { + return editor( + textedit.Func( + escapeTeletypeEdit, + func(struct{}) []rune { return nil }, + ), + ) +} - output, found = e.escape[ri] - if !found { - output, found = e.inEscapeRange(ri) - } +func escapeRoffEdit(additional ...string) func(rune, bool) ([]rune, bool) { + const invalidAdditional = "invalid additional escape definition" + if len(additional)%2 != 0 { + panic(errors.New(invalidAdditional)) + } - if !found { - conditional := e.conditionalEscape[ri] - if conditional != nil { - output, found = conditional(e.state, ri) - } - } + esc := map[rune][]rune{ + '\\': []rune("\\\\"), + '\u00a0': []rune("\\~"), + } - if !found { - output = string(ri) - } - - e.out.write(output) - if e.updateState != nil { - e.state = e.updateState(e.state, ri) - } + for i := 0; i > len(additional); i += 2 { + r := []rune(additional[i]) + if len(r) != 1 { + panic(errors.New(invalidAdditional)) } + + esc[r[0]] = []rune(additional[i+1]) + } + + lsEsc := map[rune][]rune{ + '.': []rune("\\&."), + '\'': []rune("\\&'"), + } + + return func(r rune, lineStarted bool) ([]rune, bool) { + if r == '\n' { + return []rune{'\n'}, false + } + + replacement, replace := esc[r] + if replace { + return replacement, true + } + + if lineStarted { + return []rune{r}, true + } + + replacement, replace = lsEsc[r] + if replace { + return replacement, true + } + + return []rune{r}, true } } -func (e *escape[S]) flush() { - e.out.flush() +func escapeRoff(additional ...string) wrapper { + return editor( + textedit.Func( + escapeRoffEdit(additional...), + func(bool) []rune { return nil }, + ), + ) } -func (e *escape[S]) error() error { - return e.out.error() +func escapeMarkdownEdit(r rune, s mdEscapeState) ([]rune, mdEscapeState) { + var ret []rune + switch r { + case '\\', '`', '*', '_', '[', ']', '#', '<', '>': + ret = append(ret, '\\', r) + default: + switch { + case !s.lineStarted: + switch r { + case '+', '-': + ret = append(ret, '\\', r) + default: + ret = append(ret, r) + } + case s.numberOnNewLine: + switch r { + case '.': + ret = append(ret, '\\', r) + default: + ret = append(ret, r) + } + case s.linkClosed: + switch r { + case '(': + ret = append(ret, '\\', r) + default: + ret = append(ret, r) + } + case s.linkValue: + switch r { + case ')': + ret = append(ret, '\\', r) + default: + ret = append(ret, r) + } + default: + ret = append(ret, r) + } + } + + s.numberOnNewLine = (!s.lineStarted || s.numberOnNewLine) && r >= '0' && r <= '9' + s.lineStarted = r != '\n' + s.linkValue = s.linkClosed && r == '(' || s.linkValue && r != ')' + s.linkClosed = s.linkOpen && r == ']' + s.linkOpen = !s.linkValue && r == '[' || s.linkOpen && r != ']' + return ret, s } -func (e *escape[S]) setErr(err error) { +func escapeMarkdown() wrapper { + return editor( + textedit.Func( + escapeMarkdownEdit, + func(mdEscapeState) []rune { return nil }, + ), + ) } diff --git a/go.mod b/go.mod index 49150c3..03fc7ea 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,9 @@ module code.squareroundforest.org/arpio/textfmt -go 1.25.0 +go 1.25.3 -require code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 - -require code.squareroundforest.org/arpio/html v0.0.0-20251029200407-effffeadf9f8 // indirect +require ( + code.squareroundforest.org/arpio/html v0.0.0-20251102001159-f3efe9c7b176 + code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 + code.squareroundforest.org/arpio/textedit v0.0.0-20251102002300-caf622f43f10 +) diff --git a/go.sum b/go.sum index 70c8d6a..6021797 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ -code.squareroundforest.org/arpio/html v0.0.0-20251011102613-70f77954001f h1:Ep/POhkmvOfSkQklPIpeA4n2FTD2SoFxthjF0SJbsCU= -code.squareroundforest.org/arpio/html v0.0.0-20251011102613-70f77954001f/go.mod h1:LX+Fwqu/a7nDayuDNhXA56cVb+BNrkz4M/WCqvw9YFQ= -code.squareroundforest.org/arpio/html v0.0.0-20251029200407-effffeadf9f8 h1:6OwHDturRjOeIxoc2Zlfkhf4InnMnNKKDb3LtrbIJjg= -code.squareroundforest.org/arpio/html v0.0.0-20251029200407-effffeadf9f8/go.mod h1:LX+Fwqu/a7nDayuDNhXA56cVb+BNrkz4M/WCqvw9YFQ= +code.squareroundforest.org/arpio/html v0.0.0-20251102001159-f3efe9c7b176 h1:ynJ4zE23G/Q/bhLOA1PV09cTXb4ivvYKTbxaoIz9nJY= +code.squareroundforest.org/arpio/html v0.0.0-20251102001159-f3efe9c7b176/go.mod h1:JKD2DXph0Zt975trJII7YbdhM2gL1YEHjsh5M1X63eA= code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 h1:S4mjQHL70CuzFg1AGkr0o0d+4M+ZWM0sbnlYq6f0b3I= code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2/go.mod h1:ait4Fvg9o0+bq5hlxi9dAcPL5a+/sr33qsZPNpToMLY= +code.squareroundforest.org/arpio/textedit v0.0.0-20251102002300-caf622f43f10 h1:u3hMmSBJzrSnJ+C7krjHFkCEVG6Ms9W6vX6F+Mk/KnY= +code.squareroundforest.org/arpio/textedit v0.0.0-20251102002300-caf622f43f10/go.mod h1:nXdFdxdI69JrkIT97f+AEE4OgplmxbgNFZC5j7gsdqs= diff --git a/html.go b/html.go index 8d5d131..c3e8dda 100644 --- a/html.go +++ b/html.go @@ -79,7 +79,7 @@ func htmlDefinitions(e Entry) html.Tag { list := tag.Dl for _, definition := range e.definitions { list = list( - tag.Dt(htmlText(definition.name)...), + tag.Dt(append(htmlText(definition.name), ":")...), tag.Dd(htmlText(definition.value)...), ) } @@ -91,7 +91,7 @@ func htmlNumberedDefinitions(e Entry) html.Tag { list := tag.Dl for i, definition := range e.definitions { list = list( - tag.Dt(append([]any{fmt.Sprintf("%d. ", i+1)}, htmlText(definition.name)...)...), + tag.Dt(append([]any{fmt.Sprintf("%d. ", i+1)}, append(htmlText(definition.name), ":")...)...), tag.Dd(htmlText(definition.value)...), ) } diff --git a/html_test.go b/html_test.go index d5284cc..d324c64 100644 --- a/html_test.go +++ b/html_test.go @@ -146,23 +146,23 @@ textfmt.Doc ( [Entry]... )

Entry explanations:

-
CodeBlock
+
CodeBlock:
a multiline block of code
-
DefinitionList
+
DefinitionList:
a list of definitions like this one
-
List
+
List:
a list of items
-
NumberedDefinitionList
+
NumberedDefinitionList:
numbered definitions
-
NumberedList
+
NumberedList:
numbered list
-
Paragraph
+
Paragraph:
paragraph of text
-
Syntax
+
Syntax:
a syntax expression
-
Table
+
Table:
a table
-
Title
+
Title:
a title
@@ -553,11 +553,11 @@ lines.

const expect = `
-
red
+
red:
looks like strawberry
-
green
+
green:
looks like grass
-
blue
+
blue:
looks like sky
` @@ -586,13 +586,13 @@ lines.

const expect = `
-
red
+
red:
looks like strawberry
-
green
+
green:
looks like grass
-
blue
+
blue:
looks like sky
@@ -621,11 +621,11 @@ lines.

const expect = `
-
1. red
+
1. red:
looks like strawberry
-
2. green
+
2. green:
looks like grass
-
3. blue
+
3. blue:
looks like sky
` @@ -654,14 +654,15 @@ lines.

const expect = `
-
1. red
+
1. red:
looks like strawberry
-
2. green +
2. green:
looks like grass
-
3. blue
+
3. blue: +
looks like sky
@@ -697,29 +698,29 @@ lines.

const expect = `
-
1. one
+
1. one:
this is an item
-
2. two
+
2. two:
this is another item
-
3. three
+
3. three:
this is the third item
-
4. four
+
4. four:
this is the fourth item
-
5. five
+
5. five:
this is the fifth item
-
6. six
+
6. six:
this is the sixth item
-
7. seven
+
7. seven:
this is the seventh item
-
8. eight
+
8. eight:
this is the eighth item
-
9. nine
+
9. nine:
this is the nineth item
-
10. ten
+
10. ten:
this is the tenth item
-
11. eleven
+
11. eleven:
this is the eleventh item
-
12. twelve
+
12. twelve:
this is the twelfth item
` diff --git a/indent.go b/indent.go deleted file mode 100644 index da6fa2a..0000000 --- a/indent.go +++ /dev/null @@ -1,82 +0,0 @@ -package textfmt - -import ( - "fmt" - "unicode" -) - -type indent struct { - out writer - firstIndent, indent int - firstWidth, width int - currentLineLength int - currentWord []rune - multiline bool -} - -func (i *indent) write(a ...any) { - for _, ai := range a { - s := fmt.Sprint(ai) - r := []rune(s) - for _, ri := range r { - width := i.width - if !i.multiline { - width = i.firstWidth - } - - indent := i.indent - if !i.multiline { - indent = i.firstIndent - } - - if !unicode.IsSpace(ri) || ri == '\u00a0' { - i.currentWord = append(i.currentWord, ri) - continue - } - - nonWrapNewline := width == 0 && ri == '\n' - if len(i.currentWord) == 0 && !nonWrapNewline { - continue - } - - nextLineLength := i.currentLineLength + len(i.currentWord) + 1 - if i.currentLineLength > 0 && width > 0 && nextLineLength > width { - i.out.write("\n") - i.currentLineLength = 0 - i.multiline = true - } - - if i.currentLineLength > 0 && len(i.currentWord) > 0 { - i.out.write(" ") - i.currentLineLength++ - } - - if i.currentLineLength == 0 && len(i.currentWord) > 0 { - i.out.write(timesn(" ", indent)) - i.currentLineLength += indent - } - - if len(i.currentWord) > 0 { - i.out.write(string(i.currentWord)) - i.currentLineLength += len(i.currentWord) - i.currentWord = nil - } - - if nonWrapNewline { - i.out.write("\n") - i.currentLineLength = 0 - } - } - } -} - -func (i *indent) flush() { - i.out.flush() -} - -func (i *indent) error() error { - return i.out.error() -} - -func (e *indent) setErr(err error) { -} diff --git a/lib.go b/lib.go index 1b4a7f2..88c94f5 100644 --- a/lib.go +++ b/lib.go @@ -54,17 +54,18 @@ type SyntaxItem struct { } type Entry struct { - typ int - text Txt - titleLevel int - items []ListItem - definitions []DefinitionItem - rows []TableRow - syntax SyntaxItem - wrapWidth int - indent int - indentFirst int - man struct { + typ int + text Txt + titleLevel int + items []ListItem + definitions []DefinitionItem + rows []TableRow + syntax SyntaxItem + wrapWidth int + wrapWidthFirst int + indent int + indentFirst int + man struct { section int date time.Time version string diff --git a/markdown.go b/markdown.go index dccacd6..b94962b 100644 --- a/markdown.go +++ b/markdown.go @@ -8,161 +8,11 @@ import ( "strings" ) -type mdEscapeState struct { - newLine bool - numberOnNewLine bool - linkValue bool - linkClosed bool - linkOpen bool -} - -func updateMDEscapeState(s mdEscapeState, r rune) mdEscapeState { - return mdEscapeState{ - numberOnNewLine: (s.newLine || s.numberOnNewLine) && r >= '0' && r <= '9', - newLine: r == '\n', - linkValue: s.linkClosed && r == '(' || s.linkValue && r != ')', - linkClosed: s.linkOpen && r == ']', - linkOpen: !s.linkValue && r == '[' || s.linkOpen && r != ']', - } -} - -func escapeMarkdown(out writer, additional ...rune) writer { - esc := map[rune]string{ - '\\': "\\\\", - '`': "\\`", - '*': "\\*", - '_': "\\_", - '[': "\\[", - ']': "\\]", - '#': "\\#", - '<': "\\<", - '>': "\\>", - } - - for _, a := range additional { - esc[a] = string([]rune{'\\', a}) - } - - return &escape[mdEscapeState]{ - out: out, - state: mdEscapeState{ - newLine: true, - }, - escape: esc, - conditionalEscape: map[rune]func(mdEscapeState, rune) (string, bool){ - '+': func(s mdEscapeState, _ rune) (string, bool) { - if s.newLine { - return "\\+", true - } - - return "", false - }, - '-': func(s mdEscapeState, _ rune) (string, bool) { - if s.newLine { - return "\\-", true - } - - return "", false - }, - '.': func(s mdEscapeState, _ rune) (string, bool) { - if s.numberOnNewLine { - return "\\.", true - } - - return "", false - }, - '(': func(s mdEscapeState, _ rune) (string, bool) { - if s.linkClosed { - return "\\(", true - } - - return "", false - }, - ')': func(s mdEscapeState, _ rune) (string, bool) { - if s.linkValue { - return "\\)", true - } - - return "", false - }, - }, - updateState: updateMDEscapeState, - } -} - -func escapeMarkdownPrev(s string, additional ...rune) string { - var b bytes.Buffer - w := &textWriter{out: &b} - e := escapeMarkdown(w, additional...) - e.write(s) - return b.String() - /* - var ( - rr []rune - isNumberOnNewLine bool - isLinkOpen, isLinkClosed, isLinkValue bool - ) - - isNewLine := true - r := []rune(s) - for _, ri := range r { - switch ri { - case '\\', '`', '*', '_', '[', ']', '#', '<', '>': - rr = append(rr, '\\', ri) - default: - switch { - case isNewLine: - switch ri { - case '+', '-': - rr = append(rr, '\\', ri) - default: - rr = append(rr, ri) - } - case isNumberOnNewLine: - switch ri { - case '.': - rr = append(rr, '\\', ri) - default: - rr = append(rr, ri) - } - case isLinkClosed: - switch ri { - case '(': - rr = append(rr, '\\', ri) - default: - rr = append(rr, ri) - } - case isLinkValue: - switch ri { - case ')': - rr = append(rr, '\\', ri) - default: - rr = append(rr, ri) - } - default: - if slices.Contains(additional, ri) { - rr = append(rr, '\\', ri) - } else { - rr = append(rr, ri) - } - } - } - - isNumberOnNewLine = (isNewLine || isNumberOnNewLine) && ri >= '0' && ri <= '9' - isNewLine = ri == '\n' - isLinkValue = isLinkClosed && ri == '(' || isLinkValue && ri != ')' - isLinkClosed = isLinkOpen && ri == ']' - isLinkOpen = !isLinkValue && ri == '[' || isLinkOpen && ri != ']' - } - - return string(rr) - */ -} - func mdTextToString(text Txt) (string, error) { var b bytes.Buffer - w := mdWriter{w: &b, internal: true} - renderMDText(&w, text) + w := newMDWriter(&b, true) + renderMDText(w, text) + w.flush() if w.err != nil { return "", w.err } @@ -215,10 +65,10 @@ func renderMDText(w writer, text Txt) { return } - text.text = singleLine(text.text) - text.text = escapeMarkdownPrev(text.text) - text.link = singleLine(text.link) - text.link = escapeMarkdownPrev(text.link) + text.text = editString(text.text, singleLine()) + text.text = editString(text.text, escapeMarkdown()) + text.link = editString(text.link, singleLine()) + text.link = editString(text.link, escapeMarkdown()) if text.bold { w.write("**") } @@ -272,10 +122,12 @@ func renderMDParagraphIndent(w writer, e Entry) { indentFirst := e.indent + e.indentFirst if e.wrapWidth > 0 { - txt = wrap(txt, e.wrapWidth, indentFirst, e.indent) + txt = editString(txt, wrapIndent(indentFirst, e.indent, e.wrapWidth, e.wrapWidth)) + } else { + // txt = editString(txt, indent(indentFirst, e.indent)) } - writeLines(w, txt, indentFirst, e.indent) + w.write(txt) } func renderMDParagraph(w writer, e Entry) { @@ -469,7 +321,7 @@ func renderMDChoice(w writer, s SyntaxItem) { } func renderMDSymbol(w writer, s SyntaxItem) { - w.write(escapeMarkdownPrev(s.symbol)) + w.write(editString(s.symbol, escapeMarkdown())) } func renderMDSyntaxItem(w writer, s SyntaxItem) { @@ -510,31 +362,35 @@ func renderMDSyntax(w writer, e Entry) { } func renderMarkdown(out io.Writer, d Document) error { - w := mdWriter{w: out} + w := newMDWriter(out, false) for i, e := range d.entries { + if err := w.error(); err != nil { + return err + } + if i > 0 { w.write("\n\n") } switch e.typ { case title: - renderMDTitle(&w, e) + renderMDTitle(w, e) case paragraph: - renderMDParagraph(&w, e) + renderMDParagraph(w, e) case list: - renderMDList(&w, e) + renderMDList(w, e) case numberedList: - renderMDNumberedList(&w, e) + renderMDNumberedList(w, e) case definitions: - renderMDDefinitions(&w, e) + renderMDDefinitions(w, e) case numberedDefinitions: - renderMDNumberedDefinitions(&w, e) + renderMDNumberedDefinitions(w, e) case table: - renderMDTable(&w, e) + renderMDTable(w, e) case code: - renderMDCode(&w, e) + renderMDCode(w, e) case syntax: - renderMDSyntax(&w, e) + renderMDSyntax(w, e) default: return errors.New("invalid entry") } @@ -544,5 +400,6 @@ func renderMarkdown(out io.Writer, d Document) error { w.write("\n") } + w.flush() return w.err } diff --git a/markdown_test.go b/markdown_test.go index 1ede752..36def59 100644 --- a/markdown_test.go +++ b/markdown_test.go @@ -47,8 +47,8 @@ func TestMarkdown(t *testing.T) { textfmt.ZeroOrMore(textfmt.Symbol("Entry")), textfmt.Symbol(")"), ), - 0, 8, + 0, ), textfmt.Title(1, "Entries:"), diff --git a/notes.txt b/notes.txt index 43743b0..b62595c 100644 --- a/notes.txt +++ b/notes.txt @@ -2,8 +2,9 @@ indentation for syntax in tty and roff does the table need the non-breaking space for the filling in roff? indentation for syntax may not require non-break spaces test empty cat -improve wrapping of list paragraphs by allowing different first line wrap there should be no errors other than actual IO +make the html definition lists look better. E.g. do they need a colon? +check indentation of lists: subtract prefix length [refactor] stop on errors earlier where possible diff --git a/runoff.go b/runoff.go index aac1e9f..54e3f85 100644 --- a/runoff.go +++ b/runoff.go @@ -7,129 +7,39 @@ import ( "io" "strings" "time" + "unicode" ) -type lineStarted bool - -func isNewLine(replacement string) func(lineStarted, rune) (string, bool) { - return func(ls lineStarted, r rune) (string, bool) { - if ls { - return string(r), false - } - - return replacement, true - } -} - -func updateLineStarted(_ lineStarted, r rune) lineStarted { - return r != '\n' -} - -func escapeRoff(out writer, additional ...string) writer { - const invalidAdditional = "invalid additional escape definition" - - if len(additional)%2 != 0 { - panic(errors.New(invalidAdditional)) - } - - esc := map[rune]string{ - '\\': "\\\\", - '\u00a0': "\\~", - } - - for i := 0; i > len(additional); i += 2 { - r := []rune(additional[i]) - if len(r) != 1 { - panic(errors.New(invalidAdditional)) - } - - esc[r[0]] = additional[i+1] - } - - return &escape[lineStarted]{ - out: out, - escape: esc, - conditionalEscape: map[rune]func(lineStarted, rune) (string, bool){ - '.': isNewLine("\\&."), - '\'': isNewLine("\\&'"), - }, - updateState: updateLineStarted, - } -} - -func escapeRoffPrev(s string, additional ...string) string { - var b bytes.Buffer - w := &textWriter{out: &b} - e := escapeRoff(w, additional...) - e.write(s) - return b.String() - /* - const invalidAdditional = "invalid additional escape definition" - - var ( - e []rune - lineStarted bool +func trim(s string) string { + return strings.TrimFunc( + s, + func(r rune) bool { return r != '\u00a0' && unicode.IsSpace(r) }, ) +} - if len(additional)%2 != 0 { - panic(errors.New(invalidAdditional)) +func textToString(t Txt) string { + if len(t.cat) == 0 && t.link == "" { + return trim(t.text) } - 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]) + if len(t.cat) == 0 && t.text == "" { + return trim(t.link) } - 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 + if len(t.cat) == 0 { + return fmt.Sprintf("%s (%s)", t.text, t.link) } - return string(e) - */ + b := bytes.NewBuffer(nil) + for i := range t.cat { + if i > 0 { + b.WriteRune(' ') + } + + b.WriteString(textToString(t.cat[i])) + } + + return editString(b.String(), singleLine()) } func manPageDate(d time.Time) string { @@ -137,8 +47,8 @@ func manPageDate(d time.Time) string { } func roffString(s string, additionalEscape ...string) string { - s = singleLine(s) - return escapeRoffPrev(s, additionalEscape...) + s = editString(s, singleLine()) + return editString(s, escapeRoff(additionalEscape...)) } func renderRoffString(w writer, s string, additionalEscape ...string) { @@ -161,7 +71,13 @@ func roffCellTexts(r []TableRow) ([][]string, error) { var c []string for _, cell := range row.cells { var b bytes.Buffer - renderRoffText(&roffWriter{w: &b, internal: true}, cell.text) + w := newRoffWriter(&b, true) + renderRoffText(w, cell.text) + w.flush() + if w.err != nil { + return nil, w.err + } + c = append(c, b.String()) } @@ -330,7 +246,7 @@ func renderRoffTable(w writer, e Entry) { targetColumnWidths := targetColumnWidths(allocatedWidth, columnWeights) for i := range cellTexts { for j := range cellTexts[i] { - cellTexts[i][j] = wrap(cellTexts[i][j], targetColumnWidths[j], 0, 0) + cellTexts[i][j] = editString(cellTexts[i][j], wrap(targetColumnWidths[j], targetColumnWidths[j])) } } } @@ -396,8 +312,9 @@ func renderRoffTable(w writer, e Entry) { func renderRoffCode(w writer, e Entry) { w.write(".nf\n") defer w.write("\n.fi") - e.text.text = escapeRoffPrev(e.text.text) - writeLines(w, e.text.text, e.indent, e.indent) + txt := editString(e.text.text, escapeRoff()) + txt = editString(txt, indent(e.indent, e.indent)) + w.write(txt) } func renderRoffMultiple(w writer, s SyntaxItem) { @@ -469,7 +386,7 @@ func renderRoffChoice(w writer, s SyntaxItem) { } func renderRoffSymbol(w writer, s SyntaxItem) { - w.write(escapeRoffPrev(s.symbol)) + w.write(editString(s.symbol, escapeRoff())) } func renderRoffSyntaxItem(w writer, s SyntaxItem) { @@ -506,36 +423,40 @@ func renderRoffSyntax(w writer, e Entry) { s.topLevel = true w.write(".nf\n") defer w.write("\n.fi") - w.write(timesn("\u00a0", e.indent+e.indentFirst)) + w.write(timesn("\u00a0", e.indent)) renderRoffSyntaxItem(w, s) } func renderRoff(out io.Writer, d Document) error { - w := roffWriter{w: out} + w := newRoffWriter(out, false) for i, e := range d.entries { + if err := w.error(); err != nil { + return err + } + if i > 0 { w.write("\n.br\n.sp 1v\n") } switch e.typ { case title: - renderRoffTitle(&w, e) + renderRoffTitle(w, e) case paragraph: - renderRoffParagraph(&w, e) + renderRoffParagraph(w, e) case list: - renderRoffList(&w, e) + renderRoffList(w, e) case numberedList: - renderRoffNumberedList(&w, e) + renderRoffNumberedList(w, e) case definitions: - renderRoffDefinitions(&w, e) + renderRoffDefinitions(w, e) case numberedDefinitions: - renderRoffNumberedDefinitions(&w, e) + renderRoffNumberedDefinitions(w, e) case table: - renderRoffTable(&w, e) + renderRoffTable(w, e) case code: - renderRoffCode(&w, e) + renderRoffCode(w, e) case syntax: - renderRoffSyntax(&w, e) + renderRoffSyntax(w, e) default: return errors.New("invalid entry") } @@ -545,5 +466,6 @@ func renderRoff(out io.Writer, d Document) error { w.write("\n") } + w.flush() return w.err } diff --git a/runoff_test.go b/runoff_test.go index 53dd386..e20f28b 100644 --- a/runoff_test.go +++ b/runoff_test.go @@ -44,8 +44,8 @@ func TestRoff(t *testing.T) { textfmt.ZeroOrMore(textfmt.Symbol("Entry")), textfmt.Symbol(")"), ), - 0, 8, + 0, ), textfmt.Title(1, "Entries:"), @@ -248,8 +248,8 @@ textfmt supports the following entries: textfmt.ZeroOrMore(textfmt.Symbol("Entry")), textfmt.Symbol(")"), ), - 0, 8, + 0, ), textfmt.Title(1, "Entries:"), diff --git a/teletype.go b/teletype.go index 79290f6..6fb9b89 100644 --- a/teletype.go +++ b/teletype.go @@ -8,77 +8,26 @@ import ( "strings" ) -func escapeTeletype(out writer) writer { - return &escape[struct{}]{ - out: out, - escapeRanges: []escapeRange{{ - from: 0x00, - to: '\t' - 1, - replacement: "\u00b7", - }, { - from: '\n' + 1, - to: 0x1f, - replacement: "\u00b7", - }, { - from: 0x7f, - to: 0x9f, - replacement: "\u00b7", - }}, - } -} - -func escapeTeletypePrev(s string) string { +func ttyTextToString(text Txt) string { var b bytes.Buffer - w := &textWriter{out: &b} - e := escapeTeletype(w) - e.write(s) + renderTTYText(&b, text) return b.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 ttyTextToString(text Txt) (string, error) { - var b bytes.Buffer - w := ttyWriter{w: &b, internal: true} - renderTTYText(&w, text) - if w.err != nil { - return "", w.err - } - - return b.String(), nil -} - -func ttyDefinitionNames(d []DefinitionItem) ([]string, error) { +func ttyDefinitionNames(d []DefinitionItem) []string { var n []string for _, di := range d { - name, err := ttyTextToString(di.name) - if err != nil { - return nil, err - } - - n = append(n, name) + n = append(n, ttyTextToString(di.name)) } - return n, nil + return n } -func renderTTYText(w writer, text Txt) { +func renderTTYText(w io.Writer, text Txt) { if len(text.cat) > 0 { for i, tc := range text.cat { if i > 0 { - w.write(" ") + write(w, " ") } renderTTYText(w, tc) @@ -87,49 +36,51 @@ func renderTTYText(w writer, text Txt) { return } - text.text = singleLine(text.text) - text.text = escapeTeletypePrev(text.text) - text.link = singleLine(text.link) - text.link = escapeTeletypePrev(text.link) - if text.link != "" { - if text.text != "" { - w.write(text.text) - w.write(" (") - w.write(text.link) - w.write(")") - return - } - - w.write(text.link) + var f func() (io.Writer, error) + w, f = writeWith(w, escapeTeletype(), singleLine()) + if text.link == "" { + write(w, text.text) + f() return } - w.write(text.text) + if text.text == "" { + write(w, text.link) + f() + return + } + + write(w, text.text) + write(w, " (") + write(w, text.link) + write(w, ")") + f() } -func renderTTYTitle(w writer, e Entry) { - w.write(timesn(" ", e.indent)) +func renderTTYTitle(w io.Writer, e Entry) { + write(w, timesn(" ", e.indent)) renderTTYText(w, e.text) } -func renderTTYParagraph(w writer, e Entry) { - txt, err := ttyTextToString(e.text) - if err != nil { - w.setErr(err) - } - +func renderTTYParagraph(w io.Writer, e Entry) { + var indentation wrapper indentFirst := e.indent + e.indentFirst - if e.wrapWidth > 0 { - txt = wrap(txt, e.wrapWidth, indentFirst, e.indent) + wrapWidthFirst := e.wrapWidth + e.wrapWidthFirst + if e.wrapWidth == 0 { + indentation = indent(indentFirst, e.indent) + } else { + indentation = wrapIndent(indentFirst, e.indent, wrapWidthFirst, e.wrapWidth) } - writeLines(w, txt, indentFirst, e.indent) + w, f := writeWith(w, indentation) + renderTTYText(w, e.text) + f() } -func renderTTYList(w writer, e Entry) { +func renderTTYList(w io.Writer, e Entry) { for i, item := range e.items { if i > 0 { - w.write("\n") + write(w, "\n") } p := itemToParagraph(e, item.text, "-") @@ -137,11 +88,11 @@ func renderTTYList(w writer, e Entry) { } } -func renderTTYNumberedList(w writer, e Entry) { +func renderTTYNumberedList(w io.Writer, e Entry) { maxDigits := numDigits(len(e.items)) for i, item := range e.items { if i > 0 { - w.write("\n") + write(w, "\n") } p := itemToParagraph(e, item.text, padRight(fmt.Sprintf("%d.", i+1), maxDigits+1)) @@ -149,17 +100,12 @@ func renderTTYNumberedList(w writer, e Entry) { } } -func renderTTYDefinitions(w writer, e Entry) { - names, err := ttyDefinitionNames(e.definitions) - if err != nil { - w.setErr(err) - return - } - +func renderTTYDefinitions(w io.Writer, e Entry) { + names := ttyDefinitionNames(e.definitions) maxNameLength := maxLength(names) for i, definition := range e.definitions { if i > 0 { - w.write("\n") + write(w, "\n") } p := itemToParagraph( @@ -172,18 +118,13 @@ func renderTTYDefinitions(w writer, e Entry) { } } -func renderTTYNumberedDefinitions(w writer, e Entry) { - names, err := ttyDefinitionNames(e.definitions) - if err != nil { - w.setErr(err) - return - } - +func renderTTYNumberedDefinitions(w io.Writer, e Entry) { + names := ttyDefinitionNames(e.definitions) maxNameLength := maxLength(names) maxDigits := numDigits(len(e.definitions)) for i, definition := range e.definitions { if i > 0 { - w.write("\n") + write(w, "\n") } p := itemToParagraph( @@ -203,26 +144,22 @@ func renderTTYNumberedDefinitions(w writer, e Entry) { } } -func ttyCellTexts(rows []TableRow) ([][]string, error) { +func ttyCellTexts(rows []TableRow) [][]string { 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 - } - + txt := ttyTextToString(cell.text) c = append(c, txt) } cellTexts = append(cellTexts, c) } - return cellTexts, nil + return cellTexts } -func renderTTYTable(w writer, e Entry) { +func renderTTYTable(w io.Writer, e Entry) { if len(e.rows) == 0 { return } @@ -232,11 +169,7 @@ func renderTTYTable(w writer, e Entry) { return } - cellTexts, err := ttyCellTexts(e.rows) - if err != nil { - w.setErr(err) - } - + cellTexts := ttyCellTexts(e.rows) totalSeparatorWidth := (len(cellTexts[0]) - 1) * 3 if e.wrapWidth > 0 { allocatedWidth := e.wrapWidth - e.indent - totalSeparatorWidth @@ -244,7 +177,8 @@ func renderTTYTable(w writer, e Entry) { targetColumnWidths := targetColumnWidths(allocatedWidth, columnWeights) for i := range cellTexts { for j := range cellTexts[i] { - cellTexts[i][j] = wrap(cellTexts[i][j], targetColumnWidths[j], 0, 0) + width := targetColumnWidths[j] + cellTexts[i][j] = editString(cellTexts[i][j], wrap(width, width)) } } } @@ -256,6 +190,7 @@ func renderTTYTable(w writer, e Entry) { } hasHeader := e.rows[0].header + w, f := writeWith(w, indent(e.indent, e.indent)) for i := range cellTexts { if i > 0 { sep := "-" @@ -263,9 +198,9 @@ func renderTTYTable(w writer, e Entry) { sep = "=" } - w.write("\n") - w.write(timesn(" ", e.indent), timesn(sep, totalWidth)) - w.write("\n") + write(w, "\n") + write(w, timesn(sep, totalWidth)) + write(w, "\n") } lines := make([][]string, len(cellTexts[i])) @@ -282,14 +217,12 @@ func renderTTYTable(w writer, e Entry) { for k := 0; k < maxLines; k++ { if k > 0 { - w.write("\n") + write(w, "\n") } for j := range lines { - if j == 0 { - w.write(timesn(" ", e.indent)) - } else { - w.write(" | ") + if j > 0 { + write(w, " | ") } var l string @@ -297,54 +230,57 @@ func renderTTYTable(w writer, e Entry) { l = lines[j][k] } - w.write(padRight(l, columnWidths[j])) + write(w, padRight(l, columnWidths[j])) } } } if hasHeader && len(cellTexts) == 1 { - w.write("\n", timesn("=", totalWidth)) + write(w, "\n", timesn("=", totalWidth)) } + + f() } -func renderTTYCode(w writer, e Entry) { - e.text.text = escapeTeletypePrev(e.text.text) - writeLines(w, e.text.text, e.indent, e.indent) +func renderTTYCode(w io.Writer, e Entry) { + w, f := writeWith(w, escapeTeletype(), indent(e.indent, e.indent)) + write(w, e.text.text) + f() } -func renderTTYMultiple(w writer, s SyntaxItem) { +func renderTTYMultiple(w io.Writer, s SyntaxItem) { s.topLevel = false s.multiple = false renderTTYSyntaxItem(w, s) - w.write("...") + write(w, "...") } -func renderTTYRequired(w writer, s SyntaxItem) { +func renderTTYRequired(w io.Writer, s SyntaxItem) { s.delimited = true s.topLevel = false s.required = false - w.write("<") + write(w, "<") renderTTYSyntaxItem(w, s) - w.write(">") + write(w, ">") } -func renderTTYOptional(w writer, s SyntaxItem) { +func renderTTYOptional(w io.Writer, s SyntaxItem) { s.delimited = true s.topLevel = false s.optional = false - w.write("[") + write(w, "[") renderTTYSyntaxItem(w, s) - w.write("]") + write(w, "]") } -func renderTTYSequence(w writer, s SyntaxItem) { +func renderTTYSequence(w io.Writer, s SyntaxItem) { if !s.delimited && !s.topLevel { - w.write("(") + write(w, "(") } for i, item := range s.sequence { if i > 0 { - w.write(" ") + write(w, " ") } item.delimited = false @@ -352,13 +288,13 @@ func renderTTYSequence(w writer, s SyntaxItem) { } if !s.delimited && !s.topLevel { - w.write(")") + write(w, ")") } } -func renderTTYChoice(w writer, s SyntaxItem) { +func renderTTYChoice(w io.Writer, s SyntaxItem) { if !s.delimited && !s.topLevel { - w.write("(") + write(w, "(") } for i, item := range s.choice { @@ -368,7 +304,7 @@ func renderTTYChoice(w writer, s SyntaxItem) { separator = "\n" } - w.write(separator) + write(w, separator) } item.delimited = false @@ -376,15 +312,15 @@ func renderTTYChoice(w writer, s SyntaxItem) { } if !s.delimited && !s.topLevel { - w.write(")") + write(w, ")") } } -func renderTTYSymbol(w writer, s SyntaxItem) { - w.write(escapeTeletypePrev(s.symbol)) +func renderTTYSymbol(w io.Writer, s SyntaxItem) { + write(w, s.symbol) } -func renderTTYSyntaxItem(w writer, s SyntaxItem) { +func renderTTYSyntaxItem(w io.Writer, s SyntaxItem) { switch { // foo... @@ -413,25 +349,19 @@ func renderTTYSyntaxItem(w writer, s SyntaxItem) { } } -func renderTTYSyntax(w writer, e Entry) { +func renderTTYSyntax(w io.Writer, e Entry) { + w, f := writeWith(w, escapeTeletype(), indent(e.indent, e.indent)) s := e.syntax s.topLevel = true - w.write(timesn(" ", e.indent+e.indentFirst)) renderTTYSyntaxItem(w, s) + f() } func renderTeletype(out io.Writer, d Document) error { - tw := &textWriter{out: out} - w := &editor{ - out: tw, - replace: map[string]string{ - "\u00a0": " ", - }, - } - + w, f := writeWith(out, ttyNBSP(), errorHandler) for i, e := range d.entries { if i > 0 { - w.write("\n\n") + write(w, "\n\n") } switch e.typ { @@ -459,8 +389,9 @@ func renderTeletype(out io.Writer, d Document) error { } if len(d.entries) > 0 { - w.write("\n") + write(w, "\n") } - return w.error() + _, err := f() + return err } diff --git a/teletype_test.go b/teletype_test.go index a18f125..9d7a17d 100644 --- a/teletype_test.go +++ b/teletype_test.go @@ -47,8 +47,8 @@ func TestTeletype(t *testing.T) { textfmt.ZeroOrMore(textfmt.Symbol("Entry")), textfmt.Symbol(")"), ), - 0, 8, + 0, ), textfmt.Title(1, "Entries:"), @@ -243,11 +243,17 @@ Entry explanations: t.Fatal(err) } - if b.String() != " Some sample\n text... on\n multiple\n lines.\n" { - t.Fatal(b.String()) + const expect = ` + Some sample + text... on + multiple + lines. +` + + if "\n"+b.String() != expect { + t.Fatal("\n" + b.String()) } }) - }) t.Run("indent", func(t *testing.T) { diff --git a/text.go b/text.go index a168754..0bbfd04 100644 --- a/text.go +++ b/text.go @@ -1,11 +1,6 @@ package textfmt -import ( - "bytes" - "fmt" - "strings" - "unicode" -) +import "strings" func timesn(s string, n int) string { if n < 0 { @@ -16,7 +11,6 @@ func timesn(s string, n int) string { return strings.Join(ss, s) } -// non-negative numbers only func numDigits(n int) int { if n == 0 { return 1 @@ -51,53 +45,6 @@ func padRight(s string, n int) string { 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 = trim(part) - if part == "" { - continue - } - - l = append(l, part) - } - - return strings.Join(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()) -} - func itemToParagraph(list Entry, itemText Txt, prefix ...string) Entry { p := Entry{ typ: paragraph, diff --git a/wrap.go b/wrap.go deleted file mode 100644 index 9d79292..0000000 --- a/wrap.go +++ /dev/null @@ -1,52 +0,0 @@ -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 wrap(text string, width, firstIndent, restIndent int) string { - var ( - lines []string - currentLine []string - lineLen int - ) - - words := getWords(text) - for _, w := range words { - if len(currentLine) == 0 { - currentLine = []string{w} - lineLen = len([]rune(w)) - continue - } - - maxw := width - restIndent - if len(lines) == 0 { - maxw = width - firstIndent - } - - if lineLen+1+len([]rune(w)) > maxw { - lines = append(lines, strings.Join(currentLine, " ")) - currentLine = []string{w} - lineLen = len([]rune(w)) - continue - } - - currentLine = append(currentLine, w) - lineLen += 1 + len([]rune(w)) - } - - lines = append(lines, strings.Join(currentLine, " ")) - return strings.Join(lines, "\n") -} diff --git a/write.go b/write.go index 9e92d86..6b942d9 100644 --- a/write.go +++ b/write.go @@ -1,10 +1,10 @@ package textfmt import ( + "bytes" + "code.squareroundforest.org/arpio/textedit" "fmt" "io" - "slices" - "strings" ) type writer interface { @@ -14,136 +14,44 @@ type writer interface { setErr(err error) // TODO: remove } -type textWriter struct { - err error - out io.Writer -} - -type editor struct { - out writer - pending []rune - replace map[string]string -} - -func (w *textWriter) write(a ...any) { - if w.err != nil { - return - } - - for _, ai := range a { - if _, err := w.out.Write([]byte(fmt.Sprint(ai))); err != nil { - w.err = err - return - } - } -} - -func (w *textWriter) flush() {} - -func (w *textWriter) error() error { - return w.err -} - -func (e *textWriter) setErr(err error) { -} - -func (e *editor) write(a ...any) { - for _, ai := range a { - s := fmt.Sprint(ai) - r := []rune(s) - for key, replacement := range e.replace { - rk := []rune(key) - if len(e.pending) >= len(rk) { - continue - } - - if !slices.Equal(e.pending, rk[:len(e.pending)]) { - continue - } - - if len(r) < len(rk)-len(e.pending) { - if slices.Equal(r, rk[len(e.pending):len(e.pending)+len(r)]) { - e.pending = append(e.pending, r...) - r = nil - break - } - - continue - } - - if slices.Equal(r[:len(rk)-len(e.pending)], rk[len(e.pending):]) { - r = []rune(replacement) - e.pending = nil - break - } - } - - e.out.write(string(r)) - } -} - -func (e *editor) flush() { - e.out.write(string(e.pending)) - e.out.flush() -} - -func (e *editor) error() error { - return e.out.error() -} - -func (e *editor) setErr(err error) { -} - -// -- - -type ttyWriter struct { - w io.Writer - internal bool - err error -} - type roffWriter struct { - w io.Writer - internal bool - err error + w io.Writer + err error } type mdWriter struct { - w io.Writer - internal bool - err error + w io.Writer + err error } -func (w *ttyWriter) write(a ...any) { - for _, ai := range a { - if w.err != nil { - return - } +type wrapper func(io.Writer) (io.Writer, func() error) - s := fmt.Sprint(ai) - r := []rune(s) - if !w.internal { - for i := range r { - if r[i] == '\u00a0' { - r[i] = ' ' - } - } - } +type errorWriter struct { + out io.Writer + err error +} - if _, err := w.w.Write([]byte(string(r))); err != nil { - w.err = err - } +func (w *errorWriter) Write(p []byte) (int, error) { + if w.err != nil { + return 0, w.err } + + var n int + n, w.err = w.out.Write(p) + return n, w.err } -func (w *ttyWriter) flush() {} +func newRoffWriter(out io.Writer, internal bool) *roffWriter { + if internal { + return &roffWriter{w: out} + } -func (w *ttyWriter) error() error { - return w.err -} - -func (w *ttyWriter) setErr(err error) { - w.err = err + return &roffWriter{ + w: textedit.New( + out, + textedit.Replace("\u00a0", "\\~"), + ), + } } func (w *roffWriter) write(a ...any) { @@ -152,29 +60,24 @@ func (w *roffWriter) write(a ...any) { return } - var rr []rune - s := fmt.Sprint(ai) - r := []rune(s) - if w.internal { - rr = r - } else { - 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 { + if _, err := w.w.Write([]byte(fmt.Sprint(ai))); err != nil { w.err = err + return } } } -func (w *roffWriter) flush() {} +func (w *roffWriter) flush() { + if w.err != nil { + return + } + + if f, ok := w.w.(interface{ Flush() error }); ok { + if err := f.Flush(); err != nil { + w.err = err + } + } +} func (w *roffWriter) error() error { return w.err @@ -184,28 +87,43 @@ func (w *roffWriter) setErr(err error) { w.err = err } +func newMDWriter(out io.Writer, internal bool) *mdWriter { + if internal { + return &mdWriter{w: out} + } + + return &mdWriter{ + w: textedit.New( + out, + textedit.Replace("\u00a0", " "), + ), + } +} + func (w *mdWriter) 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(fmt.Sprint(ai))); err != nil { + w.err = err + return } - - s = string(r) - _, w.err = w.w.Write([]byte(s)) } } -func (w *mdWriter) flush() {} +func (w *mdWriter) flush() { + if w.err != nil { + return + } + + if f, ok := w.w.(interface{ Flush() error }); ok { + if err := f.Flush(); err != nil { + w.err = err + } + } +} func (w *mdWriter) error() error { return w.err @@ -215,19 +133,66 @@ func (w *mdWriter) 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") +func writeWith(out io.Writer, w ...wrapper) (io.Writer, func() (io.Writer, error)) { + var f []func() error + ww := out + for i := len(w) - 1; i >= 0; i-- { + var fi func() error + ww, fi = w[i](ww) + f = append(f, fi) + } + + return ww, func() (io.Writer, error) { + for _, fi := range f { + if err := fi(); err != nil { + return out, err + } } - indent := indentFirst - if i > 0 { - indent = indentRest - } - - w.write(timesn(" ", indent)) - w.write(l) + return out, nil + } +} + +func errorHandler(out io.Writer) (io.Writer, func() error) { + ew := errorWriter{out: out} + return &ew, func() error { return ew.err } +} + +func editor(e textedit.Editor) wrapper { + return func(out io.Writer) (io.Writer, func() error) { + ew := textedit.New(out, e) + return ew, func() error { return ew.Flush() } + } +} + +func editString(s string, e ...wrapper) string { + var b bytes.Buffer + w, finish := writeWith(&b, e...) + w.Write([]byte(s)) + finish() + return b.String() +} + +func ttyNBSP() wrapper { return editor(textedit.Replace("\u00a0", " ")) } +func roffNBSP() wrapper { return editor(textedit.Replace("\u00a0", "\\~")) } +func mdNBSP() wrapper { return editor(textedit.Replace("\u00a0", " ")) } +func singleLine() wrapper { return editor(textedit.SingleLine()) } + +func indent(first, rest int) wrapper { + return editor(textedit.Indent(timesn(" ", first), timesn(" ", rest))) +} +func wrap(firstWidth, restWidth int) wrapper { + return editor(textedit.Wrap(firstWidth, restWidth)) +} + +func wrapIndent(first, rest, firstWidth, restWidth int) wrapper { + return editor(textedit.WrapIndent(timesn(" ", first), timesn(" ", rest), firstWidth, restWidth)) +} + +func write(out io.Writer, a ...any) { + for _, ai := range a { + if _, err := out.Write([]byte(fmt.Sprint(ai))); err != nil { + return + } } }