From b9cea7d6d4eb6eb732d0e0c8d144844ed7be22dd Mon Sep 17 00:00:00 2001 From: Lea Date: Wed, 27 May 2026 11:55:57 +0400 Subject: [PATCH] feat: data URI and remote fetch Co-authored-by: Steve Evans Signed-off-by: drew --- README.md | 18 ++++++ docs/content/api.mdx | 26 +++++++-- docs/content/getting-started.mdx | 27 +++++++++ docs/content/introduction.mdx | 6 +- docs/content/sandbox.mdx | 34 ++++++++--- internal/source/source.go | 99 ++++++++++++++++++++++++++++++++ internal/source/source_test.go | 86 +++++++++++++++++++++++++++ sandbox/sandbox.go | 91 +++++++++++++++++++++++++++-- sandbox/sandbox_linux.go | 11 +++- termimage.go | 37 +++++++++--- termimage_test.go | 57 ++++++++++++++++++ 11 files changed, 463 insertions(+), 29 deletions(-) create mode 100644 internal/source/source.go create mode 100644 internal/source/source_test.go diff --git a/README.md b/README.md index 9f5bb6b..93f0b2f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ ## Features - **Auto-detected protocols** — Kitty, Sixel, half-block fallback. Works on any modern terminal. +- **Multiple sources** — local files, `data:` URIs (base64), and `http(s)://` URLs. - **Sandboxed decoder** — image bytes parsed in an isolated subprocess with Landlock + seccomp on Linux. - **No CGO required for the consumer** — pure-Go API surface; the C decoder is contained in the worker subprocess. - **Terminal pixel detection** — sizes output to the actual cell pixel dimensions when available. @@ -55,6 +56,23 @@ func main() { } ``` +### Sources + +The `src` argument accepts any of: + +```go +termimage.Display(os.Stdout, "/path/to/cat.png", opts) +termimage.Display(os.Stdout, "https://example.com/cat.png", opts) +termimage.Display(os.Stdout, "data:image/png;base64,iVBORw0KGgo...", opts) +``` + +Remote URLs and data URIs are fetched/decoded in the parent process, then handed +to the sandboxed worker over stdin — the worker still runs with Landlock denying +all filesystem access. Remote payloads are capped at 64 MiB. + +Use `DisplayContext` to pass a `context.Context` for cancellation of HTTP fetches +and decoding. + ### Options | Field | Description | diff --git a/docs/content/api.mdx b/docs/content/api.mdx index ab7705e..3f2eba9 100644 --- a/docs/content/api.mdx +++ b/docs/content/api.mdx @@ -7,11 +7,28 @@ title: API Reference ## `termimage.Display` ```go -func Display(w io.Writer, path string, opts Options) error +func Display(w io.Writer, src string, opts Options) error ``` -Decodes the image at `path` and writes terminal graphics to `w`. Returns the -first error encountered (file open, decode, or render failure). +Decodes the image at `src` and writes terminal graphics to `w`. `src` may be: + +- a local file path (`/path/to/cat.png`) +- an `http(s)://` URL +- a `data:` URI with a base64 payload (`data:image/png;base64,...`) + +Remote URLs and data URIs are fetched/decoded in the parent process, then +piped to the sandboxed worker over stdin. Payloads are capped at **64 MiB**. + +Returns the first error encountered (fetch, decode, or render failure). + +## `termimage.DisplayContext` + +```go +func DisplayContext(ctx context.Context, w io.Writer, src string, opts Options) error +``` + +Same as `Display` but takes a `context.Context` for cancellation of HTTP +fetches and sandboxed decoding. ## `termimage.Options` @@ -47,5 +64,6 @@ image, writes raw RGBA to stdout, and calls `os.Exit(0)`. | `decode` | CGo stb_image binding. `decode.File(path)` and `decode.Bytes(data)` | | `detect` | Protocol detection. `detect.Best()` | | `render` | `render.Kitty`, `render.Sixel`, `render.HalfBlock` | -| `sandbox` | Subprocess worker. `sandbox.Decode(path)` | +| `sandbox` | Subprocess worker. `sandbox.Decode(path)`, `sandbox.DecodeBytes(data)` | +| `internal/source` | Source resolver. Branches file path / `data:` URI / `http(s)://` URL | | `internal/resize` | `resize.Fit(img, maxW, maxH)` — BiLinear scale | diff --git a/docs/content/getting-started.mdx b/docs/content/getting-started.mdx index b6039e8..a34ef2d 100644 --- a/docs/content/getting-started.mdx +++ b/docs/content/getting-started.mdx @@ -61,3 +61,30 @@ termimage.Display(os.Stdout, path, termimage.Options{ MaxHeight: 1080, }) ``` + +## Remote images and data URIs + +`Display` accepts any of these as `src`: + +```go +// Local file +termimage.Display(os.Stdout, "/path/to/cat.png", opts) + +// Remote URL +termimage.Display(os.Stdout, "https://example.com/cat.png", opts) + +// Data URI (base64 only) +termimage.Display(os.Stdout, "data:image/png;base64,iVBORw0KGgo...", opts) +``` + +Remote URLs are fetched in the parent process; payloads cap at 64 MiB. The +sandboxed worker still runs — bytes are piped to it over stdin with Landlock +denying all filesystem access. + +For cancellable fetches, use `DisplayContext`: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() +termimage.DisplayContext(ctx, os.Stdout, url, opts) +``` diff --git a/docs/content/introduction.mdx b/docs/content/introduction.mdx index 217e43e..d67e4e5 100644 --- a/docs/content/introduction.mdx +++ b/docs/content/introduction.mdx @@ -12,11 +12,13 @@ the best protocol your terminal supports. - **Three render modes** — Kitty graphics, DEC Sixel, Unicode half-block fallback. Auto-detected from environment. +- **Multiple sources** — local files, `data:` URIs (base64), and `http(s)://` + URLs. Remote payloads capped at 64 MiB. - **CGo decode via stb_image** — single-header C library compiled with `-O3`. Significantly faster than Go's stdlib `image` package for large JPEG/PNG. - **Subprocess sandbox** — decode runs in an isolated child process with - Landlock filesystem restriction (read-only access to the target file only). - Seccomp allowlist is upcoming. + Landlock filesystem restriction (read-only access to the target file only, + or full lockdown for remote/data sources). Seccomp allowlist is upcoming. - **Zero forced dependencies** — Kitty and half-block use only stdlib. Sixel quantization is pure Go median-cut. The sandbox uses `golang.org/x/sys`. diff --git a/docs/content/sandbox.mdx b/docs/content/sandbox.mdx index 17bc305..f8c41ae 100644 --- a/docs/content/sandbox.mdx +++ b/docs/content/sandbox.mdx @@ -16,14 +16,18 @@ do. When `Sandboxed: true`: -1. The parent process spawns `os.Executable()` as a subprocess with - `TERMIMAGE_WORKER=1` and `TERMIMAGE_WORKER_PATH=` in the environment. -2. The child calls `MaybeRunWorker()`, which detects the env var and takes +1. The parent resolves the source (file / `data:` URI / `http(s)://` URL). + Remote fetches and base64 decode happen here, **outside** the sandbox. +2. The parent spawns `os.Executable()` as a subprocess with `TERMIMAGE_WORKER=1` + and `TERMIMAGE_WORKER_MODE=path|stdin` in the environment. For path mode + it also sets `TERMIMAGE_WORKER_PATH=`; for stdin mode it pipes the + bytes to the child's stdin. +3. The child calls `MaybeRunWorker()`, which detects the env var and takes over. -3. **Before opening the file**, the child applies OS restrictions. -4. The child reads, decodes, and writes raw RGBA pixels (`width[4] + height[4] - + pixels`) to stdout. -5. The parent reads the pixel data over the pipe and renders it. +4. **Before touching any input**, the child applies OS restrictions. +5. The child decodes and writes raw RGBA pixels (`width[4] + height[4] + + pixels`) to stdout. +6. The parent reads the pixel data over the pipe and renders it. > [!CAUTION] > The sandboxed child re-execs **your binary**, not a dedicated helper. If your @@ -33,15 +37,29 @@ When `Sandboxed: true`: ## Landlock (Linux ≥5.13) -Landlock restricts filesystem access to the target file only, read-only: +In **path mode** (local file), Landlock restricts filesystem access to the +target file only, read-only: ```go landlock.V3.BestEffort().RestrictPaths(landlock.ROFiles(path)) ``` +In **stdin mode** (remote URL / data URI), no file access is needed — the +worker reads bytes from the pipe — so Landlock is configured with no granted +paths, denying all filesystem access: + +```go +landlock.V3.BestEffort().RestrictPaths() // empty = deny-all +``` + `BestEffort()` silently degrades on older kernels — the binary still runs, just without Landlock protection. +> [!NOTE] +> Network access for remote URLs happens **in the parent**, before the +> sandboxed child is spawned. The child itself never gets to make network +> calls — the bytes are already in memory by the time it starts. + ## Seccomp (upcoming) A syscall allowlist via BPF is in progress. The allowlist must accommodate the diff --git a/internal/source/source.go b/internal/source/source.go new file mode 100644 index 0000000..363a904 --- /dev/null +++ b/internal/source/source.go @@ -0,0 +1,99 @@ +// Package source resolves image sources: file paths, data URIs, and remote URLs. +package source + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// Kind tags how the source should be loaded. +type Kind int + +const ( + KindFile Kind = iota + KindBytes +) + +// Resolved is the resolver result. Exactly one of Path or Bytes is set. +type Resolved struct { + Kind Kind + Path string // when Kind == KindFile + Bytes []byte // when Kind == KindBytes +} + +// MaxRemoteBytes caps remote/data URI payloads to prevent OOM on hostile servers. +const MaxRemoteBytes = 64 * 1024 * 1024 + +// Resolve inspects src and returns either a file path or pre-loaded bytes. +// HTTP(S) URLs and data: URIs are fetched/decoded here; everything else is +// treated as a file path. +func Resolve(ctx context.Context, src string) (*Resolved, error) { + if strings.HasPrefix(src, "data:") { + b, err := decodeDataURI(src) + if err != nil { + return nil, err + } + return &Resolved{Kind: KindBytes, Bytes: b}, nil + } + + if u, err := url.Parse(src); err == nil && (u.Scheme == "http" || u.Scheme == "https") { + b, err := fetch(ctx, src) + if err != nil { + return nil, err + } + return &Resolved{Kind: KindBytes, Bytes: b}, nil + } + + return &Resolved{Kind: KindFile, Path: src}, nil +} + +// decodeDataURI parses RFC 2397 data: URIs. Only base64-encoded payloads are +// supported (the common form for images); plain percent-encoded payloads are +// rejected — callers wanting that should pre-decode. +func decodeDataURI(s string) ([]byte, error) { + rest := strings.TrimPrefix(s, "data:") + comma := strings.IndexByte(rest, ',') + if comma < 0 { + return nil, fmt.Errorf("data URI: missing comma") + } + meta, payload := rest[:comma], rest[comma+1:] + if !strings.Contains(meta, "base64") { + return nil, fmt.Errorf("data URI: only base64 payloads supported") + } + b, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return nil, fmt.Errorf("data URI: base64 decode: %w", err) + } + if len(b) > MaxRemoteBytes { + return nil, fmt.Errorf("data URI: payload exceeds %d bytes", MaxRemoteBytes) + } + return b, nil +} + +func fetch(ctx context.Context, rawURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, fmt.Errorf("remote: build request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("remote: fetch: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("remote: HTTP %s", resp.Status) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, MaxRemoteBytes+1)) + if err != nil { + return nil, fmt.Errorf("remote: read body: %w", err) + } + if len(b) > MaxRemoteBytes { + return nil, fmt.Errorf("remote: response exceeds %d bytes", MaxRemoteBytes) + } + return b, nil +} diff --git a/internal/source/source_test.go b/internal/source/source_test.go new file mode 100644 index 0000000..9df1eea --- /dev/null +++ b/internal/source/source_test.go @@ -0,0 +1,86 @@ +package source + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestResolve_File(t *testing.T) { + r, err := Resolve(context.Background(), "/tmp/cat.png") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if r.Kind != KindFile { + t.Errorf("Kind = %v, want KindFile", r.Kind) + } + if r.Path != "/tmp/cat.png" { + t.Errorf("Path = %q", r.Path) + } +} + +func TestResolve_DataURI(t *testing.T) { + payload := []byte("hello-bytes") + uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(payload) + r, err := Resolve(context.Background(), uri) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if r.Kind != KindBytes { + t.Fatalf("Kind = %v, want KindBytes", r.Kind) + } + if string(r.Bytes) != string(payload) { + t.Errorf("Bytes mismatch: %q", r.Bytes) + } +} + +func TestResolve_DataURI_RejectsNonBase64(t *testing.T) { + _, err := Resolve(context.Background(), "data:text/plain,hello") + if err == nil { + t.Error("expected error for non-base64 data URI") + } +} + +func TestResolve_DataURI_BadBase64(t *testing.T) { + _, err := Resolve(context.Background(), "data:image/png;base64,!!!not-base64!!!") + if err == nil { + t.Error("expected error for invalid base64") + } +} + +func TestResolve_HTTP(t *testing.T) { + body := []byte("pretend-png-bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + defer srv.Close() + + r, err := Resolve(context.Background(), srv.URL+"/x.png") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if r.Kind != KindBytes { + t.Fatalf("Kind = %v, want KindBytes", r.Kind) + } + if string(r.Bytes) != string(body) { + t.Errorf("body mismatch: %q", r.Bytes) + } +} + +func TestResolve_HTTP_NonOK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := Resolve(context.Background(), srv.URL+"/missing.png") + if err == nil { + t.Error("expected error for HTTP 404") + } + if err != nil && !strings.Contains(err.Error(), "404") { + t.Errorf("error should mention status: %v", err) + } +} diff --git a/sandbox/sandbox.go b/sandbox/sandbox.go index 61a3097..d7985b8 100644 --- a/sandbox/sandbox.go +++ b/sandbox/sandbox.go @@ -11,6 +11,7 @@ package sandbox import ( + "bytes" "context" "encoding/binary" "fmt" @@ -21,7 +22,18 @@ import ( "path/filepath" ) -const workerEnv = "TERMIMAGE_WORKER" +const ( + workerEnv = "TERMIMAGE_WORKER" + workerModeEnv = "TERMIMAGE_WORKER_MODE" + workerPathEnv = "TERMIMAGE_WORKER_PATH" + + modePath = "path" + modeStdin = "stdin" + + // maxStdinBytes caps the payload the worker will read from stdin to avoid + // unbounded allocation if the parent misbehaves. + maxStdinBytes = 256 * 1024 * 1024 +) // IsWorker reports whether this process was spawned as a sandbox worker. func IsWorker() bool { @@ -51,15 +63,45 @@ func Decode(path string) (*image.NRGBA, error) { // DecodeContext is Decode with caller-supplied context for cancellation. func DecodeContext(ctx context.Context, path string) (*image.NRGBA, error) { + return spawn(ctx, modePath, path, nil) +} + +// DecodeBytes spawns a sandboxed worker subprocess to decode raw image bytes +// piped over stdin. Used for data: URIs and remote URLs where there is no +// on-disk file to grant access to. +func DecodeBytes(data []byte) (*image.NRGBA, error) { + return DecodeBytesContext(context.Background(), data) +} + +// DecodeBytesContext is DecodeBytes with caller-supplied context. +func DecodeBytesContext(ctx context.Context, data []byte) (*image.NRGBA, error) { + if len(data) == 0 { + return nil, fmt.Errorf("sandbox: empty input") + } + if len(data) > maxStdinBytes { + return nil, fmt.Errorf("sandbox: input exceeds %d bytes", maxStdinBytes) + } + return spawn(ctx, modeStdin, "", data) +} + +func spawn(ctx context.Context, mode, path string, stdinData []byte) (*image.NRGBA, error) { self, err := os.Executable() if err != nil { return nil, fmt.Errorf("sandbox: resolve self: %w", err) } cmd := exec.CommandContext(ctx, self) - cmd.Env = append(os.Environ(), workerEnv+"=1", "TERMIMAGE_WORKER_PATH="+path) + env := append(os.Environ(), workerEnv+"=1", workerModeEnv+"="+mode) + if mode == modePath { + env = append(env, workerPathEnv+"="+path) + } + cmd.Env = env cmd.Stderr = os.Stderr + if mode == modeStdin { + cmd.Stdin = bytes.NewReader(stdinData) + } + stdout, err := cmd.StdoutPipe() if err != nil { return nil, err @@ -81,9 +123,25 @@ func DecodeContext(ctx context.Context, path string) (*image.NRGBA, error) { // runWorker is called inside the sandboxed child process. func runWorker() error { - path := os.Getenv("TERMIMAGE_WORKER_PATH") + mode := os.Getenv(workerModeEnv) + if mode == "" { + mode = modePath // legacy default + } + + switch mode { + case modePath: + return runPathWorker() + case modeStdin: + return runStdinWorker() + default: + return fmt.Errorf("unknown worker mode %q", mode) + } +} + +func runPathWorker() error { + path := os.Getenv(workerPathEnv) if path == "" { - return fmt.Errorf("TERMIMAGE_WORKER_PATH not set") + return fmt.Errorf("%s not set", workerPathEnv) } clean := filepath.Clean(path) @@ -106,6 +164,31 @@ func runWorker() error { return writePixels(os.Stdout, img) } +func runStdinWorker() error { + // No filesystem access needed — lock down before reading bytes. + if err := apply(""); err != nil { + return fmt.Errorf("sandbox apply: %w", err) + } + + data, err := io.ReadAll(io.LimitReader(os.Stdin, maxStdinBytes+1)) + if err != nil { + return fmt.Errorf("read stdin: %w", err) + } + if len(data) == 0 { + return fmt.Errorf("read stdin: empty") + } + if len(data) > maxStdinBytes { + return fmt.Errorf("read stdin: payload exceeds %d bytes", maxStdinBytes) + } + + img, err := decodeBytes(data) + if err != nil { + return fmt.Errorf("decode: %w", err) + } + + return writePixels(os.Stdout, img) +} + // Wire protocol: 4B width + 4B height (little-endian uint32) then raw RGBA bytes. func writePixels(w io.Writer, img *image.NRGBA) error { b := img.Bounds() diff --git a/sandbox/sandbox_linux.go b/sandbox/sandbox_linux.go index 3f7d60d..26962dc 100644 --- a/sandbox/sandbox_linux.go +++ b/sandbox/sandbox_linux.go @@ -7,12 +7,17 @@ import ( ) // apply locks down the worker process before any untrusted bytes are read. -// Landlock (kernel ≥5.13) restricts filesystem access to the target file only. +// Landlock (kernel ≥5.13) restricts filesystem access to the target file only, +// or denies all fs access when path is empty (stdin mode). // Seccomp syscall filtering is a TODO — see sandbox_seccomp_linux.go once the // allowlist is tuned for the Go runtime + stb_image. func apply(path string) error { - // BestEffort silently skips Landlock if the kernel is too old. - return landlock.V3.BestEffort().RestrictPaths( + cfg := landlock.V3.BestEffort() + if path == "" { + // Deny all filesystem access — bytes arrive via stdin. + return cfg.RestrictPaths() + } + return cfg.RestrictPaths( landlock.ROFiles(path), ) } diff --git a/termimage.go b/termimage.go index 780c205..170ab66 100644 --- a/termimage.go +++ b/termimage.go @@ -12,6 +12,7 @@ package termimage import ( + "context" "image" "io" "os" @@ -19,6 +20,7 @@ import ( "github.com/floatpane/termimage/decode" "github.com/floatpane/termimage/detect" "github.com/floatpane/termimage/internal/resize" + "github.com/floatpane/termimage/internal/source" "github.com/floatpane/termimage/render" "github.com/floatpane/termimage/sandbox" ) @@ -51,8 +53,15 @@ type Options struct { Sandboxed bool } -// Display decodes the image at path and writes terminal graphics to w. -func Display(w io.Writer, path string, opts Options) error { +// Display decodes the image at src and writes terminal graphics to w. +// src may be a local file path, a data: URI, or an http(s):// URL. +func Display(w io.Writer, src string, opts Options) error { + return DisplayContext(context.Background(), w, src, opts) +} + +// DisplayContext is Display with caller-supplied context for cancellation of +// remote fetches and sandboxed decoding. +func DisplayContext(ctx context.Context, w io.Writer, src string, opts Options) error { maxW, maxH := effectiveDimensions(opts) proto := opts.Protocol @@ -60,13 +69,25 @@ func Display(w io.Writer, path string, opts Options) error { proto = detect.Best() } - var img *image.NRGBA - var err error + resolved, err := source.Resolve(ctx, src) + if err != nil { + return err + } - if opts.Sandboxed { - img, err = sandbox.Decode(path) - } else { - img, err = decode.File(path) + var img *image.NRGBA + switch resolved.Kind { + case source.KindFile: + if opts.Sandboxed { + img, err = sandbox.DecodeContext(ctx, resolved.Path) + } else { + img, err = decode.File(resolved.Path) + } + case source.KindBytes: + if opts.Sandboxed { + img, err = sandbox.DecodeBytesContext(ctx, resolved.Bytes) + } else { + img, err = decode.Bytes(resolved.Bytes) + } } if err != nil { return err diff --git a/termimage_test.go b/termimage_test.go index 09886e0..ca019eb 100644 --- a/termimage_test.go +++ b/termimage_test.go @@ -2,9 +2,12 @@ package termimage import ( "bytes" + "encoding/base64" "image" "image/color" "image/png" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -12,6 +15,21 @@ import ( "github.com/floatpane/termimage/detect" ) +func makePNG(t *testing.T) []byte { + t.Helper() + src := image.NewNRGBA(image.Rect(0, 0, 4, 4)) + for y := 0; y < 4; y++ { + for x := 0; x < 4; x++ { + src.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 64), G: uint8(y * 64), A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, src); err != nil { + t.Fatalf("encode: %v", err) + } + return buf.Bytes() +} + func TestProtocolConstants(t *testing.T) { // Public re-exports should match the detect package. if HalfBlock != detect.HalfBlock { @@ -116,6 +134,45 @@ func TestDisplay_AutoProtocolPicksOne(t *testing.T) { } } +func TestDisplay_DataURI(t *testing.T) { + pngBytes := makePNG(t) + uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes) + + var out bytes.Buffer + err := Display(&out, uri, Options{ + MaxWidth: 80, + MaxHeight: 24, + Protocol: HalfBlock, + }) + if err != nil { + t.Fatalf("Display: %v", err) + } + if !bytes.Contains(out.Bytes(), []byte("▀")) { + t.Errorf("expected half-block glyph in output") + } +} + +func TestDisplay_RemoteHTTP(t *testing.T) { + pngBytes := makePNG(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(pngBytes) + })) + defer srv.Close() + + var out bytes.Buffer + err := Display(&out, srv.URL+"/cat.png", Options{ + MaxWidth: 80, + MaxHeight: 24, + Protocol: HalfBlock, + }) + if err != nil { + t.Fatalf("Display: %v", err) + } + if !bytes.Contains(out.Bytes(), []byte("▀")) { + t.Errorf("expected half-block glyph in output") + } +} + func TestDisplay_MissingFile(t *testing.T) { var out bytes.Buffer err := Display(&out, "/no/such/image.png", Options{Protocol: HalfBlock, MaxWidth: 80, MaxHeight: 24})