Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Highlight lines #164

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package main
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/adrg/xdg"
Expand All @@ -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:""`
Expand All @@ -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"`
Expand Down
87 changes: 82 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"bytes"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"

"github.com/alecthomas/chroma/v2"
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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],
Expand Down
24 changes: 24 additions & 0 deletions soft_wrap.go
Original file line number Diff line number Diff line change
@@ -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
}
62 changes: 62 additions & 0 deletions soft_wrap_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}