diff --git a/config.go b/config.go index 68a3554..7343f77 100644 --- a/config.go +++ b/config.go @@ -3,9 +3,12 @@ package main import ( "embed" "encoding/json" + "fmt" "io/fs" "os" "path/filepath" + "strconv" + "strings" "time" "github.com/adrg/xdg" @@ -31,6 +34,7 @@ type Config struct { Language string `json:"language,omitempty" help:"Language of code file." short:"l" group:"Settings" placeholder:"go"` Theme string `json:"theme" help:"Theme to use for syntax highlighting." short:"t" group:"Settings" placeholder:"charm"` Wrap int `json:"wrap" help:"Wrap lines at a specific width." short:"w" group:"Settings" default:"0" placeholder:"80"` + SoftWrap bool `json:"soft-wrap" help:"Do not count wrapped lines (Lines & LineHeight)." group:"Settings"` Output string `json:"output,omitempty" help:"Output location for {{.svg}}, {{.png}}, or {{.webp}}." short:"o" group:"Settings" default:"" placeholder:"freeze.svg"` Execute string `json:"-" help:"Capture output of command execution." short:"x" group:"Settings" default:""` @@ -46,9 +50,64 @@ type Config struct { // Line LineHeight float64 `json:"line_height" help:"Line height relative to font size." group:"Line" placeholder:"1.2"` Lines []int `json:"-" help:"Lines to capture (start,end)." group:"Line" placeholder:"0,-1" value:"0,-1"` + HighlightLines string `json:"-" help:"Lines to highlight (range: \"start-end\", separator:\";\")." group:"Line" placeholder:"0,10" value:""` ShowLineNumbers bool `json:"show_line_numbers" help:"" group:"Line" placeholder:"false"` } +// ComputeHighlightedLines parse the config.HighlightLines option +// And return a map of Line numbers where the highlight should be applied +func (cfg Config) ComputeHighlightedLines() map[int]bool { + uniqueNumbers := make(map[int]bool) // Use a map to ensure uniqueness + if cfg.HighlightLines == "" { + return uniqueNumbers + } + // Split the input by ';' + parts := strings.Split(cfg.HighlightLines, ";") + + for _, part := range parts { + // Check if the part contains a dash '-' + if strings.Contains(part, "-") { + // Split the part by '-' to get start and end of the range + rangeParts := strings.Split(part, "-") + if len(rangeParts) == 2 { + start, err1 := strconv.Atoi(rangeParts[0]) + end, err2 := strconv.Atoi(rangeParts[1]) + + if end <= start { + err := fmt.Errorf("end of range lower (%d) than start of range (%d): %s", end, start, part) + printErrorFatal("error while parsing highlight lines range", err) + } + // If parsing is successful and start <= end + if err1 == nil && err2 == nil && start <= end { + // Add all numbers in the range to the map + for i := start; i <= end; i++ { + uniqueNumbers[i] = true + } + } else if err1 != nil { + err := fmt.Errorf("unable to parse the first part of the range: %s", rangeParts[0]) + printErrorFatal("error while parsing highlight lines range", err) + } else if err2 != nil { + err := fmt.Errorf("unable to parse the second part of the range: %s", rangeParts[1]) + printErrorFatal("error while parsing highlight lines range", err) + } + } else { + err := fmt.Errorf("a range should contains exactly two part: %s", part) + printErrorFatal("error while parsing highlight lines range", err) + } + } else { + // If no dash, just convert the number and add it to the map + num, err := strconv.Atoi(part) + if err != nil { + err := fmt.Errorf("unable to parse to integer: %s", part) + printErrorFatal("error while parsing highlight lines", err) + } + uniqueNumbers[num] = true + } + } + + return uniqueNumbers +} + // Shadow is the configuration options for a drop shadow. type Shadow struct { Blur float64 `json:"blur" help:"Shadow Gaussian Blur." placeholder:"0"` diff --git a/main.go b/main.go index 3a0919d..a2a4cfe 100644 --- a/main.go +++ b/main.go @@ -4,9 +4,11 @@ import ( "bytes" "errors" "fmt" + "math" "os" "path/filepath" "runtime/debug" + "strconv" "strings" "github.com/alecthomas/chroma/v2" @@ -171,13 +173,14 @@ func main() { for i := range config.Lines { config.Lines[i]-- } + highlightedLines := config.ComputeHighlightedLines() var strippedInput string = ansi.Strip(input) isAnsi := strings.ToLower(config.Language) == "ansi" || strippedInput != input strippedInput = cut(strippedInput, config.Lines) // wrap to character limit. - if config.Wrap > 0 { + if config.Wrap > 0 && !config.SoftWrap { strippedInput = wordwrap.String(strippedInput, config.Wrap) input = wordwrap.String(input, config.Wrap) } @@ -195,6 +198,23 @@ func main() { } } + isRealLine := []bool{} + strippedIsRealLine := []bool{} + // wrap to character limit. + if config.Wrap > 0 && config.SoftWrap { + isRealLine = SoftWrap(input, config.Wrap) + strippedIsRealLine = SoftWrap(strippedInput, config.Wrap) + strippedInput = wordwrap.String(strippedInput, config.Wrap) + input = wordwrap.String(input, config.Wrap) + } + + if config.Wrap <= 0 { + // If Wrap is disabled, but SoftWrap enabled, we force disable SoftWrap as it does not make sense + // to keep this option enabled. + printError("Wrap option disabled, but SoftWrap option enabled", fmt.Errorf("wrap option disabled")) + config.SoftWrap = false + } + s, ok := styles.Registry[strings.ToLower(config.Theme)] if s == nil || !ok { s = charmStyle @@ -320,22 +340,72 @@ func main() { config.LineHeight *= float64(scale) + softWrapOffset := 0 for i, line := range text { if isAnsi { line.SetText("") } // Offset the text by padding... // (x, y) -> (x+p, y+p) + + var bg *etree.Element + doHighlightLine := highlightedLines[i+1+offsetLine-softWrapOffset] + if config.SoftWrap { + // If the current line is soft-wrapped, we need to find the previous real line + if !isRealLine[i] { + j := i + // iterate previous lines until we find the previous real line + for ; !isRealLine[j]; j-- { + } + // we apply to the current line the highlight status of the found real line + doHighlightLine = highlightedLines[j+1+offsetLine-softWrapOffset] + } + } + if config.ShowLineNumbers { ln := etree.NewElement("tspan") ln.CreateAttr("xml:space", "preserve") - ln.CreateAttr("fill", s.Get(chroma.LineNumbers).Colour.String()) - ln.SetText(fmt.Sprintf("%3d ", i+1+offsetLine)) + if doHighlightLine { + ln.CreateAttr("fill", s.Get(chroma.LineHighlight).Colour.String()) + } else { + ln.CreateAttr("fill", s.Get(chroma.LineNumbers).Colour.String()) + } + if config.SoftWrap { + if (isAnsi && strippedIsRealLine[i]) || (!isAnsi && isRealLine[i]) { + ln.SetText(fmt.Sprintf("%3d ", i+1+offsetLine-softWrapOffset)) + } else { + ln.SetText(" ") + } + } else { + ln.SetText(fmt.Sprintf("%3d ", i+1+offsetLine)) + } + ln.CreateAttr("height", strconv.Itoa(int(math.Round(config.Font.Size*config.LineHeight)))) line.InsertChildAt(0, ln) } + if config.SoftWrap && !((isAnsi && strippedIsRealLine[i]) || (!isAnsi && isRealLine[i])) { + softWrapOffset++ + } x := float64(config.Padding[left] + config.Margin[left]) - y := (float64(i+1))*(config.Font.Size*config.LineHeight) + float64(config.Padding[top]) + float64(config.Margin[top]) - + // Rounding required to ensure that each line have the same height + y := (float64(i+1))*math.Round(config.Font.Size*config.LineHeight) + float64(config.Padding[top]) + float64(config.Margin[top]) + if doHighlightLine { + // Create a background element, with grey color and 50% opacity + bg = etree.NewElement("rect") + bg.CreateAttr("fill", "grey") + bg.CreateAttr("fill-opacity", "0.5") + // This lineWidth is not accurate when the width is dynamic, it will be computed later + lineWidth := imageWidth + config.Margin[left] + config.Padding[left] + config.Margin[right] + config.Padding[right] + bg.CreateAttr("width", strconv.Itoa(int(lineWidth))) + // We round to ensure that two highlighted consecutive lines do not leave a one pixel line between + bg.CreateAttr("height", strconv.Itoa(int(math.Round(config.Font.Size*config.LineHeight)))) + line.Parent().InsertChildAt(0, bg) + + yRect := float64(i)*math.Round(config.Font.Size*config.LineHeight) + + config.Padding[top] + + config.Margin[top] + math.Round(config.LineHeight*config.Font.Size)/4 + // We round to ensure that two highlighted consecutive lines do not leave a one pixel line between + svg.Move(bg, 0, math.Round(yRect)) + } svg.Move(line, x, y) // We are passed visible lines, remove the rest. @@ -373,6 +443,13 @@ func main() { } } + // Adjust the highlighted rect width with the accurate computed width + if len(highlightedLines) != 0 { + for _, elem := range textGroup.SelectElements("rect") { + elem.CreateAttr("width", strconv.Itoa(int(imageWidth))) + } + } + if !autoHeight || !autoWidth { svg.AddClipPath(image, "terminalMask", config.Margin[left], config.Margin[top], diff --git a/soft_wrap.go b/soft_wrap.go new file mode 100644 index 0000000..0b57e45 --- /dev/null +++ b/soft_wrap.go @@ -0,0 +1,24 @@ +package main + +import ( + "github.com/muesli/reflow/wordwrap" + "strings" +) + +func SoftWrap(input string, wrapLength int) []bool { + var wrap []bool + for _, line := range strings.Split(input, "\n") { + wrappedLine := wordwrap.String(line, wrapLength) + + for i := range strings.Split(wrappedLine, "\n") { + if i == 0 { + // We want line number on the original line + wrap = append(wrap, true) + } else { + // for wrapped line, we do not want line number + wrap = append(wrap, false) + } + } + } + return wrap +} diff --git a/soft_wrap_test.go b/soft_wrap_test.go new file mode 100644 index 0000000..6d6ec11 --- /dev/null +++ b/soft_wrap_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "reflect" + "testing" +) + +// Mock the dependency if needed, assuming wordwrap.String works correctly. +func TestSoftWrap(t *testing.T) { + tests := []struct { + name string + input string + wrapLength int + expected []bool + }{ + { + name: "Single short line, no wrapping", + input: "Hello", + wrapLength: 10, + expected: []bool{true}, + }, + { + name: "Single long line, wrapping", + input: "Hello World, this is a long line", + wrapLength: 10, + expected: []bool{true, false, false, false}, + }, + { + name: "Multiple lines, some wrapped", + input: "Short\nThis is a long line", + wrapLength: 10, + expected: []bool{true, true, false}, + }, + { + name: "Multiple lines, multiple wraps", + input: "This is an long line\nThis is an other long line\nThis is the last long line\nShort line", + wrapLength: 10, + expected: []bool{true, false, true, false, false, true, false, false, true}, + }, + { + name: "Empty input", + input: "", + wrapLength: 10, + expected: []bool{true}, + }, + { + name: "Lines with spaces only", + input: " \n ", + wrapLength: 5, + expected: []bool{true, true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SoftWrap(tt.input, tt.wrapLength) + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("SoftWrap() = %v, expected %v", got, tt.expected) + } + }) + } +}