feat(parse): add ImageRef type and OSC 9 marker parser
Introduces ImageRef struct and ParseImageOSC() to parse OSC 9 image markers (\033]9;image:path=...;alt=...\007) emitted by filters. Supports BEL and ESC-backslash terminators, tolerates markers embedded in larger lines, and rejects malformed or path-less markers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
package parse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageRef represents an image referenced by a filter via OSC 9 marker.
|
||||||
|
type ImageRef struct {
|
||||||
|
Index int // Sequential position in the message
|
||||||
|
Path string // Absolute path to image file on disk
|
||||||
|
Alt string // Alt text fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
var oscImagePrefix = []byte("\033]9;image:")
|
||||||
|
|
||||||
|
// ParseImageOSC parses an OSC 9 image marker from a byte slice.
|
||||||
|
// Format: \033]9;image:path=/tmp/foo.png;alt=text\007
|
||||||
|
// The marker may appear anywhere in the line.
|
||||||
|
// Returns the parsed reference and whether parsing succeeded.
|
||||||
|
func ParseImageOSC(line []byte) (*ImageRef, bool) {
|
||||||
|
start := bytes.Index(line, oscImagePrefix)
|
||||||
|
if start == -1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rest := line[start+len(oscImagePrefix):]
|
||||||
|
|
||||||
|
// Find string terminator: BEL (\007) or ST (ESC \)
|
||||||
|
end := -1
|
||||||
|
for i := 0; i < len(rest); i++ {
|
||||||
|
if rest[i] == '\007' {
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rest[i] == '\033' && i+1 < len(rest) && rest[i+1] == '\\' {
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end == -1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
params := string(rest[:end])
|
||||||
|
ref := &ImageRef{}
|
||||||
|
for _, pair := range strings.Split(params, ";") {
|
||||||
|
k, v, ok := strings.Cut(pair, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "path":
|
||||||
|
ref.Path = v
|
||||||
|
case "alt":
|
||||||
|
ref.Alt = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ref.Path == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return ref, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package parse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseImageOSC_ValidFull(t *testing.T) {
|
||||||
|
line := []byte("\033]9;image:path=/tmp/aerc-xxx/img0.png;alt=product photo\007")
|
||||||
|
ref, ok := ParseImageOSC(line)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected successful parse")
|
||||||
|
}
|
||||||
|
if ref.Path != "/tmp/aerc-xxx/img0.png" {
|
||||||
|
t.Errorf("path = %q, want /tmp/aerc-xxx/img0.png", ref.Path)
|
||||||
|
}
|
||||||
|
if ref.Alt != "product photo" {
|
||||||
|
t.Errorf("alt = %q, want 'product photo'", ref.Alt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageOSC_PathOnly(t *testing.T) {
|
||||||
|
line := []byte("\033]9;image:path=/tmp/img.jpg\007")
|
||||||
|
ref, ok := ParseImageOSC(line)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected successful parse")
|
||||||
|
}
|
||||||
|
if ref.Path != "/tmp/img.jpg" {
|
||||||
|
t.Errorf("path = %q, want /tmp/img.jpg", ref.Path)
|
||||||
|
}
|
||||||
|
if ref.Alt != "" {
|
||||||
|
t.Errorf("alt = %q, want empty", ref.Alt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageOSC_EscBackslashTerminator(t *testing.T) {
|
||||||
|
line := []byte("\033]9;image:path=/tmp/img.png\033\\")
|
||||||
|
ref, ok := ParseImageOSC(line)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected successful parse with ESC\\ terminator")
|
||||||
|
}
|
||||||
|
if ref.Path != "/tmp/img.png" {
|
||||||
|
t.Errorf("path = %q", ref.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageOSC_NoPath(t *testing.T) {
|
||||||
|
line := []byte("\033]9;image:alt=just alt text\007")
|
||||||
|
_, ok := ParseImageOSC(line)
|
||||||
|
if ok {
|
||||||
|
t.Error("expected failure when path is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageOSC_NotOSC9(t *testing.T) {
|
||||||
|
line := []byte("Hello, this is plain text")
|
||||||
|
_, ok := ParseImageOSC(line)
|
||||||
|
if ok {
|
||||||
|
t.Error("expected failure on plain text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageOSC_WrongOSCNumber(t *testing.T) {
|
||||||
|
line := []byte("\033]8;image:path=/tmp/img.png\007")
|
||||||
|
_, ok := ParseImageOSC(line)
|
||||||
|
if ok {
|
||||||
|
t.Error("expected failure on OSC 8 (not 9)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageOSC_EmptyPath(t *testing.T) {
|
||||||
|
line := []byte("\033]9;image:path=\007")
|
||||||
|
_, ok := ParseImageOSC(line)
|
||||||
|
if ok {
|
||||||
|
t.Error("expected failure on empty path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageOSC_MarkerEmbeddedInLine(t *testing.T) {
|
||||||
|
line := []byte("prefix text \033]9;image:path=/tmp/img.png\007 suffix")
|
||||||
|
ref, ok := ParseImageOSC(line)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected successful parse even with surrounding text")
|
||||||
|
}
|
||||||
|
if ref.Path != "/tmp/img.png" {
|
||||||
|
t.Errorf("path = %q", ref.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageOSC_NoTerminator(t *testing.T) {
|
||||||
|
line := []byte("\033]9;image:path=/tmp/img.png")
|
||||||
|
_, ok := ParseImageOSC(line)
|
||||||
|
if ok {
|
||||||
|
t.Error("expected failure when no terminator present")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user