529 lines
15 KiB
Go
529 lines
15 KiB
Go
package magick
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/kovidgoyal/imaging/nrgb"
|
|
"github.com/kovidgoyal/imaging/prism/meta"
|
|
"github.com/kovidgoyal/imaging/prism/meta/gifmeta"
|
|
"github.com/kovidgoyal/imaging/prism/meta/icc"
|
|
"github.com/kovidgoyal/imaging/types"
|
|
)
|
|
|
|
var _ = fmt.Print
|
|
|
|
var MagickExe = sync.OnceValue(func() string {
|
|
ans, err := exec.LookPath("magick")
|
|
if err != nil || ans == "" {
|
|
ans = "magick"
|
|
}
|
|
return ans
|
|
})
|
|
var HasMagick = sync.OnceValue(func() bool { return MagickExe() != "magick" })
|
|
|
|
var TempDirInRAMIfPossible = sync.OnceValue(func() string { return get_temp_dir() })
|
|
|
|
type ImageFrame struct {
|
|
Width, Height, Left, Top int
|
|
Number int // 1-based number
|
|
Compose_onto int // number of frame to compose onto
|
|
Delay_ms int32 // negative for gapless frame, zero ignored, positive is number of ms
|
|
Replace bool // do a replace rather than an alpha blend
|
|
Is_opaque bool
|
|
Img image.Image
|
|
}
|
|
|
|
func check_resize(frame *ImageFrame, filename string) error {
|
|
// ImageMagick sometimes generates RGBA images smaller than the specified
|
|
// size. See https://github.com/kovidgoyal/kitty/issues/276 for examples
|
|
s, err := os.Stat(filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sz := int(s.Size())
|
|
bytes_per_pixel := 4
|
|
if frame.Is_opaque {
|
|
bytes_per_pixel = 3
|
|
}
|
|
expected_size := bytes_per_pixel * frame.Width * frame.Height
|
|
if sz < expected_size {
|
|
if bytes_per_pixel == 4 && sz == 3*frame.Width*frame.Height {
|
|
frame.Is_opaque = true
|
|
return nil
|
|
}
|
|
missing := expected_size - sz
|
|
if missing%(bytes_per_pixel*frame.Width) != 0 {
|
|
return fmt.Errorf("ImageMagick failed to resize correctly. It generated %d < %d of data (w=%d h=%d bpp=%d frame-number: %d)", sz, expected_size, frame.Width, frame.Height, bytes_per_pixel, frame.Number)
|
|
}
|
|
frame.Height -= missing / (bytes_per_pixel * frame.Width)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func RunMagick(i *input, cmd []string) ([]byte, error) {
|
|
cmd = append([]string{MagickExe()}, cmd...)
|
|
c := exec.Command(cmd[0], cmd[1:]...)
|
|
if i.os_file != nil {
|
|
c.ExtraFiles = append(c.ExtraFiles, i.os_file)
|
|
}
|
|
output, err := c.Output()
|
|
if err != nil {
|
|
var exit_err *exec.ExitError
|
|
if errors.As(err, &exit_err) {
|
|
return nil, fmt.Errorf("Running the command: %s\nFailed with error:\n%s", strings.Join(cmd, " "), string(exit_err.Stderr))
|
|
}
|
|
return nil, fmt.Errorf("Could not find the program: %#v. Is ImageMagick installed and in your PATH?", cmd[0])
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
type IdentifyOutput struct {
|
|
Fmt, Canvas, Transparency, Gap, Index, Size, Dpi, Dispose, Orientation, Colorspace string
|
|
}
|
|
|
|
type DisposeOp int
|
|
|
|
const (
|
|
DisposeNone DisposeOp = iota
|
|
DisposeBackground
|
|
DisposePrevious
|
|
)
|
|
|
|
type IdentifyRecord struct {
|
|
Fmt_uppercase string
|
|
Gap int
|
|
Canvas struct{ Width, Height, Left, Top int }
|
|
Width, Height int
|
|
Dpi struct{ X, Y float64 }
|
|
Index int
|
|
Is_opaque bool
|
|
Needs_blend bool
|
|
Disposal DisposeOp
|
|
Dimensions_swapped bool
|
|
ColorSpace string
|
|
}
|
|
|
|
func parse_identify_record(ans *IdentifyRecord, raw *IdentifyOutput) (err error) {
|
|
ans.Fmt_uppercase = strings.ToUpper(raw.Fmt)
|
|
if raw.Gap != "" {
|
|
ans.Gap, err = strconv.Atoi(raw.Gap)
|
|
if err != nil {
|
|
return fmt.Errorf("Invalid gap value in identify output: %s", raw.Gap)
|
|
}
|
|
ans.Gap = max(0, ans.Gap)
|
|
}
|
|
area, pos, found := strings.Cut(raw.Canvas, "+")
|
|
ok := false
|
|
if found {
|
|
w, h, found := strings.Cut(area, "x")
|
|
if found {
|
|
ans.Canvas.Width, err = strconv.Atoi(w)
|
|
if err == nil {
|
|
ans.Canvas.Height, err = strconv.Atoi(h)
|
|
if err == nil {
|
|
x, y, found := strings.Cut(pos, "+")
|
|
if found {
|
|
ans.Canvas.Left, err = strconv.Atoi(x)
|
|
if err == nil {
|
|
if ans.Canvas.Top, err = strconv.Atoi(y); err == nil {
|
|
ok = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("Invalid canvas value in identify output: %s", raw.Canvas)
|
|
}
|
|
w, h, found := strings.Cut(raw.Size, "x")
|
|
ok = false
|
|
if found {
|
|
ans.Width, err = strconv.Atoi(w)
|
|
if err == nil {
|
|
if ans.Height, err = strconv.Atoi(h); err == nil {
|
|
ok = true
|
|
}
|
|
}
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("Invalid size value in identify output: %s", raw.Size)
|
|
}
|
|
x, y, found := strings.Cut(raw.Dpi, "x")
|
|
ok = false
|
|
if found {
|
|
ans.Dpi.X, err = strconv.ParseFloat(x, 64)
|
|
if err == nil {
|
|
if ans.Dpi.Y, err = strconv.ParseFloat(y, 64); err == nil {
|
|
ok = true
|
|
}
|
|
}
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("Invalid dpi value in identify output: %s", raw.Dpi)
|
|
}
|
|
ans.Index, err = strconv.Atoi(raw.Index)
|
|
if err != nil {
|
|
return fmt.Errorf("Invalid index value in identify output: %s", raw.Index)
|
|
}
|
|
q := strings.ToLower(raw.Transparency)
|
|
if q == "blend" || q == "true" {
|
|
ans.Is_opaque = false
|
|
} else {
|
|
ans.Is_opaque = true
|
|
}
|
|
ans.Needs_blend = q == "blend"
|
|
switch strings.ToLower(raw.Dispose) {
|
|
case "none", "undefined":
|
|
ans.Disposal = DisposeNone
|
|
case "background":
|
|
ans.Disposal = DisposeBackground
|
|
case "previous":
|
|
ans.Disposal = DisposePrevious
|
|
default:
|
|
return fmt.Errorf("Invalid value for dispose: %s", raw.Dispose)
|
|
}
|
|
ans.ColorSpace = raw.Colorspace
|
|
switch raw.Orientation {
|
|
case "5", "6", "7", "8":
|
|
ans.Dimensions_swapped = true
|
|
}
|
|
if ans.Dimensions_swapped {
|
|
ans.Canvas.Width, ans.Canvas.Height = ans.Canvas.Height, ans.Canvas.Width
|
|
ans.Width, ans.Height = ans.Height, ans.Width
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func identify(path *input) (ans []IdentifyRecord, err error) {
|
|
cmd := []string{"identify"}
|
|
q := `{"fmt":"%m","canvas":"%g","transparency":"%A","gap":"%T","index":"%p","size":"%wx%h",` +
|
|
`"dpi":"%xx%y","dispose":"%D","orientation":"%[EXIF:Orientation]","colorspace":"%[colorspace]"},`
|
|
cmd = append(cmd, "-format", q, "--", path.arg)
|
|
output, err := RunMagick(path, cmd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to identify image at path: %s with error: %w", path, err)
|
|
}
|
|
output = bytes.TrimRight(bytes.TrimSpace(output), ",")
|
|
raw_json := make([]byte, 0, len(output)+2)
|
|
raw_json = append(raw_json, '[')
|
|
raw_json = append(raw_json, output...)
|
|
raw_json = append(raw_json, ']')
|
|
var records []IdentifyOutput
|
|
err = json.Unmarshal(raw_json, &records)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("The ImageMagick identify program returned malformed output for the image at path: %s, with error: %w", path, err)
|
|
}
|
|
ans = make([]IdentifyRecord, len(records))
|
|
for i, rec := range records {
|
|
err = parse_identify_record(&ans[i], &rec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return ans, nil
|
|
}
|
|
|
|
type RenderOptions struct {
|
|
Background *color.RGBA64
|
|
ResizeTo image.Point
|
|
OnlyFirstFrame bool
|
|
AutoOrient bool
|
|
ToSRGB bool
|
|
Transform types.TransformType
|
|
RenderingIntent icc.RenderingIntent
|
|
BlackpointCompensation bool
|
|
}
|
|
|
|
func rgba64ToImageMagick(c_ color.RGBA64) string {
|
|
c := color.NRGBA64Model.Convert(c_).(color.NRGBA64)
|
|
rPercent := float64(c.R) / 65535.0 * 100.0
|
|
gPercent := float64(c.G) / 65535.0 * 100.0
|
|
bPercent := float64(c.B) / 65535.0 * 100.0
|
|
alpha := float64(c.A) / 65535.0
|
|
return fmt.Sprintf("rgba(%.3f%%,%.3f%%,%.3f%%,%.4f)", rPercent, gPercent, bPercent, alpha)
|
|
}
|
|
|
|
func is_not_srgb(name string) bool {
|
|
return name != "" && strings.ToUpper(name) != "SRGB"
|
|
}
|
|
|
|
func render(path *input, ro *RenderOptions, is_srgb bool, frames []IdentifyRecord) (ans []*ImageFrame, err error) {
|
|
cmd := []string{}
|
|
add_alpha_remove := false
|
|
if ro.Background == nil {
|
|
cmd = append(cmd, "-background", "none")
|
|
} else {
|
|
if ro.Background.A == 0xffff {
|
|
n := nrgb.Model.Convert(*ro.Background).(nrgb.Color)
|
|
add_alpha_remove = true
|
|
cmd = append(cmd, "-background", n.AsSharp())
|
|
} else {
|
|
cmd = append(cmd, "-background", rgba64ToImageMagick(*ro.Background))
|
|
}
|
|
}
|
|
cpath := path.arg
|
|
if ro.OnlyFirstFrame {
|
|
cpath += "[0]"
|
|
}
|
|
has_multiple_frames := len(frames) > 1
|
|
get_multiple_frames := has_multiple_frames && !ro.OnlyFirstFrame
|
|
cmd = append(cmd, "--", cpath)
|
|
if ro.AutoOrient {
|
|
cmd = append(cmd, "-auto-orient")
|
|
}
|
|
if add_alpha_remove {
|
|
cmd = append(cmd, "-alpha", "remove")
|
|
} else if ro.Background != nil {
|
|
cmd = append(cmd, "-flatten")
|
|
}
|
|
switch ro.Transform {
|
|
case types.FlipHTransform:
|
|
cmd = append(cmd, "-flop")
|
|
case types.FlipVTransform:
|
|
cmd = append(cmd, "-flip")
|
|
case types.TransposeTransform:
|
|
cmd = append(cmd, "-transpose")
|
|
case types.TransverseTransform:
|
|
cmd = append(cmd, "-transverse")
|
|
case types.Rotate90Transform:
|
|
cmd = append(cmd, "-rotate", "-90")
|
|
case types.Rotate180Transform:
|
|
cmd = append(cmd, "-rotate", "180")
|
|
case types.Rotate270Transform:
|
|
cmd = append(cmd, "-rotate", "-270")
|
|
}
|
|
tdir, err := os.MkdirTemp(TempDirInRAMIfPossible(), "")
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to create temporary directory to hold ImageMagick output with error: %w", err)
|
|
return
|
|
}
|
|
defer os.RemoveAll(tdir)
|
|
if ro.ToSRGB && !is_srgb {
|
|
profile_path := filepath.Join(tdir, "sRGB.icc")
|
|
if err = os.WriteFile(profile_path, icc.Srgb_xyz_profile_data, 0o666); err != nil {
|
|
return nil, fmt.Errorf("failed to create temporary file with profile for ImageMagick with error: %w", err)
|
|
}
|
|
|
|
cmd = append(cmd, icc.IfElse(ro.BlackpointCompensation, "-", "+")+"black-point-compensation")
|
|
cmd = append(cmd, "-intent", ro.RenderingIntent.String())
|
|
cmd = append(cmd, "-profile", profile_path)
|
|
}
|
|
if ro.ResizeTo.X > 0 {
|
|
rcmd := []string{"-resize", fmt.Sprintf("%dx%d!", ro.ResizeTo.X, ro.ResizeTo.Y)}
|
|
if get_multiple_frames {
|
|
cmd = append(cmd, "-coalesce")
|
|
cmd = append(cmd, rcmd...)
|
|
cmd = append(cmd, "-deconstruct")
|
|
} else {
|
|
cmd = append(cmd, rcmd...)
|
|
}
|
|
}
|
|
cmd = append(cmd, "-depth", "8", "-set", "filename:f", "%w-%h-%g-%p")
|
|
if get_multiple_frames {
|
|
cmd = append(cmd, "+adjoin")
|
|
}
|
|
mode := "rgba"
|
|
if frames[0].Is_opaque {
|
|
mode = "rgb"
|
|
}
|
|
cmd = append(cmd, filepath.Join(tdir, "im-%[filename:f]."+mode))
|
|
_, err = RunMagick(path, cmd)
|
|
if err != nil {
|
|
return
|
|
}
|
|
entries, err := os.ReadDir(tdir)
|
|
if err != nil {
|
|
err = fmt.Errorf("Failed to read temp dir used to store ImageMagick output with error: %w", err)
|
|
return
|
|
}
|
|
gaps := make([]int, len(frames))
|
|
for i, frame := range frames {
|
|
gaps[i] = frame.Gap
|
|
}
|
|
// although ImageMagick *might* be already taking care of this adjustment,
|
|
// I dont know for sure, so do it anyway.
|
|
min_gap := gifmeta.CalcMinimumGap(gaps)
|
|
for _, entry := range entries {
|
|
fname := entry.Name()
|
|
p, _, _ := strings.Cut(fname, ".")
|
|
parts := strings.Split(p, "-")
|
|
if len(parts) < 5 {
|
|
continue
|
|
}
|
|
index, cerr := strconv.Atoi(parts[len(parts)-1])
|
|
if cerr != nil || index < 0 || index >= len(frames) {
|
|
continue
|
|
}
|
|
width, cerr := strconv.Atoi(parts[1])
|
|
if cerr != nil {
|
|
continue
|
|
}
|
|
height, cerr := strconv.Atoi(parts[2])
|
|
if cerr != nil {
|
|
continue
|
|
}
|
|
_, pos, found := strings.Cut(parts[3], "+")
|
|
if !found {
|
|
continue
|
|
}
|
|
px, py, found := strings.Cut(pos, "+")
|
|
if !found {
|
|
// ImageMagick is a buggy POS
|
|
px, py = "0", "0"
|
|
}
|
|
x, cerr := strconv.Atoi(px)
|
|
if cerr != nil {
|
|
continue
|
|
}
|
|
y, cerr := strconv.Atoi(py)
|
|
if cerr != nil {
|
|
continue
|
|
}
|
|
identify_data := frames[index]
|
|
path := filepath.Join(tdir, fname)
|
|
frame := ImageFrame{
|
|
Number: index + 1, Width: width, Height: height, Left: x, Top: y, Is_opaque: identify_data.Is_opaque,
|
|
}
|
|
frame.Delay_ms = int32(max(min_gap, identify_data.Gap) * 10)
|
|
err = check_resize(&frame, path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to read temp file for image %#v with error: %w", path, err)
|
|
}
|
|
dest_rect := image.Rect(0, 0, frame.Width, frame.Height)
|
|
if frame.Is_opaque {
|
|
frame.Img = &nrgb.Image{Pix: data, Stride: frame.Width * 3, Rect: dest_rect}
|
|
} else {
|
|
frame.Img = &image.NRGBA{Pix: data, Stride: frame.Width * 4, Rect: dest_rect}
|
|
}
|
|
ans = append(ans, &frame)
|
|
}
|
|
if len(ans) < len(frames) {
|
|
err = fmt.Errorf("Failed to render %d out of %d frames", len(frames)-len(ans), len(frames))
|
|
return
|
|
}
|
|
slices.SortFunc(ans, func(a, b *ImageFrame) int { return a.Number - b.Number })
|
|
prev_disposal := DisposeBackground
|
|
prev_compose_onto := 0
|
|
for i, frame := range ans {
|
|
switch prev_disposal {
|
|
case DisposeNone:
|
|
frame.Compose_onto = frame.Number - 1
|
|
case DisposePrevious:
|
|
frame.Compose_onto = prev_compose_onto
|
|
}
|
|
prev_disposal, prev_compose_onto = frames[i].Disposal, frame.Compose_onto
|
|
}
|
|
return
|
|
}
|
|
|
|
type Image struct {
|
|
Frames []*ImageFrame
|
|
Format_uppercase string
|
|
}
|
|
|
|
type input struct {
|
|
arg string
|
|
os_file *os.File
|
|
needs_close bool
|
|
needs_remove string
|
|
}
|
|
|
|
func (i input) String() string {
|
|
return i.arg
|
|
}
|
|
|
|
func magick_input_path(i *types.Input) (inp *input, err error) {
|
|
if i.Path != "" {
|
|
return &input{arg: i.Path}, nil
|
|
}
|
|
r := i.Reader
|
|
if s, ok := r.(*os.File); ok {
|
|
if _, serr := s.Seek(0, io.SeekCurrent); serr == nil {
|
|
if s.Name() != "" {
|
|
if _, serr := os.Stat(s.Name()); serr == nil {
|
|
return &input{arg: s.Name()}, nil
|
|
}
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
return &input{arg: "fd:3", os_file: s}, nil
|
|
}
|
|
}
|
|
}
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if f, merr := memfd(data); merr == nil {
|
|
return &input{arg: "fd:3", os_file: f, needs_close: true}, nil
|
|
}
|
|
f, err := os.CreateTemp(TempDirInRAMIfPossible(), "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
if _, err = f.Write(data); err != nil {
|
|
return nil, err
|
|
}
|
|
return &input{arg: f.Name(), needs_remove: f.Name()}, nil
|
|
}
|
|
|
|
func OpenAll(input *types.Input, md *meta.Data, callback func(w, h int) RenderOptions) (ans *Image, err error) {
|
|
if !HasMagick() {
|
|
return nil, fmt.Errorf("the magick command as not found in PATH")
|
|
}
|
|
// ImageMagick needs to be told explicitly to use APNG otherwise it only returns the first frame
|
|
i, err := magick_input_path(input)
|
|
if i.os_file != nil && i.needs_close {
|
|
defer i.os_file.Close()
|
|
}
|
|
if i.needs_remove != "" {
|
|
defer os.Remove(i.needs_remove)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
is_apng := md != nil && md.Format == types.PNG && md.HasFrames
|
|
if is_apng {
|
|
i.arg = "APNG:" + i.arg
|
|
}
|
|
identify_records, err := identify(i)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
is_srgb := !is_not_srgb(identify_records[0].ColorSpace)
|
|
if is_srgb && md != nil {
|
|
// ImageMagick is a PoS that cant identify profiles
|
|
is_srgb = md.IsSRGB()
|
|
}
|
|
ro := callback(identify_records[0].Canvas.Width, identify_records[0].Canvas.Height)
|
|
frames, err := render(i, &ro, is_srgb, identify_records)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ans = &Image{
|
|
Format_uppercase: identify_records[0].Fmt_uppercase, Frames: frames,
|
|
}
|
|
return ans, nil
|
|
}
|