From f6b970f0b2f92cc0f7581fbd08819e93406c28f1 Mon Sep 17 00:00:00 2001 From: Hazegard Date: Sat, 14 Dec 2024 20:48:06 +0100 Subject: [PATCH 1/2] Add SoftWrap to handle Lines and ShowLineNumbers behavior This commit introduces a `SoftWrap` option that adjusts the behavior of the `Lines` option and `ShowLineNumbers` when used with the `Wrap` option. - `Lines`: Currently, when both `Lines` and `Wrap` options are used, the line count is computed after wrapping occurs. This can lead to discrepancies where wrapped lines count as multiple lines, causing the final output to exclude some original input lines. With the new `SoftWrap` option, wrapped lines will count as a single line, ensuring that all lines specified in the `Lines` option are correctly included. - `ShowLineNumbers`: When `SoftWrap` is enabled, line numbers are not added to wrapped lines. This preserves the original line numbering, ensuring consistency with the input file. --- config.go | 1 + main.go | 33 +++++++++++++++++++++++-- soft_wrap.go | 24 ++++++++++++++++++ soft_wrap_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 soft_wrap.go create mode 100644 soft_wrap_test.go diff --git a/config.go b/config.go index 68a3554..2ea915b 100644 --- a/config.go +++ b/config.go @@ -31,6 +31,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:""` diff --git a/main.go b/main.go index 3a0919d..673dd6b 100644 --- a/main.go +++ b/main.go @@ -177,7 +177,7 @@ func main() { 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 +195,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,6 +337,7 @@ func main() { config.LineHeight *= float64(scale) + softWrapOffset := 0 for i, line := range text { if isAnsi { line.SetText("") @@ -330,9 +348,20 @@ func main() { 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 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)) + } 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]) 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) + } + }) + } +} From 4f19f05268e9cf14f367ee558057b1f50efb945c Mon Sep 17 00:00:00 2001 From: Hazegard Date: Sun, 15 Dec 2024 21:59:12 +0100 Subject: [PATCH 2/2] Add option to highlight specific lines The highlighting is done by applying a grey background with 50% opacity to all selected lines. The parsing of the option is as follows: - Each input (lines or line ranges) is separated by ; - To provide a range, "-" separates the start and the end of the range. - In a range, both start and end are required, and end must be greater than start. --- config.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/config.go b/config.go index 2ea915b..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" @@ -47,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 673dd6b..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,6 +173,7 @@ 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 @@ -344,10 +347,29 @@ func main() { } // 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()) + 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)) @@ -357,14 +379,33 @@ func main() { } 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. @@ -402,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],