From f124ea06d1f62874eda330ba739f6fde1a876e80 Mon Sep 17 00:00:00 2001 From: Chris Kuehl Date: Sun, 24 Nov 2024 23:44:51 -0600 Subject: [PATCH] Mostly-working ANSI parsing --- TODO | 1 + cmd/print-styles-css/print.go | 168 +++++++++--- server/highlighting/ansi_colors.go | 401 ++++++++++++++++++++++++---- server/highlighting/highlighting.go | 9 +- server/security/csp.go | 3 +- server/templates/paste.html | 2 +- server/views/uploads.go | 2 +- 7 files changed, 489 insertions(+), 97 deletions(-) diff --git a/TODO b/TODO index b4c7cfd..da864cc 100644 --- a/TODO +++ b/TODO @@ -33,3 +33,4 @@ * TODO assertions in views/uploads_test.go * Consolidate fpb and fput into just fpb and have it automatically deal with making pastes or not * Language is set both in query string and POST body of fpb cli +* Verify behavior around final end-of-lines matches across highlighters diff --git a/cmd/print-styles-css/print.go b/cmd/print-styles-css/print.go index 4859a2f..69153ee 100644 --- a/cmd/print-styles-css/print.go +++ b/cmd/print-styles-css/print.go @@ -2,7 +2,7 @@ package main import ( "fmt" - "os" + "strings" "github.com/chriskuehl/fluffy/server/highlighting" ) @@ -10,44 +10,134 @@ import ( func main() { for _, cat := range highlighting.Styles { for _, style := range cat.Styles { - fmt.Printf(".style-%s {\n", style.ChromaStyle.Name) - highlighting.Formatter.WriteCSS(os.Stdout, style.ChromaStyle) - fmt.Printf(".line-numbers {\n") - fmt.Printf(" background-color: %s;\n", style.FluffyColors.LineNumbersBackground) - fmt.Printf(" border-color: %s;\n", style.FluffyColors.Border) - fmt.Printf("}\n") - fmt.Printf(".text {\n") - fmt.Printf(" background-color: %s;\n", style.FluffyColors.Border) - fmt.Printf("}\n") - fmt.Printf(".line-numbers a {\n") - fmt.Printf(" color: %s;\n", style.FluffyColors.LineNumbersForeground) - fmt.Printf("}\n") - fmt.Printf(".line-numbers a:hover {\n") - fmt.Printf(" background-color: %s !important;\n", style.FluffyColors.LineNumbersHoverBackground) - fmt.Printf("}\n") - fmt.Printf(".line-numbers a.selected {\n") - fmt.Printf(" background-color: %s;\n", style.FluffyColors.LineNumbersSelectedBackground) - fmt.Printf("}\n") - fmt.Printf(".paste-toolbar {\n") - fmt.Printf(" background-color: %s;\n", style.FluffyColors.ToolbarBackground) - fmt.Printf(" color: %s;\n", style.FluffyColors.ToolbarForeground) - fmt.Printf("}\n") - fmt.Printf(".text .chroma .line.selected {\n") - fmt.Printf(" background-color: %s;\n", style.FluffyColors.SelectedLineBackground) - fmt.Printf("}\n") - fmt.Printf(".text .chroma .line.diff-add {\n") - fmt.Printf(" background-color: %s;\n", style.FluffyColors.DiffAddLineBackground) - fmt.Printf("}\n") - fmt.Printf(".text .chroma .line.diff-add.selected {\n") - fmt.Printf(" background-color: %s;\n", style.FluffyColors.DiffAddSelectedLineBackground) - fmt.Printf("}\n") - fmt.Printf(".text .chroma .line.diff-remove {\n") - fmt.Printf(" background-color: %s;\n", style.FluffyColors.DiffRemoveLineBackground) - fmt.Printf("}\n") - fmt.Printf(".text .chroma .line.diff-remove.selected {\n") - fmt.Printf(" background-color: %s;\n", style.FluffyColors.DiffRemoveSelectedLineBackground) - fmt.Printf("}\n") - fmt.Printf("}\n") + var chromaCSS strings.Builder + highlighting.Formatter.WriteCSS(&chromaCSS, style.ChromaStyle) + + fmt.Printf(` + .style-%s { + /* Chroma */ + %s + + /* Fluffy UI colors */ + .line-numbers { + background-color: %s; + border-color: %s; + } + .text { + background-color: %s; + } + .line-numbers a { + color: %s; + } + .line-numbers a:hover { + background-color: %s !important; + } + .line-numbers a.selected { + background-color: %s; + } + .paste-toolbar { + background-color: %s; + color: %s; + } + .text .chroma .line.selected { + background-color: %s; + } + .text .chroma .line.diff-add { + background-color: %s; + } + .text .chroma .line.diff-add.selected { + background-color: %s; + } + .text .chroma .line.diff-remove { + background-color: %s; + } + .text .chroma .line.diff-remove.selected { + background-color: %s; + } + + /* ANSI colors */ + .text .chroma .fg-0 { + color: %s; + } + .text .chroma .fg-0-faint { + color: %s; + } + .text .chroma .fg-1 { + color: %s; + } + .text .chroma .fg-1-faint { + color: %s; + } + .text .chroma .fg-2 { + color: %s; + } + .text .chroma .fg-2-faint { + color: %s; + } + .text .chroma .fg-3 { + color: %s; + } + .text .chroma .fg-3-faint { + color: %s; + } + .text .chroma .fg-4 { + color: %s; + } + .text .chroma .fg-4-faint { + color: %s; + } + .text .chroma .fg-5 { + color: %s; + } + .text .chroma .fg-5-faint { + color: %s; + } + .text .chroma .fg-6 { + color: %s; + } + .text .chroma .fg-6-faint { + color: %s; + } + .text .chroma .fg-7 { + color: %s; + } + .text .chroma .fg-7-faint { + color: %s; + } + } + `, + style.Name, + chromaCSS.String(), + style.FluffyColors.LineNumbersBackground, + style.FluffyColors.Border, + style.FluffyColors.Border, + style.FluffyColors.LineNumbersForeground, + style.FluffyColors.LineNumbersHoverBackground, + style.FluffyColors.LineNumbersSelectedBackground, + style.FluffyColors.ToolbarBackground, + style.FluffyColors.ToolbarForeground, + style.FluffyColors.SelectedLineBackground, + style.FluffyColors.DiffAddLineBackground, + style.FluffyColors.DiffAddSelectedLineBackground, + style.FluffyColors.DiffRemoveLineBackground, + style.FluffyColors.DiffRemoveSelectedLineBackground, + style.ANSIColors.Foreground.Black, + style.ANSIColors.ForegroundFaint.Black, + style.ANSIColors.Foreground.Red, + style.ANSIColors.ForegroundFaint.Red, + style.ANSIColors.Foreground.Green, + style.ANSIColors.ForegroundFaint.Green, + style.ANSIColors.Foreground.Yellow, + style.ANSIColors.ForegroundFaint.Yellow, + style.ANSIColors.Foreground.Blue, + style.ANSIColors.ForegroundFaint.Blue, + style.ANSIColors.Foreground.Magenta, + style.ANSIColors.ForegroundFaint.Magenta, + style.ANSIColors.Foreground.Cyan, + style.ANSIColors.ForegroundFaint.Cyan, + style.ANSIColors.Foreground.White, + style.ANSIColors.ForegroundFaint.White, + ) } } } diff --git a/server/highlighting/ansi_colors.go b/server/highlighting/ansi_colors.go index a08b43e..d0ddf17 100644 --- a/server/highlighting/ansi_colors.go +++ b/server/highlighting/ansi_colors.go @@ -1,63 +1,356 @@ package highlighting -type ansiColors struct { - black Color - red Color - green Color - yellow Color - blue Color - magenta Color - cyan Color - white Color -} - -type ansiColorSet struct { - foreground ansiColors - background ansiColors -} - -var ansiColorsLight = &ansiColorSet{ - foreground: ansiColors{ - black: "#000000", - red: "#EF2929", - green: "#62CA00", - yellow: "#DAC200", - blue: "#3465A4", - magenta: "#CE42BE", - cyan: "#34E2E2", - white: "#FFFFFF", +import ( + "fmt" + "html" + "html/template" + "strings" +) + +type ANSIColors struct { + Black Color + Red Color + Green Color + Yellow Color + Blue Color + Magenta Color + Cyan Color + White Color +} + +type ANSIColorSet struct { + Foreground ANSIColors + ForegroundFaint ANSIColors + Background ANSIColors +} + +var ansiColorsLight = &ANSIColorSet{ + Foreground: ANSIColors{ + Black: "#000000", + Red: "#EF2929", + Green: "#62CA00", + Yellow: "#DAC200", + Blue: "#3465A4", + Magenta: "#CE42BE", + Cyan: "#34E2E2", + White: "#FFFFFF", + }, + // TODO: double-check these colors + ForegroundFaint: ANSIColors{ + Black: "#676767", + Red: "#ff6d67", + Green: "#5ff967", + Yellow: "#fefb67", + Blue: "#6871ff", + Magenta: "#ff76ff", + Cyan: "#5ffdff", + White: "#feffff", }, - background: ansiColors{ - black: "#000000", - red: "#EF2929", - green: "#8AE234", - yellow: "#FCE94F", - blue: "#3465A4", - magenta: "#C509C5", - cyan: "#34E2E2", - white: "#FFFFFF", + Background: ANSIColors{ + Black: "#000000", + Red: "#EF2929", + Green: "#8AE234", + Yellow: "#FCE94F", + Blue: "#3465A4", + Magenta: "#C509C5", + Cyan: "#34E2E2", + White: "#FFFFFF", }, } -var ansiColorsDark = &ansiColorSet{ - foreground: ansiColors{ - black: "#555753", - red: "#FF5C5C", - green: "#8AE234", - yellow: "#FCE94F", - blue: "#8FB6E1", - magenta: "#FF80F1", - cyan: "#34E2E2", - white: "#EEEEEC", +var ansiColorsDark = &ANSIColorSet{ + Foreground: ANSIColors{ + Black: "#555753", + Red: "#FF5C5C", + Green: "#8AE234", + Yellow: "#FCE94F", + Blue: "#8FB6E1", + Magenta: "#FF80F1", + Cyan: "#34E2E2", + White: "#EEEEEC", + }, + // TODO: double-check these colors + ForegroundFaint: ANSIColors{ + Black: "#676767", + Red: "#ff6d67", + Green: "#5ff967", + Yellow: "#fefb67", + Blue: "#6871ff", + Magenta: "#ff76ff", + Cyan: "#5ffdff", + White: "#feffff", }, - background: ansiColors{ - black: "#555753", - red: "#F03D3D", - green: "#6ABC1B", - yellow: "#CEB917", - blue: "#6392C6", - magenta: "#FF80F1", - cyan: "#2FC0C0", - white: "#BFBFBF", + Background: ANSIColors{ + Black: "#555753", + Red: "#F03D3D", + Green: "#6ABC1B", + Yellow: "#CEB917", + Blue: "#6392C6", + Magenta: "#FF80F1", + Cyan: "#2FC0C0", + White: "#BFBFBF", }, } + +type ANSIHighlighter struct{} + +func (h *ANSIHighlighter) Name() string { + return "ANSI Color" +} + +func (h *ANSIHighlighter) RenderAsDiff() bool { + return false +} + +func (h *ANSIHighlighter) RenderAsRichText() bool { + return false +} + +func (h *ANSIHighlighter) RenderAsTerminal() bool { + return true +} + +func (h *ANSIHighlighter) ExtraHTMLClasses() []string { + return nil +} + +func (h *ANSIHighlighter) GenerateTexts(text string) []*Text { + return []*Text{simpleText(text)} +} + +func (h *ANSIHighlighter) Highlight(text *Text) (template.HTML, error) { + var sb strings.Builder + + sb.WriteString(`
`)
+
+	lines := strings.Split(strings.TrimSuffix(text.Text, "\n"), "\n")
+	state := ansiState{}
+
+	for i, line := range lines {
+		fmt.Fprintf(&sb, ``, i+1)
+
+		for _, parsed := range parseANSI(line, state) {
+			state = parsed.state // For next iteration.
+			classes := state.cssClasses()
+			styles := state.cssStyles()
+			if len(classes) > 0 || len(styles) > 0 {
+				sb.WriteString(" 0 {
+					fmt.Fprintf(&sb, ` class="%s"`, strings.Join(classes, " "))
+				}
+
+				if len(styles) > 0 {
+					sb.WriteString(` style="`)
+					first := true
+					for key, value := range styles {
+						if !first {
+							sb.WriteString(";")
+						}
+						fmt.Fprintf(&sb, "%s: %s", key, value)
+						first = false
+					}
+					sb.WriteString(`"`)
+				}
+				sb.WriteString(">")
+
+				sb.WriteString(html.EscapeString(parsed.text))
+				sb.WriteString("")
+			} else {
+				sb.WriteString(html.EscapeString(parsed.text))
+			}
+
+		}
+
+		sb.WriteString("\n") // Newline at the end ensures that empty lines are still rendered.
+		sb.WriteString(``)
+	}
+
+	sb.WriteString(`
`) + + return template.HTML(sb.String()), nil +} + +type rgb struct { + r uint8 + g uint8 + b uint8 +} + +type ansiColor struct { + index uint8 + // nil indicates standard color and index is set + rgb *rgb +} + +type ansiState struct { + bold bool + faint bool + italic bool + underline bool + strikethrough bool + // nil indicates no color is set + foreground *ansiColor + background *ansiColor +} + +func (s ansiState) cssStyles() map[string]string { + ret := map[string]string{} + if s.bold { + ret["font-weight"] = "bold" + } + if s.italic { + ret["font-style"] = "italic" + } + if s.underline { + ret["text-decoration"] = "underline" + } + if s.strikethrough { + ret["text-decoration"] = "line-through" + } + if s.foreground != nil && s.foreground.rgb != nil { + r := s.foreground.rgb.r + g := s.foreground.rgb.g + b := s.foreground.rgb.b + if s.faint { + // TODO: no idea if this is correct + r = r / 2 + g = g / 2 + b = b / 2 + } + ret["color"] = fmt.Sprintf("rgb(%d, %d, %d)", r, g, b) + } + if s.background != nil && s.background.rgb != nil { + ret["background-color"] = fmt.Sprintf("rgb(%d, %d, %d)", s.background.rgb.r, s.background.rgb.g, s.background.rgb.b) + } + return ret +} + +func (s ansiState) cssClasses() []string { + // Index colors are done through classes so that they can be customized by themes. + ret := []string{} + if s.foreground != nil && s.foreground.rgb == nil { + class := fmt.Sprintf("fg-%d", s.foreground.index) + if s.faint { + class += "-faint" + } + ret = append(ret, class) + } + if s.background != nil && s.background.rgb == nil { + ret = append(ret, fmt.Sprintf("bg-%d", s.background.index)) + } + return ret +} + +func (s ansiState) update(command string) ansiState { + switch command { + case "0": + return ansiState{} + case "1": + s.bold = true + case "2": + s.faint = true + case "3": + s.italic = true + case "4": + s.underline = true + case "9": + s.strikethrough = true + case "22": + s.bold = false + s.faint = false + case "23": + s.italic = false + case "24": + s.underline = false + case "29": + s.strikethrough = false + case "30": + s.foreground = &ansiColor{index: 0} + case "31": + s.foreground = &ansiColor{index: 1} + case "32": + s.foreground = &ansiColor{index: 2} + case "33": + s.foreground = &ansiColor{index: 3} + case "34": + s.foreground = &ansiColor{index: 4} + case "35": + s.foreground = &ansiColor{index: 5} + case "36": + s.foreground = &ansiColor{index: 6} + case "37": + s.foreground = &ansiColor{index: 7} + case "39": + s.foreground = nil + case "40": + s.background = &ansiColor{index: 0} + case "41": + s.background = &ansiColor{index: 1} + case "42": + s.background = &ansiColor{index: 2} + case "43": + s.background = &ansiColor{index: 3} + case "44": + s.background = &ansiColor{index: 4} + case "45": + s.background = &ansiColor{index: 5} + case "46": + s.background = &ansiColor{index: 6} + case "47": + s.background = &ansiColor{index: 7} + case "49": + s.background = nil + + } + return s +} + +type parsedANSI struct { + text string + state ansiState +} + +// parse parses the given text and returns the parsed text, the new state, and the remaining text. +func parseANSI(text string, state ansiState) []parsedANSI { + ret := []parsedANSI{} + cur := strings.Builder{} + escape := false + + output := func(force bool) { + if cur.Len() > 0 || force { + ret = append(ret, parsedANSI{ + text: cur.String(), + state: state, + }) + cur.Reset() + } + } + + for i := 0; i < len(text); i++ { + if text[i] == '\x1b' { + if len(text) > i+1 && text[i+1] == '[' { + i++ + escape = true + output(false) + continue + } + } + if escape { + if text[i] >= 0x40 && text[i] <= 0x7e { + escape = false + commands := cur.String() + cur.Reset() + if text[i] == 'm' { + for _, command := range strings.Split(commands, ";") { + state = state.update(command) + } + } + continue + } + } + cur.WriteByte(text[i]) + } + output(true) + return ret +} diff --git a/server/highlighting/highlighting.go b/server/highlighting/highlighting.go index 0ec7e46..2d6610b 100644 --- a/server/highlighting/highlighting.go +++ b/server/highlighting/highlighting.go @@ -87,7 +87,7 @@ var lightFluffyColors = &FluffyColorSet{ type Style struct { Name string ChromaStyle *chroma.Style - ANSIColors *ansiColorSet + ANSIColors *ANSIColorSet FluffyColors *FluffyColorSet } @@ -235,6 +235,13 @@ type Highlighter interface { // * Line mappings in the returned Texts are in reference to the primary Text. GenerateTexts(text string) []*Text // Highlight takes a piece of text and returns an HTML representation. + // + // For rich-text highlighters, the returned HTML may be anything. + // + // For plain-text highlighters, the returned HTML should contain: + // * A
 with class "chroma" wrapping the highlighted text.
+	//   * Each line should be its own element (e.g.  or 
) with classes "line" and + // "line-NUMBER" where NUMBER is the 1-indexed line number. Highlight(text *Text) (template.HTML, error) } diff --git a/server/security/csp.go b/server/security/csp.go index c5f0035..a3419e6 100644 --- a/server/security/csp.go +++ b/server/security/csp.go @@ -29,7 +29,8 @@ func CSPNonce(ctx context.Context) (string, error) { // This can only really happen in development mode when serving uploaded HTML objects from the app. // In prod, this isn't an issue because these files are not served by the app. func isDevStaticFileRequest(conf *config.Config, r *http.Request) bool { - return conf.DevMode && strings.HasPrefix(r.URL.Path, "/dev/storage/html/") + return conf.DevMode && (strings.HasPrefix(r.URL.Path, "/dev/storage/html/") || + strings.HasPrefix(r.URL.Path, "/dev/paste/")) } func NewCSPMiddleware(conf *config.Config, logger logging.Logger, next http.Handler) http.Handler { diff --git a/server/templates/paste.html b/server/templates/paste.html index 9c4e557..b1eea19 100644 --- a/server/templates/paste.html +++ b/server/templates/paste.html @@ -7,7 +7,7 @@
{{template "highlightStart" .}}
diff --git a/server/views/uploads.go b/server/views/uploads.go index 051f68a..fdafdd3 100644 --- a/server/views/uploads.go +++ b/server/views/uploads.go @@ -535,7 +535,7 @@ var HandleDevPasteMarkdown = makeDevPaste( var HandleDevPasteANSIColor = makeDevPaste( "ansi-color", // TODO - highlighting.NewChromaHighlighter(lexers.Get("go")), + &highlighting.ANSIHighlighter{}, ) var HandleDevPasteDiff = makeDevPaste(