diff --git a/ansi/graphics.go b/ansi/graphics.go new file mode 100644 index 00000000..604fef47 --- /dev/null +++ b/ansi/graphics.go @@ -0,0 +1,199 @@ +package ansi + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "image" + "io" + "os" + "strings" + + "github.com/charmbracelet/x/ansi/kitty" +) + +// KittyGraphics returns a sequence that encodes the given image in the Kitty +// graphics protocol. +// +// APC G [comma separated options] ; [base64 encoded payload] ST +// +// See https://sw.kovidgoyal.net/kitty/graphics-protocol/ +func KittyGraphics(payload []byte, opts ...string) string { + var buf bytes.Buffer + buf.WriteString("\x1b_G") + buf.WriteString(strings.Join(opts, ",")) + if len(payload) > 0 { + buf.WriteString(";") + buf.Write(payload) + } + buf.WriteString("\x1b\\") + return buf.String() +} + +var ( + // KittyGraphicsTempDir is the directory where temporary files are stored. + // This is used in [WriteKittyGraphics] along with [os.CreateTemp]. + KittyGraphicsTempDir = "" + + // KittyGraphicsTempPattern is the pattern used to create temporary files. + // This is used in [WriteKittyGraphics] along with [os.CreateTemp]. + // The Kitty Graphics protocol requires the file path to contain the + // substring "tty-graphics-protocol". + KittyGraphicsTempPattern = "tty-graphics-protocol-*" +) + +// WriteKittyGraphics writes an image using the Kitty Graphics protocol with +// the given options to w. It chunks the written data if o.Chunk is true. +// +// You can omit m and use nil when rendering an image from a file. In this +// case, you must provide a file path in o.File and use o.Transmission = +// [kitty.File]. You can also use o.Transmission = [kitty.TempFile] to write +// the image to a temporary file. In that case, the file path is ignored, and +// the image is written to a temporary file that is automatically deleted by +// the terminal. +// +// See https://sw.kovidgoyal.net/kitty/graphics-protocol/ +func WriteKittyGraphics(w io.Writer, m image.Image, o *kitty.Options) error { + if o == nil { + o = &kitty.Options{} + } + + if o.Transmission == 0 && len(o.File) != 0 { + o.Transmission = kitty.File + } + + var data bytes.Buffer // the data to be encoded into base64 + e := &kitty.Encoder{ + Compress: o.Compression == kitty.Zlib, + Format: o.Format, + } + + switch o.Transmission { + case kitty.Direct: + if err := e.Encode(&data, m); err != nil { + return fmt.Errorf("failed to encode direct image: %w", err) + } + + case kitty.SharedMemory: + // TODO: Implement shared memory + return fmt.Errorf("shared memory transmission is not yet implemented") + + case kitty.File: + if len(o.File) == 0 { + return kitty.ErrMissingFile + } + + f, err := os.Open(o.File) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + + defer f.Close() //nolint:errcheck + + stat, err := f.Stat() + if err != nil { + return fmt.Errorf("failed to get file info: %w", err) + } + + mode := stat.Mode() + if !mode.IsRegular() { + return fmt.Errorf("file is not a regular file") + } + + // Write the file path to the buffer + if _, err := data.WriteString(f.Name()); err != nil { + return fmt.Errorf("failed to write file path to buffer: %w", err) + } + + case kitty.TempFile: + f, err := os.CreateTemp(KittyGraphicsTempDir, KittyGraphicsTempPattern) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + defer f.Close() //nolint:errcheck + + if err := e.Encode(f, m); err != nil { + return fmt.Errorf("failed to encode image to file: %w", err) + } + + // Write the file path to the buffer + if _, err := data.WriteString(f.Name()); err != nil { + return fmt.Errorf("failed to write file path to buffer: %w", err) + } + } + + // Encode image to base64 + var payload bytes.Buffer // the base64 encoded image to be written to w + b64 := base64.NewEncoder(base64.StdEncoding, &payload) + if _, err := data.WriteTo(b64); err != nil { + return fmt.Errorf("failed to write base64 encoded image to payload: %w", err) + } + if err := b64.Close(); err != nil { + return err + } + + // If not chunking, write all at once + if !o.Chunk { + _, err := io.WriteString(w, KittyGraphics(payload.Bytes(), o.Options()...)) + return err + } + + // Write in chunks + var ( + err error + n int + ) + chunk := make([]byte, kitty.MaxChunkSize) + isFirstChunk := true + + for { + // Stop if we read less than the chunk size [kitty.MaxChunkSize]. + n, err = io.ReadFull(&payload, chunk) + if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("failed to read chunk: %w", err) + } + + opts := buildChunkOptions(o, isFirstChunk, false) + if _, err := io.WriteString(w, KittyGraphics(chunk[:n], opts...)); err != nil { + return err + } + + isFirstChunk = false + } + + // Write the last chunk + opts := buildChunkOptions(o, isFirstChunk, true) + _, err = io.WriteString(w, KittyGraphics(chunk[:n], opts...)) + return err +} + +// buildChunkOptions creates the options slice for a chunk +func buildChunkOptions(o *kitty.Options, isFirstChunk, isLastChunk bool) []string { + var opts []string + if isFirstChunk { + opts = o.Options() + } else { + // These options are allowed in subsequent chunks + if o.Quite > 0 { + opts = append(opts, fmt.Sprintf("q=%d", o.Quite)) + } + if o.Action == kitty.Frame { + opts = append(opts, "a=f") + } + } + + if !isFirstChunk || !isLastChunk { + // We don't need to encode the (m=) option when we only have one chunk. + if isLastChunk { + opts = append(opts, "m=0") + } else { + opts = append(opts, "m=1") + } + } + return opts +} diff --git a/ansi/kitty/decoder.go b/ansi/kitty/decoder.go new file mode 100644 index 00000000..fbd08441 --- /dev/null +++ b/ansi/kitty/decoder.go @@ -0,0 +1,85 @@ +package kitty + +import ( + "compress/zlib" + "fmt" + "image" + "image/color" + "image/png" + "io" +) + +// Decoder is a decoder for the Kitty graphics protocol. It supports decoding +// images in the 24-bit [RGB], 32-bit [RGBA], and [PNG] formats. It can also +// decompress data using zlib. +// The default format is 32-bit [RGBA]. +type Decoder struct { + // Uses zlib decompression. + Decompress bool + + // Can be one of [RGB], [RGBA], or [PNG]. + Format int + + // Width of the image in pixels. This can be omitted if the image is [PNG] + // formatted. + Width int + + // Height of the image in pixels. This can be omitted if the image is [PNG] + // formatted. + Height int +} + +// Decode decodes the image data from r in the specified format. +func (d *Decoder) Decode(r io.Reader) (image.Image, error) { + if d.Decompress { + zr, err := zlib.NewReader(r) + if err != nil { + return nil, fmt.Errorf("failed to create zlib reader: %w", err) + } + + defer zr.Close() //nolint:errcheck + r = zr + } + + if d.Format == 0 { + d.Format = RGBA + } + + switch d.Format { + case RGBA, RGB: + return d.decodeRGBA(r, d.Format == RGBA) + + case PNG: + return png.Decode(r) + + default: + return nil, fmt.Errorf("unsupported format: %d", d.Format) + } +} + +// decodeRGBA decodes the image data in 32-bit RGBA or 24-bit RGB formats. +func (d *Decoder) decodeRGBA(r io.Reader, alpha bool) (image.Image, error) { + m := image.NewRGBA(image.Rect(0, 0, d.Width, d.Height)) + + var buf []byte + if alpha { + buf = make([]byte, 4) + } else { + buf = make([]byte, 3) + } + + for y := 0; y < d.Height; y++ { + for x := 0; x < d.Width; x++ { + if _, err := io.ReadFull(r, buf[:]); err != nil { + return nil, fmt.Errorf("failed to read pixel data: %w", err) + } + if alpha { + m.SetRGBA(x, y, color.RGBA{buf[0], buf[1], buf[2], buf[3]}) + } else { + m.SetRGBA(x, y, color.RGBA{buf[0], buf[1], buf[2], 0xff}) + } + } + } + + return m, nil +} diff --git a/ansi/kitty/encoder.go b/ansi/kitty/encoder.go new file mode 100644 index 00000000..f668b9e3 --- /dev/null +++ b/ansi/kitty/encoder.go @@ -0,0 +1,64 @@ +package kitty + +import ( + "compress/zlib" + "fmt" + "image" + "image/png" + "io" +) + +// Encoder is an encoder for the Kitty graphics protocol. It supports encoding +// images in the 24-bit [RGB], 32-bit [RGBA], and [PNG] formats, and +// compressing the data using zlib. +// The default format is 32-bit [RGBA]. +type Encoder struct { + // Uses zlib compression. + Compress bool + + // Can be one of [RGBA], [RGB], or [PNG]. + Format int +} + +// Encode encodes the image data in the specified format and writes it to w. +func (e *Encoder) Encode(w io.Writer, m image.Image) error { + if m == nil { + return nil + } + + if e.Compress { + zw := zlib.NewWriter(w) + defer zw.Close() //nolint:errcheck + w = zw + } + + if e.Format == 0 { + e.Format = RGBA + } + + switch e.Format { + case RGBA, RGB: + bounds := m.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, a := m.At(x, y).RGBA() + switch e.Format { + case RGBA: + w.Write([]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)}) //nolint:errcheck + case RGB: + w.Write([]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8)}) //nolint:errcheck + } + } + } + + case PNG: + if err := png.Encode(w, m); err != nil { + return fmt.Errorf("failed to encode PNG: %w", err) + } + + default: + return fmt.Errorf("unsupported format: %d", e.Format) + } + + return nil +} diff --git a/ansi/kitty/graphics.go b/ansi/kitty/graphics.go new file mode 100644 index 00000000..490e7a8a --- /dev/null +++ b/ansi/kitty/graphics.go @@ -0,0 +1,414 @@ +package kitty + +import "errors" + +// ErrMissingFile is returned when the file path is missing. +var ErrMissingFile = errors.New("missing file path") + +// MaxChunkSize is the maximum chunk size for the image data. +const MaxChunkSize = 1024 * 4 + +// Placeholder is a special Unicode character that can be used as a placeholder +// for an image. +const Placeholder = '\U0010EEEE' + +// Graphics image format. +const ( + // 32-bit RGBA format. + RGBA = 32 + + // 24-bit RGB format. + RGB = 24 + + // PNG format. + PNG = 100 +) + +// Compression types. +const ( + Zlib = 'z' +) + +// Transmission types. +const ( + // The data transmitted directly in the escape sequence. + Direct = 'd' + + // The data transmitted in a regular file. + File = 'f' + + // A temporary file is used and deleted after transmission. + TempFile = 't' + + // A shared memory object. + // For POSIX see https://pubs.opengroup.org/onlinepubs/9699919799/functions/shm_open.html + // For Windows see https://docs.microsoft.com/en-us/windows/win32/memory/creating-named-shared-memory + SharedMemory = 's' +) + +// Action types. +const ( + // Transmit image data. + Transmit = 't' + // TransmitAndPut transmit image data and display (put) it. + TransmitAndPut = 'T' + // Query terminal for image info. + Query = 'q' + // Put (display) previously transmitted image. + Put = 'p' + // Delete image. + Delete = 'd' + // Frame transmits data for animation frames. + Frame = 'f' + // Animate controls animation. + Animate = 'a' + // Compose composes animation frames. + Compose = 'c' +) + +// Delete types. +const ( + // Delete all placements visible on screen + DeleteAll = 'a' + // Delete all images with the specified id, specified using the i key. If + // you specify a p key for the placement id as well, then only the + // placement with the specified image id and placement id will be deleted. + DeleteID = 'i' + // Delete newest image with the specified number, specified using the I + // key. If you specify a p key for the placement id as well, then only the + // placement with the specified number and placement id will be deleted. + DeleteNumber = 'n' + // Delete all placements that intersect with the current cursor position. + DeleteCursor = 'c' + // Delete animation frames. + DeleteFrames = 'f' + // Delete all placements that intersect a specific cell, the cell is + // specified using the x and y keys + DeleteCell = 'p' + // Delete all placements that intersect a specific cell having a specific + // z-index. The cell and z-index is specified using the x, y and z keys. + DeleteCellZ = 'q' + // Delete all images whose id is greater than or equal to the value of the x + // key and less than or equal to the value of the y. + DeleteRange = 'r' + // Delete all placements that intersect the specified column, specified using + // the x key. + DeleteColumn = 'x' + // Delete all placements that intersect the specified row, specified using + // the y key. + DeleteRow = 'y' + // Delete all placements that have the specified z-index, specified using the + // z key. + DeleteZ = 'z' +) + +// Diacritic returns the diacritic rune at the specified index. If the index is +// out of bounds, the first diacritic rune is returned. +func Diacritic(i int) rune { + if i < 0 || i >= len(diacritics) { + return diacritics[0] + } + return diacritics[i] +} + +// From https://sw.kovidgoyal.net/kitty/_downloads/f0a0de9ec8d9ff4456206db8e0814937/rowcolumn-diacritics.txt +// See https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders for further explanation. +var diacritics = []rune{ + '\u0305', + '\u030D', + '\u030E', + '\u0310', + '\u0312', + '\u033D', + '\u033E', + '\u033F', + '\u0346', + '\u034A', + '\u034B', + '\u034C', + '\u0350', + '\u0351', + '\u0352', + '\u0357', + '\u035B', + '\u0363', + '\u0364', + '\u0365', + '\u0366', + '\u0367', + '\u0368', + '\u0369', + '\u036A', + '\u036B', + '\u036C', + '\u036D', + '\u036E', + '\u036F', + '\u0483', + '\u0484', + '\u0485', + '\u0486', + '\u0487', + '\u0592', + '\u0593', + '\u0594', + '\u0595', + '\u0597', + '\u0598', + '\u0599', + '\u059C', + '\u059D', + '\u059E', + '\u059F', + '\u05A0', + '\u05A1', + '\u05A8', + '\u05A9', + '\u05AB', + '\u05AC', + '\u05AF', + '\u05C4', + '\u0610', + '\u0611', + '\u0612', + '\u0613', + '\u0614', + '\u0615', + '\u0616', + '\u0617', + '\u0657', + '\u0658', + '\u0659', + '\u065A', + '\u065B', + '\u065D', + '\u065E', + '\u06D6', + '\u06D7', + '\u06D8', + '\u06D9', + '\u06DA', + '\u06DB', + '\u06DC', + '\u06DF', + '\u06E0', + '\u06E1', + '\u06E2', + '\u06E4', + '\u06E7', + '\u06E8', + '\u06EB', + '\u06EC', + '\u0730', + '\u0732', + '\u0733', + '\u0735', + '\u0736', + '\u073A', + '\u073D', + '\u073F', + '\u0740', + '\u0741', + '\u0743', + '\u0745', + '\u0747', + '\u0749', + '\u074A', + '\u07EB', + '\u07EC', + '\u07ED', + '\u07EE', + '\u07EF', + '\u07F0', + '\u07F1', + '\u07F3', + '\u0816', + '\u0817', + '\u0818', + '\u0819', + '\u081B', + '\u081C', + '\u081D', + '\u081E', + '\u081F', + '\u0820', + '\u0821', + '\u0822', + '\u0823', + '\u0825', + '\u0826', + '\u0827', + '\u0829', + '\u082A', + '\u082B', + '\u082C', + '\u082D', + '\u0951', + '\u0953', + '\u0954', + '\u0F82', + '\u0F83', + '\u0F86', + '\u0F87', + '\u135D', + '\u135E', + '\u135F', + '\u17DD', + '\u193A', + '\u1A17', + '\u1A75', + '\u1A76', + '\u1A77', + '\u1A78', + '\u1A79', + '\u1A7A', + '\u1A7B', + '\u1A7C', + '\u1B6B', + '\u1B6D', + '\u1B6E', + '\u1B6F', + '\u1B70', + '\u1B71', + '\u1B72', + '\u1B73', + '\u1CD0', + '\u1CD1', + '\u1CD2', + '\u1CDA', + '\u1CDB', + '\u1CE0', + '\u1DC0', + '\u1DC1', + '\u1DC3', + '\u1DC4', + '\u1DC5', + '\u1DC6', + '\u1DC7', + '\u1DC8', + '\u1DC9', + '\u1DCB', + '\u1DCC', + '\u1DD1', + '\u1DD2', + '\u1DD3', + '\u1DD4', + '\u1DD5', + '\u1DD6', + '\u1DD7', + '\u1DD8', + '\u1DD9', + '\u1DDA', + '\u1DDB', + '\u1DDC', + '\u1DDD', + '\u1DDE', + '\u1DDF', + '\u1DE0', + '\u1DE1', + '\u1DE2', + '\u1DE3', + '\u1DE4', + '\u1DE5', + '\u1DE6', + '\u1DFE', + '\u20D0', + '\u20D1', + '\u20D4', + '\u20D5', + '\u20D6', + '\u20D7', + '\u20DB', + '\u20DC', + '\u20E1', + '\u20E7', + '\u20E9', + '\u20F0', + '\u2CEF', + '\u2CF0', + '\u2CF1', + '\u2DE0', + '\u2DE1', + '\u2DE2', + '\u2DE3', + '\u2DE4', + '\u2DE5', + '\u2DE6', + '\u2DE7', + '\u2DE8', + '\u2DE9', + '\u2DEA', + '\u2DEB', + '\u2DEC', + '\u2DED', + '\u2DEE', + '\u2DEF', + '\u2DF0', + '\u2DF1', + '\u2DF2', + '\u2DF3', + '\u2DF4', + '\u2DF5', + '\u2DF6', + '\u2DF7', + '\u2DF8', + '\u2DF9', + '\u2DFA', + '\u2DFB', + '\u2DFC', + '\u2DFD', + '\u2DFE', + '\u2DFF', + '\uA66F', + '\uA67C', + '\uA67D', + '\uA6F0', + '\uA6F1', + '\uA8E0', + '\uA8E1', + '\uA8E2', + '\uA8E3', + '\uA8E4', + '\uA8E5', + '\uA8E6', + '\uA8E7', + '\uA8E8', + '\uA8E9', + '\uA8EA', + '\uA8EB', + '\uA8EC', + '\uA8ED', + '\uA8EE', + '\uA8EF', + '\uA8F0', + '\uA8F1', + '\uAAB0', + '\uAAB2', + '\uAAB3', + '\uAAB7', + '\uAAB8', + '\uAABE', + '\uAABF', + '\uAAC1', + '\uFE20', + '\uFE21', + '\uFE22', + '\uFE23', + '\uFE24', + '\uFE25', + '\uFE26', + '\U00010A0F', + '\U00010A38', + '\U0001D185', + '\U0001D186', + '\U0001D187', + '\U0001D188', + '\U0001D189', + '\U0001D1AA', + '\U0001D1AB', + '\U0001D1AC', + '\U0001D1AD', + '\U0001D242', + '\U0001D243', + '\U0001D244', +} diff --git a/ansi/kitty/options.go b/ansi/kitty/options.go new file mode 100644 index 00000000..9e0cfa5d --- /dev/null +++ b/ansi/kitty/options.go @@ -0,0 +1,359 @@ +package kitty + +import ( + "encoding" + "fmt" + "strconv" + "strings" +) + +var ( + _ encoding.TextMarshaler = Options{} + _ encoding.TextUnmarshaler = &Options{} +) + +// Options represents a Kitty Graphics Protocol options. +type Options struct { + // Common options. + + // Action (a=t) is the action to be performed on the image. Can be one of + // [Transmit], [TransmitDisplay], [Query], [Put], [Delete], [Frame], + // [Animate], [Compose]. + Action byte + + // Quite mode (q=0) is the quiet mode. Can be either zero, one, or two + // where zero is the default, 1 suppresses OK responses, and 2 suppresses + // both OK and error responses. + Quite byte + + // Transmission options. + + // ID (i=) is the image ID. The ID is a unique identifier for the image. + // Must be a positive integer up to [math.MaxUint32]. + ID int + + // PlacementID (p=) is the placement ID. The placement ID is a unique + // identifier for the placement of the image. Must be a positive integer up + // to [math.MaxUint32]. + PlacementID int + + // Number (I=0) is the number of images to be transmitted. + Number int + + // Format (f=32) is the image format. One of [RGBA], [RGB], [PNG]. + Format int + + // ImageWidth (s=0) is the transmitted image width. + ImageWidth int + + // ImageHeight (v=0) is the transmitted image height. + ImageHeight int + + // Compression (o=) is the image compression type. Can be [Zlib] or zero. + Compression byte + + // Transmission (t=d) is the image transmission type. Can be [Direct], [File], + // [TempFile], or[SharedMemory]. + Transmission byte + + // File is the file path to be used when the transmission type is [File]. + // If [Options.Transmission] is omitted i.e. zero and this is non-empty, + // the transmission type is set to [File]. + File string + + // Size (S=0) is the size to be read from the transmission medium. + Size int + + // Offset (O=0) is the offset byte to start reading from the transmission + // medium. + Offset int + + // Chunk (m=) whether the image is transmitted in chunks. Can be either + // zero or one. When true, the image is transmitted in chunks. Each chunk + // must be a multiple of 4, and up to [MaxChunkSize] bytes. Each chunk must + // have the m=1 option except for the last chunk which must have m=0. + Chunk bool + + // Display options. + + // X (x=0) is the pixel X coordinate of the image to start displaying. + X int + + // Y (y=0) is the pixel Y coordinate of the image to start displaying. + Y int + + // Z (z=0) is the Z coordinate of the image to display. + Z int + + // Width (w=0) is the width of the image to display. + Width int + + // Height (h=0) is the height of the image to display. + Height int + + // OffsetX (X=0) is the OffsetX coordinate of the cursor cell to start + // displaying the image. OffsetX=0 is the leftmost cell. This must be + // smaller than the terminal cell width. + OffsetX int + + // OffsetY (Y=0) is the OffsetY coordinate of the cursor cell to start + // displaying the image. OffsetY=0 is the topmost cell. This must be + // smaller than the terminal cell height. + OffsetY int + + // Columns (c=0) is the number of columns to display the image. The image + // will be scaled to fit the number of columns. + Columns int + + // Rows (r=0) is the number of rows to display the image. The image will be + // scaled to fit the number of rows. + Rows int + + // VirtualPlacement (U=0) whether to use virtual placement. This is used + // with Unicode [Placeholder] to display images. + VirtualPlacement bool + + // ParentID (P=0) is the parent image ID. The parent ID is the ID of the + // image that is the parent of the current image. This is used with Unicode + // [Placeholder] to display images relative to the parent image. + ParentID int + + // ParentPlacementID (Q=0) is the parent placement ID. The parent placement + // ID is the ID of the placement of the parent image. This is used with + // Unicode [Placeholder] to display images relative to the parent image. + ParentPlacementID int + + // Delete options. + + // Delete (d=a) is the delete action. Can be one of [DeleteAll], + // [DeleteID], [DeleteNumber], [DeleteCursor], [DeleteFrames], + // [DeleteCell], [DeleteCellZ], [DeleteRange], [DeleteColumn], [DeleteRow], + // [DeleteZ]. + Delete byte + + // DeleteResources indicates whether to delete the resources associated + // with the image. + DeleteResources bool +} + +// Options returns the options as a slice of a key-value pairs. +func (o *Options) Options() (opts []string) { + opts = []string{} + if o.Format == 0 { + o.Format = RGBA + } + + if o.Action == 0 { + o.Action = Transmit + } + + if o.Delete == 0 { + o.Delete = DeleteAll + } + + if o.Transmission == 0 { + if len(o.File) > 0 { + o.Transmission = File + } else { + o.Transmission = Direct + } + } + + if o.Format != RGBA { + opts = append(opts, fmt.Sprintf("f=%d", o.Format)) + } + + if o.Quite > 0 { + opts = append(opts, fmt.Sprintf("q=%d", o.Quite)) + } + + if o.ID > 0 { + opts = append(opts, fmt.Sprintf("i=%d", o.ID)) + } + + if o.PlacementID > 0 { + opts = append(opts, fmt.Sprintf("p=%d", o.PlacementID)) + } + + if o.Number > 0 { + opts = append(opts, fmt.Sprintf("I=%d", o.Number)) + } + + if o.ImageWidth > 0 { + opts = append(opts, fmt.Sprintf("s=%d", o.ImageWidth)) + } + + if o.ImageHeight > 0 { + opts = append(opts, fmt.Sprintf("v=%d", o.ImageHeight)) + } + + if o.Transmission != Direct { + opts = append(opts, fmt.Sprintf("t=%c", o.Transmission)) + } + + if o.Size > 0 { + opts = append(opts, fmt.Sprintf("S=%d", o.Size)) + } + + if o.Offset > 0 { + opts = append(opts, fmt.Sprintf("O=%d", o.Offset)) + } + + if o.Compression == Zlib { + opts = append(opts, fmt.Sprintf("o=%c", o.Compression)) + } + + if o.VirtualPlacement { + opts = append(opts, "U=1") + } + + if o.ParentID > 0 { + opts = append(opts, fmt.Sprintf("P=%d", o.ParentID)) + } + + if o.ParentPlacementID > 0 { + opts = append(opts, fmt.Sprintf("Q=%d", o.ParentPlacementID)) + } + + if o.X > 0 { + opts = append(opts, fmt.Sprintf("x=%d", o.X)) + } + + if o.Y > 0 { + opts = append(opts, fmt.Sprintf("y=%d", o.Y)) + } + + if o.Z > 0 { + opts = append(opts, fmt.Sprintf("z=%d", o.Z)) + } + + if o.Width > 0 { + opts = append(opts, fmt.Sprintf("w=%d", o.Width)) + } + + if o.Height > 0 { + opts = append(opts, fmt.Sprintf("h=%d", o.Height)) + } + + if o.OffsetX > 0 { + opts = append(opts, fmt.Sprintf("X=%d", o.OffsetX)) + } + + if o.OffsetY > 0 { + opts = append(opts, fmt.Sprintf("Y=%d", o.OffsetY)) + } + + if o.Columns > 0 { + opts = append(opts, fmt.Sprintf("c=%d", o.Columns)) + } + + if o.Rows > 0 { + opts = append(opts, fmt.Sprintf("r=%d", o.Rows)) + } + + if o.Delete != DeleteAll || o.DeleteResources { + da := o.Delete + if o.DeleteResources { + da = da - ' ' // to uppercase + } + + opts = append(opts, fmt.Sprintf("d=%c", da)) + } + + if o.Action != Transmit { + opts = append(opts, fmt.Sprintf("a=%c", o.Action)) + } + + return +} + +// String returns the string representation of the options. +func (o Options) String() string { + return strings.Join(o.Options(), ",") +} + +// MarshalText returns the string representation of the options. +func (o Options) MarshalText() ([]byte, error) { + return []byte(o.String()), nil +} + +// UnmarshalText parses the options from the given string. +func (o *Options) UnmarshalText(text []byte) error { + opts := strings.Split(string(text), ",") + for _, opt := range opts { + ps := strings.SplitN(opt, "=", 2) + if len(ps) != 2 || len(ps[1]) == 0 { + continue + } + + switch ps[0] { + case "a": + o.Action = ps[1][0] + case "o": + o.Compression = ps[1][0] + case "t": + o.Transmission = ps[1][0] + case "d": + d := ps[1][0] + if d >= 'A' && d <= 'Z' { + o.DeleteResources = true + d = d + ' ' // to lowercase + } + o.Delete = d + case "i", "q", "p", "I", "f", "s", "v", "S", "O", "m", "x", "y", "z", "w", "h", "X", "Y", "c", "r", "U", "P", "Q": + v, err := strconv.Atoi(ps[1]) + if err != nil { + continue + } + + switch ps[0] { + case "i": + o.ID = v + case "q": + o.Quite = byte(v) + case "p": + o.PlacementID = v + case "I": + o.Number = v + case "f": + o.Format = v + case "s": + o.ImageWidth = v + case "v": + o.ImageHeight = v + case "S": + o.Size = v + case "O": + o.Offset = v + case "m": + o.Chunk = v == 0 || v == 1 + case "x": + o.X = v + case "y": + o.Y = v + case "z": + o.Z = v + case "w": + o.Width = v + case "h": + o.Height = v + case "X": + o.OffsetX = v + case "Y": + o.OffsetY = v + case "c": + o.Columns = v + case "r": + o.Rows = v + case "U": + o.VirtualPlacement = v == 1 + case "P": + o.ParentID = v + case "Q": + o.ParentPlacementID = v + } + } + } + + return nil +}