feat(parse): add ExtractImages stream scanner for OSC 9 markers

Implements ExtractImages() which scans filter stdout line-by-line,
extracts valid OSC 9 image markers into ImageRef structs, and replaces
marker lines with null-delimited placeholders (\x00IMG:N\x00). Invalid
markers (missing path) pass through unchanged. Also clarifies the Index
field comment in ImageRef to indicate it is set by ExtractImages, not
ParseImageOSC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mortdecai
2026-04-07 20:01:10 -04:00
parent 00a6660db5
commit b3bbd55551
3 changed files with 143 additions and 1 deletions
+41
View File
@@ -0,0 +1,41 @@
package parse
import (
"bufio"
"bytes"
"fmt"
"io"
"git.sr.ht/~rjarry/aerc/lib/log"
)
// ExtractImages scans a reader for OSC 9 image markers, replaces them with
// null-byte-delimited placeholders (\x00IMG:N\x00), and returns the cleaned
// reader plus a slice of ImageRef structs.
//
// Lines containing valid image markers are replaced entirely with the
// placeholder. Lines with invalid markers (e.g. missing path) pass through
// unchanged.
func ExtractImages(r io.Reader) (io.Reader, []ImageRef) {
var images []ImageRef
buf := bytes.NewBuffer(nil)
scanner := bufio.NewScanner(r)
scanner.Buffer(nil, 1024*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if bytes.Contains(line, oscImagePrefix) {
if ref, ok := ParseImageOSC(line); ok {
ref.Index = len(images)
images = append(images, *ref)
fmt.Fprintf(buf, "\x00IMG:%d\x00\n", ref.Index)
continue
}
}
buf.Write(line)
buf.WriteByte('\n')
}
if err := scanner.Err(); err != nil {
log.Warnf("ExtractImages: scan error: %v", err)
}
return buf, images
}
+101
View File
@@ -0,0 +1,101 @@
package parse
import (
"io"
"strings"
"testing"
)
func TestExtractImages_NoMarkers(t *testing.T) {
input := "Hello\nWorld\nNo images here\n"
r, images := ExtractImages(strings.NewReader(input))
if len(images) != 0 {
t.Errorf("got %d images, want 0", len(images))
}
out, _ := io.ReadAll(r)
if string(out) != input {
t.Errorf("output = %q, want %q", string(out), input)
}
}
func TestExtractImages_SingleMarker(t *testing.T) {
input := "Line 1\n\033]9;image:path=/tmp/img.png;alt=photo\007\nLine 2\n"
r, images := ExtractImages(strings.NewReader(input))
if len(images) != 1 {
t.Fatalf("got %d images, want 1", len(images))
}
if images[0].Path != "/tmp/img.png" {
t.Errorf("path = %q", images[0].Path)
}
if images[0].Alt != "photo" {
t.Errorf("alt = %q", images[0].Alt)
}
if images[0].Index != 0 {
t.Errorf("index = %d, want 0", images[0].Index)
}
out, _ := io.ReadAll(r)
expected := "Line 1\n\x00IMG:0\x00\nLine 2\n"
if string(out) != expected {
t.Errorf("output = %q, want %q", string(out), expected)
}
}
func TestExtractImages_MultipleMarkers(t *testing.T) {
input := "Text\n\033]9;image:path=/tmp/a.png\007\nMiddle\n\033]9;image:path=/tmp/b.jpg;alt=second\007\nEnd\n"
r, images := ExtractImages(strings.NewReader(input))
if len(images) != 2 {
t.Fatalf("got %d images, want 2", len(images))
}
if images[0].Index != 0 || images[1].Index != 1 {
t.Errorf("indices: %d, %d", images[0].Index, images[1].Index)
}
if images[1].Path != "/tmp/b.jpg" {
t.Errorf("second path = %q", images[1].Path)
}
out, _ := io.ReadAll(r)
if !strings.Contains(string(out), "\x00IMG:0\x00") {
t.Error("missing placeholder 0")
}
if !strings.Contains(string(out), "\x00IMG:1\x00") {
t.Error("missing placeholder 1")
}
}
func TestExtractImages_EmptyInput(t *testing.T) {
r, images := ExtractImages(strings.NewReader(""))
if len(images) != 0 {
t.Errorf("got %d images, want 0", len(images))
}
out, _ := io.ReadAll(r)
if len(out) != 0 {
t.Errorf("expected empty output, got %q", string(out))
}
}
func TestExtractImages_MarkerAtStartAndEnd(t *testing.T) {
input := "\033]9;image:path=/tmp/first.png\007\nContent\n\033]9;image:path=/tmp/last.png\007\n"
r, images := ExtractImages(strings.NewReader(input))
if len(images) != 2 {
t.Fatalf("got %d images, want 2", len(images))
}
out, _ := io.ReadAll(r)
lines := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n")
if lines[0] != "\x00IMG:0\x00" {
t.Errorf("first line = %q, want placeholder", lines[0])
}
if lines[2] != "\x00IMG:1\x00" {
t.Errorf("last line = %q, want placeholder", lines[2])
}
}
func TestExtractImages_InvalidMarkerPassesThrough(t *testing.T) {
input := "Text\n\033]9;image:alt=no path\007\nMore text\n"
r, images := ExtractImages(strings.NewReader(input))
if len(images) != 0 {
t.Errorf("got %d images, want 0 (invalid marker)", len(images))
}
out, _ := io.ReadAll(r)
if !strings.Contains(string(out), "Text") {
t.Error("text content lost")
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// ImageRef represents an image referenced by a filter via OSC 9 marker. // ImageRef represents an image referenced by a filter via OSC 9 marker.
type ImageRef struct { type ImageRef struct {
Index int // Sequential position in the message Index int // Sequential position in the message; set by ExtractImages, not ParseImageOSC
Path string // Absolute path to image file on disk Path string // Absolute path to image file on disk
Alt string // Alt text fallback Alt string // Alt text fallback
} }