Files
2026-07-13 13:12:56 +08:00

457 lines
12 KiB
Go

package main
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"runtime/debug"
"strings"
"github.com/alecthomas/chroma/v2"
formatter "github.com/alecthomas/chroma/v2/formatters/svg"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/alecthomas/kong"
"github.com/beevik/etree"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/cellbuf"
"github.com/mattn/go-isatty"
in "github.com/charmbracelet/freeze/input"
"github.com/charmbracelet/freeze/svg"
)
const (
defaultFontSize = 14.0
defaultLineHeight = 1.2
)
var (
// Version contains the application version number. It's set via ldflags
// when building.
Version = ""
// CommitSHA contains the SHA of the commit that this application was built
// against. It's set via ldflags when building.
CommitSHA = ""
)
func main() {
const shaLen = 7
var (
input string
err error
lexer chroma.Lexer
config Config
scale float64
)
k, err := kong.New(&config, kong.Help(helpPrinter))
if err != nil {
printErrorFatal("Something went wrong", err)
}
ctx, err := k.Parse(os.Args[1:])
if err != nil || ctx.Error != nil {
printErrorFatal("Invalid Usage", err)
}
if config.Version {
if Version == "" {
info, ok := debug.ReadBuildInfo()
if ok && info.Main.Sum != "" {
Version = info.Main.Version
} else {
Version = "unknown (built from source)"
}
}
version := fmt.Sprintf("freeze version %s", Version)
if len(CommitSHA) >= shaLen {
version += " (" + CommitSHA[:shaLen] + ")"
}
fmt.Println(version)
os.Exit(0)
}
// Copy the pty output to buffer
if config.Execute != "" {
input, err = executeCommand(config)
if err != nil {
if input != "" {
err = fmt.Errorf("%w\n%s", err, input)
}
printErrorFatal("Something went wrong", err)
}
if input == "" {
printErrorFatal("Something went wrong", errors.New("no command output"))
}
}
isDefaultConfig := config.Config == "default"
configFile, err := configs.Open("configurations/" + config.Config + ".json")
if config.Config == "user" {
configFile, err = loadUserConfig()
}
if err != nil {
configFile, err = os.Open(config.Config)
}
if err != nil {
configFile, _ = configs.Open("configurations/base.json")
}
r, err := kong.JSON(configFile)
if err != nil {
printErrorFatal("Invalid JSON", err)
}
k, err = kong.New(&config, kong.Help(helpPrinter), kong.Resolvers(r))
if err != nil {
printErrorFatal("Something went wrong", err)
}
ctx, err = k.Parse(os.Args[1:])
if err != nil {
printErrorFatal("Invalid Usage", err)
}
if config.Interactive {
cfg, interactiveErr := runForm(&config)
config = *cfg
if interactiveErr != nil {
printErrorFatal("", interactiveErr)
}
if isDefaultConfig {
_ = saveUserConfig(*cfg)
}
}
autoHeight := config.Height == 0
autoWidth := config.Width == 0
if config.Output == "" {
config.Output = defaultOutputFilename
}
scale = 1
if autoHeight && autoWidth && strings.HasSuffix(config.Output, ".png") {
scale = 4
}
config.Margin = expandMargin(config.Margin, scale)
config.Padding = expandPadding(config.Padding, scale)
if config.Input == "" && !in.IsPipe(os.Stdin) && len(ctx.Args) <= 0 {
_ = helpPrinter(kong.HelpOptions{}, ctx)
os.Exit(0)
}
if config.Input == "-" || in.IsPipe(os.Stdin) {
input, err = in.ReadInput(os.Stdin)
lexer = lexers.Analyse(input)
} else if config.Execute != "" {
config.Language = "ansi"
} else {
input, err = in.ReadFile(config.Input)
if err != nil {
printErrorFatal("File not found", err)
}
lexer = lexers.Get(config.Input)
}
if config.Language != "" {
lexer = lexers.Get(config.Language)
}
// adjust for 1-indexing
for i := range config.Lines {
config.Lines[i]--
}
strippedInput := ansi.Strip(input)
isAnsi := strings.ToLower(config.Language) == "ansi" || strippedInput != input
strippedInput = cut(strippedInput, config.Lines)
// wrap to character limit.
if config.Wrap > 0 {
strippedInput = cellbuf.Wrap(strippedInput, config.Wrap, "")
input = cellbuf.Wrap(input, config.Wrap, "")
}
if !isAnsi && lexer == nil {
printErrorFatal("Language Unknown", errors.New("specify a language with the --language flag"))
}
input = cut(input, config.Lines)
if input == "" {
if err != nil {
printErrorFatal("No input", err)
} else {
printErrorFatal("No input", errors.New("check --lines is within bounds"))
}
}
s, ok := styles.Registry[strings.ToLower(config.Theme)]
if s == nil || !ok {
s = charmStyle
}
if !s.Has(chroma.Background) {
s, err = s.Builder().Add(chroma.Background, "bg:"+config.Background).Build()
if err != nil {
printErrorFatal("Could not add background", err)
}
}
// Create a token iterator.
var it chroma.Iterator
if isAnsi {
// For ANSI output, we'll inject our own SVG. For now, let's just strip the ANSI
// codes and print the text to properly size the input.
it = chroma.Literator(chroma.Token{Type: chroma.Text, Value: strippedInput})
} else {
it, err = chroma.Coalesce(lexer).Tokenise(nil, input)
if err != nil {
printErrorFatal("Could not lex file", err)
}
}
// Format the code to an SVG.
options, err := fontOptions(&config)
if err != nil {
printErrorFatal("Invalid font options", err)
}
f := formatter.New(options...)
if err != nil {
printErrorFatal("Malformed text", err)
}
buf := &bytes.Buffer{}
err = f.Format(buf, s, it)
if err != nil {
log.Fatal(err)
}
// Parse SVG (XML document)
doc := etree.NewDocument()
_, err = doc.ReadFrom(buf)
if err != nil {
printErrorFatal("Bad SVG", err)
}
elements := doc.ChildElements()
if len(elements) < 1 {
printErrorFatal("Bad Output", nil)
}
image := elements[0]
hPadding := config.Padding[left] + config.Padding[right]
hMargin := config.Margin[left] + config.Margin[right]
vMargin := config.Margin[top] + config.Margin[bottom]
vPadding := config.Padding[top] + config.Padding[bottom]
terminal := image.SelectElement("rect")
w, h := svg.GetDimensions(image)
imageWidth := float64(w)
imageHeight := float64(h)
imageWidth *= scale
imageHeight *= scale
// chroma automatically calculates the height based on a font size of 14
// and a line height of 1.2
imageHeight *= (config.Font.Size / defaultFontSize)
imageHeight *= (config.LineHeight / defaultLineHeight)
terminalWidth := imageWidth
terminalHeight := imageHeight
if !autoWidth {
imageWidth = config.Width
terminalWidth = config.Width - hMargin
} else {
imageWidth += hMargin + hPadding
terminalWidth += hPadding
}
if !autoHeight {
imageHeight = config.Height
terminalHeight = config.Height - vMargin
} else {
imageHeight += vMargin + vPadding
terminalHeight += vPadding
}
if config.Window {
windowControls := svg.NewWindowControls(5.5*float64(scale), 19.0*scale, 12.0*scale)
svg.Move(windowControls, float64(config.Margin[left]), float64(config.Margin[top]))
image.AddChild(windowControls)
config.Padding[top] += (15 * scale)
}
if config.Border.Radius > 0 {
svg.AddCornerRadius(terminal, config.Border.Radius*scale)
}
if config.Shadow.Blur > 0 || config.Shadow.X > 0 || config.Shadow.Y > 0 {
id := "shadow"
svg.AddShadow(image, id, config.Shadow.X*scale, config.Shadow.Y*scale, config.Shadow.Blur*scale)
terminal.CreateAttr("filter", fmt.Sprintf("url(#%s)", id))
}
textGroup := image.SelectElement("g")
textGroup.CreateAttr("font-size", fmt.Sprintf("%.2fpx", config.Font.Size*float64(scale)))
textGroup.CreateAttr("clip-path", "url(#terminalMask)")
text := textGroup.SelectElements("text")
d := dispatcher{lines: text, svg: textGroup, config: &config, scale: scale}
offsetLine := 0
if len(config.Lines) > 0 {
offsetLine = config.Lines[0]
}
config.LineHeight *= float64(scale)
for i, line := range text {
if isAnsi {
line.SetText("")
}
// Offset the text by padding...
// (x, y) -> (x+p, y+p)
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))
line.InsertChildAt(0, ln)
}
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])
svg.Move(line, x, y)
// We are passed visible lines, remove the rest.
if y > float64(imageHeight-config.Margin[bottom]-config.Padding[bottom]) {
textGroup.RemoveChild(line)
}
}
if autoWidth {
tabWidth := 4
if isAnsi {
tabWidth = 6
}
longestLine := lipgloss.Width(strings.ReplaceAll(strippedInput, "\t", strings.Repeat(" ", tabWidth)))
terminalWidth = float64(longestLine+1) * (config.Font.Size / fontHeightToWidthRatio)
terminalWidth *= scale
terminalWidth += hPadding
imageWidth = terminalWidth + hMargin
}
if config.Border.Width > 0 {
svg.AddOutline(terminal, config.Border.Width, config.Border.Color)
// NOTE: necessary so that we don't clip the outline.
terminalHeight -= (config.Border.Width * 2)
terminalWidth -= (config.Border.Width * 2)
}
if config.ShowLineNumbers {
if autoWidth {
terminalWidth += config.Font.Size * 3 * scale
imageWidth += config.Font.Size * 3 * scale
} else {
terminalWidth -= config.Font.Size * 3
}
}
if !autoHeight || !autoWidth {
svg.AddClipPath(image, "terminalMask",
config.Margin[left], config.Margin[top],
terminalWidth, terminalHeight-config.Padding[bottom])
}
svg.Move(terminal, max(float64(config.Margin[left]), float64(config.Border.Width)/2), max(float64(config.Margin[top]), float64(config.Border.Width)/2))
svg.SetDimensions(image, imageWidth, imageHeight)
svg.SetDimensions(terminal, terminalWidth, terminalHeight)
if isAnsi {
parser := ansi.NewParser()
parser.SetHandler(ansi.Handler{
Print: d.Print,
HandleCsi: d.CsiDispatch,
Execute: d.Execute,
})
for _, line := range strings.Split(input, "\n") {
parser.Parse([]byte(line))
d.Execute(ansi.LF) // simulate a newline
}
}
istty := isatty.IsTerminal(os.Stdout.Fd())
switch {
case strings.HasSuffix(config.Output, ".png"):
// use libsvg conversion.
svgConversionErr := libsvgConvert(doc, imageWidth, imageHeight, config.Output)
if svgConversionErr == nil {
printFilenameOutput(config.Output)
break
}
// could not convert with libsvg, try resvg
svgConversionErr = resvgConvert(doc, imageWidth, imageHeight, config.Output)
if svgConversionErr != nil {
printErrorFatal("Unable to convert SVG to PNG", svgConversionErr)
}
printFilenameOutput(config.Output)
default:
// output file specified.
if config.Output != "" {
err = doc.WriteToFile(config.Output)
if err != nil {
printErrorFatal("Unable to write output", err)
}
printFilenameOutput(config.Output)
return
}
// reading from stdin.
if config.Input == "" || config.Input == "-" {
if istty {
err = doc.WriteToFile(defaultOutputFilename)
printFilenameOutput(defaultOutputFilename)
} else {
_, err = doc.WriteTo(os.Stdout)
}
if err != nil {
printErrorFatal("Unable to write output", err)
}
return
}
// reading from file.
if istty {
config.Output = strings.TrimSuffix(filepath.Base(config.Input), filepath.Ext(config.Input)) + ".svg"
err = doc.WriteToFile(config.Output)
printFilenameOutput(config.Output)
} else {
_, err = doc.WriteTo(os.Stdout)
}
if err != nil {
printErrorFatal("Unable to write output", err)
}
}
}
var outputHeader = lipgloss.NewStyle().Foreground(lipgloss.Color("#F1F1F1")).Background(lipgloss.Color("#6C50FF")).Bold(true).Padding(0, 1).MarginRight(1).SetString("WROTE")
func printFilenameOutput(filename string) {
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Center, outputHeader.String(), filename))
}