diff --git a/Makefile b/Makefile index 7704820..e98dea1 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,17 @@ -SOURCES = $(shell find . -name "*.go") +sources = $(shell find . -name "*.go") default: build -build: $(SOURCES) +build: $(sources) go build -fmt: $(SOURCES) +fmt: $(sources) go fmt -check: $(SOURCES) +check: $(sources) go test -count 1 -.cover: $(SOURCES) +.cover: $(sources) go test -count 1 -coverprofile .cover cover: .cover diff --git a/escape.go b/escape.go new file mode 100644 index 0000000..f534f2c --- /dev/null +++ b/escape.go @@ -0,0 +1,72 @@ +package textfmt + +import "fmt" + +type escapeRange struct { + from, to rune + replacement string +} + +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 + } + } + + return "", false +} + +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 + ) + + output, found = e.escape[ri] + if !found { + output, found = e.inEscapeRange(ri) + } + + if !found { + conditional := e.conditionalEscape[ri] + if conditional != nil { + output, found = conditional(e.state, ri) + } + } + + if !found { + output = string(ri) + } + + e.out.write(output) + if e.updateState != nil { + e.state = e.updateState(e.state, ri) + } + } + } +} + +func (e *escape[S]) flush() { + e.out.flush() +} + +func (e *escape[S]) error() error { + return e.out.error() +} + +func (e *escape[S]) setErr(err error) { +} diff --git a/indent.go b/indent.go new file mode 100644 index 0000000..da6fa2a --- /dev/null +++ b/indent.go @@ -0,0 +1,82 @@ +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/markdown.go b/markdown.go index 1c6c83b..dccacd6 100644 --- a/markdown.go +++ b/markdown.go @@ -5,70 +5,158 @@ import ( "errors" "fmt" "io" - "slices" "strings" ) -func escapeMarkdown(s string, additional ...rune) string { - var ( - rr []rune - isNumberOnNewLine bool - isLinkOpen, isLinkClosed, isLinkValue bool - ) +type mdEscapeState struct { + newLine bool + numberOnNewLine bool + linkValue bool + linkClosed bool + linkOpen 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) - } - } - } +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 != ']', + } +} - isNumberOnNewLine = (isNewLine || isNumberOnNewLine) && ri >= '0' && ri <= '9' - isNewLine = ri == '\n' - isLinkValue = isLinkClosed && ri == '(' || isLinkValue && ri != ')' - isLinkClosed = isLinkOpen && ri == ']' - isLinkOpen = !isLinkValue && ri == '[' || isLinkOpen && ri != ']' +func escapeMarkdown(out writer, additional ...rune) writer { + esc := map[rune]string{ + '\\': "\\\\", + '`': "\\`", + '*': "\\*", + '_': "\\_", + '[': "\\[", + ']': "\\]", + '#': "\\#", + '<': "\\<", + '>': "\\>", } - return string(rr) + 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) { @@ -128,9 +216,9 @@ func renderMDText(w writer, text Txt) { } text.text = singleLine(text.text) - text.text = escapeMarkdown(text.text) + text.text = escapeMarkdownPrev(text.text) text.link = singleLine(text.link) - text.link = escapeMarkdown(text.link) + text.link = escapeMarkdownPrev(text.link) if text.bold { w.write("**") } @@ -381,7 +469,7 @@ func renderMDChoice(w writer, s SyntaxItem) { } func renderMDSymbol(w writer, s SyntaxItem) { - w.write(escapeTeletype(s.symbol)) + w.write(escapeMarkdownPrev(s.symbol)) } func renderMDSyntaxItem(w writer, s SyntaxItem) { diff --git a/notes.txt b/notes.txt index c4c6fcd..43743b0 100644 --- a/notes.txt +++ b/notes.txt @@ -2,4 +2,40 @@ 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 -show top level choice on separate lines in the same block +improve wrapping of list paragraphs by allowing different first line wrap +there should be no errors other than actual IO + +[refactor] +stop on errors earlier where possible +denormalize individual render functions +support single line in indent, indentOnly option +escape is more generic than editor: +- could replace editor +- maybe indent, too +- could become its own library +- could be used in HTML, too +collect the common transformations: +- escape +- wrap +- collapse to single line +- fill spaces +- convert spaces +- width and height calculations +- ... +identify the order of these transformations while rendering the different entries: +- tty: + - title + - paragraph + - ... +- roff: + - title + - paragraph + - ... +- md: + - title + - paragraph + - ... +- html: + - title + - paragraph + - ... diff --git a/runoff.go b/runoff.go index 043d70e..aac1e9f 100644 --- a/runoff.go +++ b/runoff.go @@ -9,7 +9,61 @@ import ( "time" ) -func escapeRoff(s string, additional ...string) string { +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 ( @@ -75,6 +129,7 @@ func escapeRoff(s string, additional ...string) string { } return string(e) + */ } func manPageDate(d time.Time) string { @@ -83,7 +138,7 @@ func manPageDate(d time.Time) string { func roffString(s string, additionalEscape ...string) string { s = singleLine(s) - return escapeRoff(s, additionalEscape...) + return escapeRoffPrev(s, additionalEscape...) } func renderRoffString(w writer, s string, additionalEscape ...string) { @@ -341,7 +396,7 @@ func renderRoffTable(w writer, e Entry) { func renderRoffCode(w writer, e Entry) { w.write(".nf\n") defer w.write("\n.fi") - e.text.text = escapeRoff(e.text.text) + e.text.text = escapeRoffPrev(e.text.text) writeLines(w, e.text.text, e.indent, e.indent) } @@ -414,7 +469,7 @@ func renderRoffChoice(w writer, s SyntaxItem) { } func renderRoffSymbol(w writer, s SyntaxItem) { - w.write(escapeRoff(s.symbol)) + w.write(escapeRoffPrev(s.symbol)) } func renderRoffSyntaxItem(w writer, s SyntaxItem) { diff --git a/teletype.go b/teletype.go index f5f7d0b..79290f6 100644 --- a/teletype.go +++ b/teletype.go @@ -8,7 +8,32 @@ import ( "strings" ) -func escapeTeletype(s string) string { +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 { + var b bytes.Buffer + w := &textWriter{out: &b} + e := escapeTeletype(w) + e.write(s) + return b.String() + /* r := []rune(s) for i := range r { if r[i] >= 0x00 && r[i] <= 0x1f && r[i] != '\n' && r[i] != '\t' { @@ -21,6 +46,7 @@ func escapeTeletype(s string) string { } return string(r) + */ } func ttyTextToString(text Txt) (string, error) { @@ -62,9 +88,9 @@ func renderTTYText(w writer, text Txt) { } text.text = singleLine(text.text) - text.text = escapeTeletype(text.text) + text.text = escapeTeletypePrev(text.text) text.link = singleLine(text.link) - text.link = escapeTeletype(text.link) + text.link = escapeTeletypePrev(text.link) if text.link != "" { if text.text != "" { w.write(text.text) @@ -282,7 +308,7 @@ func renderTTYTable(w writer, e Entry) { } func renderTTYCode(w writer, e Entry) { - e.text.text = escapeTeletype(e.text.text) + e.text.text = escapeTeletypePrev(e.text.text) writeLines(w, e.text.text, e.indent, e.indent) } @@ -355,7 +381,7 @@ func renderTTYChoice(w writer, s SyntaxItem) { } func renderTTYSymbol(w writer, s SyntaxItem) { - w.write(escapeTeletype(s.symbol)) + w.write(escapeTeletypePrev(s.symbol)) } func renderTTYSyntaxItem(w writer, s SyntaxItem) { @@ -395,7 +421,14 @@ func renderTTYSyntax(w writer, e Entry) { } func renderTeletype(out io.Writer, d Document) error { - w := ttyWriter{w: out} + tw := &textWriter{out: out} + w := &editor{ + out: tw, + replace: map[string]string{ + "\u00a0": " ", + }, + } + for i, e := range d.entries { if i > 0 { w.write("\n\n") @@ -403,23 +436,23 @@ func renderTeletype(out io.Writer, d Document) error { switch e.typ { case title: - renderTTYTitle(&w, e) + renderTTYTitle(w, e) case paragraph: - renderTTYParagraph(&w, e) + renderTTYParagraph(w, e) case list: - renderTTYList(&w, e) + renderTTYList(w, e) case numberedList: - renderTTYNumberedList(&w, e) + renderTTYNumberedList(w, e) case definitions: - renderTTYDefinitions(&w, e) + renderTTYDefinitions(w, e) case numberedDefinitions: - renderTTYNumberedDefinitions(&w, e) + renderTTYNumberedDefinitions(w, e) case table: - renderTTYTable(&w, e) + renderTTYTable(w, e) case code: - renderTTYCode(&w, e) + renderTTYCode(w, e) case syntax: - renderTTYSyntax(&w, e) + renderTTYSyntax(w, e) default: return errors.New("invalid entry") } @@ -429,5 +462,5 @@ func renderTeletype(out io.Writer, d Document) error { w.write("\n") } - return w.err + return w.error() } diff --git a/write.go b/write.go index 73749ec..9e92d86 100644 --- a/write.go +++ b/write.go @@ -3,15 +3,99 @@ package textfmt import ( "fmt" "io" + "slices" "strings" ) type writer interface { write(...any) + flush() error() error - setErr(error) + 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 @@ -52,6 +136,8 @@ func (w *ttyWriter) write(a ...any) { } } +func (w *ttyWriter) flush() {} + func (w *ttyWriter) error() error { return w.err } @@ -88,6 +174,8 @@ func (w *roffWriter) write(a ...any) { } } +func (w *roffWriter) flush() {} + func (w *roffWriter) error() error { return w.err } @@ -117,6 +205,8 @@ func (w *mdWriter) write(a ...any) { } } +func (w *mdWriter) flush() {} + func (w *mdWriter) error() error { return w.err }