diff --git a/tools/cmd/tool/main.go b/tools/cmd/tool/main.go index 1ce84014d..ac925c584 100644 --- a/tools/cmd/tool/main.go +++ b/tools/cmd/tool/main.go @@ -28,6 +28,7 @@ import ( "kitty/tools/cmd/show_error" "kitty/tools/cmd/update_self" "kitty/tools/tui" + "kitty/tools/utils/images" ) var _ = fmt.Print @@ -103,6 +104,8 @@ func KittyToolEntryPoints(root *cli.Command) { return confirm_and_run_shebang(args) }, }) + // __render_image__ + images.RenderEntryPoint(root) // __generate_man_pages__ root.AddSubCommand(&cli.Command{ Name: "__generate_man_pages__", diff --git a/tools/utils/images/render.go b/tools/utils/images/render.go new file mode 100644 index 000000000..0b7fde898 --- /dev/null +++ b/tools/utils/images/render.go @@ -0,0 +1,174 @@ +package images + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "image" + "io" + "io/fs" + "os" + "path/filepath" + "time" + + "kitty/tools/cli" + "kitty/tools/utils" +) + +var _ = fmt.Print + +func convert_and_save_as_rgba_data(input_path, output_path string, perm os.FileMode) (err error) { + f, err := os.Open(input_path) + if err != nil { + return err + } + defer f.Close() + image_data, err := OpenNativeImageFromReader(f) + if err != nil { + return err + } + if len(image_data.Frames) == 0 { + return fmt.Errorf("Image at %s has no frames", input_path) + } + img := image_data.Frames[0].Img + var final_img *image.NRGBA + switch img.(type) { + case *image.NRGBA: + final_img = img.(*image.NRGBA) + default: + b := img.Bounds() + final_img = image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + ctx := Context{} + ctx.PasteCenter(final_img, img, nil) + } + b := final_img.Bounds() + header := make([]byte, 8) + var width = utils.Abs(b.Dx()) + var height = utils.Abs(b.Dy()) + binary.LittleEndian.PutUint32(header, uint32(width)) + binary.LittleEndian.PutUint32(header[4:], uint32(height)) + readers := []io.Reader{bytes.NewReader(header)} + stride := 4 * width + + if final_img.Stride == stride { + readers = append(readers, bytes.NewReader(final_img.Pix)) + } else { + p := final_img.Pix + for y := 0; y < b.Dy(); y++ { + readers = append(readers, bytes.NewReader(p[:min(stride, len(p))])) + p = p[final_img.Stride:] + } + } + return utils.AtomicUpdateFile(output_path, io.MultiReader(readers...), perm) +} + +var now_implementation = time.Now +var chmtime_after_creation = false + +func prune_cache(cdir string, max_entries int) error { + entries, err := os.ReadDir(cdir) + if err != nil { + return err + } + if len(entries) <= max_entries { + return nil + } + now := now_implementation() + epoch := time.Unix(0, 0) + entries = utils.SortWithKey(entries, func(a fs.DirEntry) time.Duration { + if info, err := a.Info(); err == nil { + return now.Sub(info.ModTime()) + } + return now.Sub(epoch) + }) + for _, x := range entries[max_entries:] { + if err = os.Remove(filepath.Join(cdir, x.Name())); err != nil { + return err + } + } + return nil +} + +func render_image(src_path, cdir string, max_cache_entries int) (output_path string, err error) { + src_path, err = filepath.EvalSymlinks(src_path) + if err != nil { + return + } + lock_file := filepath.Join(cdir, "lock") + lockf, err := os.OpenFile(lock_file, os.O_CREATE|os.O_RDWR, 0600) + defer lockf.Close() + if err != nil { + return + } + if err = utils.LockFileExclusive(lockf); err != nil { + return "", fmt.Errorf("Failed to lock cache file %s with error: %w", lock_file, err) + } + defer func() { + utils.UnlockFile(lockf) + }() + output_path = filepath.Join(cdir, hex.EncodeToString(sha256.New().Sum([]byte(src_path)))) + ".rgba" + needs_update := true + input_info, err := os.Stat(src_path) + if err != nil { + return + } + output_info, err := os.Stat(output_path) + if err == nil { + needs_update = input_info.Size() != output_info.Size() || input_info.ModTime().After(output_info.ModTime()) + } else { + if !errors.Is(err, fs.ErrNotExist) { + return + } + } + if needs_update { + if err = convert_and_save_as_rgba_data(src_path, output_path, 0600); err != nil { + return + } + if chmtime_after_creation { + n := now_implementation() + if err = os.Chtimes(output_path, n, n); err != nil { + return + } + } + if err = prune_cache(cdir, max_cache_entries); err != nil { + return + } + } else { + n := now_implementation() + if err = os.Chtimes(output_path, n, n); err != nil { + return + } + } + return +} + +func RenderEntryPoint(root *cli.Command) { + root.AddSubCommand(&cli.Command{ + Name: "__render_image__", + Hidden: true, + OnlyArgsAllowed: true, + Run: func(cmd *cli.Command, args []string) (rc int, err error) { + if len(args) != 1 { + return 1, fmt.Errorf("Usage: render input_image_path") + } + src_path, err := filepath.EvalSymlinks(args[0]) + if err != nil { + return 1, err + } + cdir := utils.CacheDir() + cdir = filepath.Join(cdir, "rgba") + if err = os.MkdirAll(cdir, 0755); err != nil { + return 1, err + } + if output_path, err := render_image(src_path, cdir, 32); err != nil { + return 1, err + } else { + fmt.Println(output_path) + } + return + }, + }) +} diff --git a/tools/utils/images/render_test.go b/tools/utils/images/render_test.go new file mode 100644 index 000000000..f4eec6894 --- /dev/null +++ b/tools/utils/images/render_test.go @@ -0,0 +1,99 @@ +package images + +import ( + "encoding/binary" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +var _ = fmt.Print + +var one_pixel_gray_png = []byte{137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0, 58, 126, 155, 85, 0, 0, 0, 10, 73, 68, 65, 84, 120, 1, 99, 176, 5, 0, 0, 63, 0, 62, 18, 174, 200, 16, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130} + +func TestRenderCache(t *testing.T) { + chmtime_after_creation = true + epoch := time.Now() + now_implementation = func() time.Time { + epoch = epoch.Add(3 * time.Second) + return epoch + } + defer func() { + chmtime_after_creation = false + now_implementation = time.Now + }() + tmp := t.TempDir() + cdir := filepath.Join(tmp, "cache") + if err := os.Mkdir(cdir, 0777); err != nil { + t.Fatal(err) + } + srcs := make([]string, 0, 5) + outputs := make([]string, 0, 5) + const max_cache_entries = 2 + for i := range max_cache_entries * 2 { + name := fmt.Sprintf(`%d.png`, i) + src_path := filepath.Join(tmp, name) + srcs = append(srcs, src_path) + if err := os.WriteFile(src_path, one_pixel_gray_png, 0644); err != nil { + t.Fatal(err) + } + output_path, err := render_image(src_path, cdir, max_cache_entries) + if err != nil { + t.Fatal(err) + } + outputs = append(outputs, output_path) + if entries, err := os.ReadDir(cdir); err != nil { + t.Fatal(err) + } else if len(entries) > max_cache_entries { + t.Fatalf("Too many files in cache dir %d > %d", len(entries), max_cache_entries) + } + } + exists := func(path string) bool { + _, err := os.Stat(path) + return err == nil + } + mtime := func(path string) time.Time { + ans, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return ans.ModTime() + } + for i, x := range outputs[len(outputs)-max_cache_entries:] { + if !exists(x) { + t.Fatalf("The %d cache entry does not exist", max_cache_entries+i) + } + } + o := outputs[len(outputs)-max_cache_entries:] + if mtime(o[0]).After(mtime(o[1])) { + t.Fatalf("The mtimes are not monotonic") + } + s := srcs[len(srcs)-max_cache_entries:] + output_path, err := render_image(s[0], cdir, max_cache_entries) + if err != nil { + t.Fatal(err) + } + if output_path != o[0] { + t.Fatalf("Output path change on rerun") + } + if mtime(o[1]).After(mtime(o[0])) || mtime(o[1]).Equal(mtime(o[0])) { + t.Fatalf("The mtime was not updated") + } + data, err := os.ReadFile(output_path) + if err != nil { + t.Fatal(err) + } + if len(data) != 12 { + t.Fatalf("unexpected data length: %d != %d", len(data), 12) + } + if width, height := binary.LittleEndian.Uint32(data), binary.LittleEndian.Uint32(data[4:]); width != 1 || height != 1 { + t.Fatalf("unexpected dimensions: %dx%d", width, height) + } + if diff := cmp.Diff(data[8:], []byte{61, 61, 61, 255}); diff != "" { + t.Fatalf("unexpected pixel: %s", diff) + } +} diff --git a/tools/utils/misc.go b/tools/utils/misc.go index 779112bd8..5f9ed25b8 100644 --- a/tools/utils/misc.go +++ b/tools/utils/misc.go @@ -310,3 +310,10 @@ func FunctionName(a any) string { } return "" } + +func Abs[T constraints.Integer](x T) T { + if x < 0 { + return -x + } + return x +}