fix: images display (#237)

Drew Smirnoff created

* fix: images display (#223)

Signed-off-by: drew <me@andrinoff.com>

* fix: improve image rendering

Signed-off-by: drew <me@andrinoff.com>

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

main.go           |   4 
tui/email_view.go |  84 +++++++++++++------
view/html.go      | 202 +++++++++++++++++++++++++++++++++++++++++++++---
view/html_test.go |  30 +++++-
4 files changed, 270 insertions(+), 50 deletions(-)

Detailed changes

main.go 🔗

@@ -141,6 +141,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 
 	case tui.BackToMailboxMsg:
+		// Ensure kitty graphics are cleared when leaving email view
+		tui.ClearKittyGraphics()
 		switch msg.Mailbox {
 		case tui.MailboxSent:
 			if m.sentInbox != nil {
@@ -822,6 +824,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, m.current.Init()
 
 	case tui.DeleteEmailMsg:
+		tui.ClearKittyGraphics()
 		m.previousModel = m.current
 		m.current = tui.NewStatus("Deleting email...")
 
@@ -838,6 +841,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID, msg.Mailbox))
 
 	case tui.ArchiveEmailMsg:
+		tui.ClearKittyGraphics()
 		m.previousModel = m.current
 		m.current = tui.NewStatus("Archiving email...")
 

tui/email_view.go 🔗

@@ -13,8 +13,8 @@ import (
 	"github.com/floatpane/matcha/view"
 )
 
-// clearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout
-func clearKittyGraphics() {
+// ClearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout.
+func ClearKittyGraphics() {
 	// Delete all images: a=d (action=delete), d=A (delete all)
 	os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\")
 	os.Stdout.Sync()
@@ -38,6 +38,7 @@ type EmailView struct {
 	isSMIME            bool
 	smimeTrusted       bool
 	isEncrypted        bool
+	imagePlacements    []view.ImagePlacement
 }
 
 func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
@@ -65,7 +66,7 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
 	// Initial state for showImages matches config unless overridden later
 	showImages := !disableImages
 
-	body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
+	body, placements, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
 	if err != nil {
 		body = fmt.Sprintf("Error rendering body: %v", err)
 	}
@@ -84,19 +85,20 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
 	vp.SetWidth(width)
 	vp.SetHeight(height - headerHeight - attachmentHeight)
 	wrapped := wrapBodyToWidth(body, vp.Width())
-	vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
+	vp.SetContent(wrapped + "\n")
 
 	return &EmailView{
-		viewport:      vp,
-		email:         email,
-		emailIndex:    emailIndex,
-		accountID:     email.AccountID,
-		mailbox:       mailbox,
-		disableImages: disableImages,
-		showImages:    showImages,
-		isSMIME:       isSMIME,
-		smimeTrusted:  smimeTrusted,
-		isEncrypted:   isEncrypted,
+		viewport:        vp,
+		email:           email,
+		emailIndex:      emailIndex,
+		accountID:       email.AccountID,
+		mailbox:         mailbox,
+		disableImages:   disableImages,
+		showImages:      showImages,
+		isSMIME:         isSMIME,
+		smimeTrusted:    smimeTrusted,
+		isEncrypted:     isEncrypted,
+		imagePlacements: placements,
 	}
 }
 
@@ -117,7 +119,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, nil
 			}
 			// Clear Kitty graphics before returning to mailbox
-			clearKittyGraphics()
+			ClearKittyGraphics()
 			return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} }
 		}
 
@@ -155,30 +157,31 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			case "i":
 				if view.ImageProtocolSupported() {
 					m.showImages = !m.showImages
-					clearKittyGraphics()
+					ClearKittyGraphics()
 
 					inlineImages := inlineImagesFromAttachments(m.email.Attachments)
-					body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
+					body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
 					if err != nil {
 						body = fmt.Sprintf("Error rendering body: %v", err)
 					}
+					m.imagePlacements = placements
 					wrapped := wrapBodyToWidth(body, m.viewport.Width())
-					m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
+					m.viewport.SetContent(wrapped + "\n")
 					return m, nil
 				}
 			case "r":
 				// Clear Kitty graphics before opening composer
-				clearKittyGraphics()
+				ClearKittyGraphics()
 				return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
 			case "f":
 				// Clear Kitty graphics before opening composer
-				clearKittyGraphics()
+				ClearKittyGraphics()
 				return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
 			case "d":
 				accountID := m.accountID
 				uid := m.email.UID
 				// Clear Kitty graphics before transitioning
-				clearKittyGraphics()
+				ClearKittyGraphics()
 				return m, func() tea.Msg {
 					return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
 				}
@@ -186,7 +189,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				accountID := m.accountID
 				uid := m.email.UID
 				// Clear Kitty graphics before transitioning
-				clearKittyGraphics()
+				ClearKittyGraphics()
 				return m, func() tea.Msg {
 					return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
 				}
@@ -208,13 +211,15 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
 
 		// When the window size changes, wrap and clear kitty images to keep placement stable
+		ClearKittyGraphics()
 		inlineImages := inlineImagesFromAttachments(m.email.Attachments)
-		body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
+		body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
 		if err != nil {
 			body = fmt.Sprintf("Error rendering body: %v", err)
 		}
+		m.imagePlacements = placements
 		wrapped := wrapBodyToWidth(body, m.viewport.Width())
-		m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
+		m.viewport.SetContent(wrapped + "\n")
 	}
 
 	m.viewport, cmd = m.viewport.Update(msg)
@@ -224,10 +229,12 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m *EmailView) View() tea.View {
-	// Clear all Kitty graphics before rendering to prevent image stacking on scroll.
-	// This must be done synchronously via stdout before the frame is drawn,
-	// as escape sequences in the return string execute too late.
-	clearKittyGraphics()
+	// Clear image placements (but keep uploaded image data in terminal memory)
+	// before re-rendering to prevent stacking on scroll. Uses d=a (delete all
+	// placements) instead of d=A (delete all including data) so that images
+	// can be re-displayed by ID without re-uploading.
+	os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\")
+	os.Stdout.Sync()
 
 	smimeStatus := ""
 	if m.isEncrypted {
@@ -271,6 +278,27 @@ func (m *EmailView) View() tea.View {
 		attachmentView = attachmentBoxStyle.Render(b.String())
 	}
 
+	// Render visible images directly to stdout. Bubbletea v2's ultraviolet
+	// renderer uses a cell-based model that cannot pass through graphics
+	// protocol escape sequences, so we write them out-of-band.
+	if m.showImages && len(m.imagePlacements) > 0 {
+		headerLines := lipgloss.Height(styledHeader) + 1 // +1 for the newline after header
+		yOffset := m.viewport.YOffset()
+		vpHeight := m.viewport.Height()
+
+		for i := range m.imagePlacements {
+			p := &m.imagePlacements[i]
+			// Only render if the image's top line is within the viewport.
+			// We can't partially clip images scrolled off the top (Kitty
+			// always renders from the top-left), so we hide them once
+			// their start line scrolls above the viewport.
+			if p.Line >= yOffset && p.Line < yOffset+vpHeight {
+				screenRow := headerLines + (p.Line - yOffset)
+				view.RenderImageToStdout(p, screenRow)
+			}
+		}
+	}
+
 	// m.viewport.View() returns a string in Bubbles v2 viewport
 	return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
 }

view/html.go 🔗

@@ -12,6 +12,7 @@ import (
 	"os"
 	"regexp"
 	"strings"
+	"sync"
 	"time"
 
 	_ "image/gif"
@@ -305,10 +306,30 @@ func debugImageProtocol(format string, args ...interface{}) {
 	}
 }
 
+// remoteImageCache caches fetched remote images (URL -> base64 PNG string).
+var remoteImageCache sync.Map
+
+// nextImageID is an auto-incrementing counter for Kitty image IDs.
+var nextImageID uint32 = 1000
+
+// allocImageID returns a unique Kitty image ID.
+func allocImageID() uint32 {
+	id := nextImageID
+	nextImageID++
+	return id
+}
+
 func fetchRemoteBase64(url string) string {
 	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
 		return ""
 	}
+
+	// Check cache first
+	if cached, ok := remoteImageCache.Load(url); ok {
+		debugImageProtocol("remote cache hit url=%s", url)
+		return cached.(string)
+	}
+
 	client := &http.Client{Timeout: 5 * time.Second}
 	resp, err := client.Get(url)
 	if err != nil {
@@ -340,6 +361,7 @@ func fetchRemoteBase64(url string) string {
 
 	encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
 	debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
+	remoteImageCache.Store(url, encoded)
 	return encoded
 }
 
@@ -455,6 +477,102 @@ func renderInlineImage(payload string) string {
 	return ""
 }
 
+// imageRows calculates the number of terminal rows an image occupies.
+func imageRows(payload string) int {
+	rows := 1
+	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
+		if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
+			cellHeight := getTerminalCellSize()
+			h := img.Bounds().Dy()
+			rows = (h + cellHeight - 1) / cellHeight
+			if rows < 1 {
+				rows = 1
+			}
+			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
+		}
+	}
+	return rows
+}
+
+// kittyUploadImage uploads image data to the terminal with a unique ID using
+// the Kitty graphics protocol transmit action (a=t). The image is stored in
+// the terminal's memory and can be displayed later by ID without re-sending data.
+func kittyUploadImage(payload string, id uint32) {
+	if payload == "" {
+		return
+	}
+
+	const chunkSize = 4096
+	for offset := 0; offset < len(payload); offset += chunkSize {
+		end := offset + chunkSize
+		if end > len(payload) {
+			end = len(payload)
+		}
+		more := "0"
+		if end < len(payload) {
+			more = "1"
+		}
+
+		chunk := payload[offset:end]
+		if offset == 0 {
+			// a=t: transmit (upload) only, don't display yet
+			// i=ID: assign this image ID
+			fmt.Fprintf(os.Stdout, "\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk)
+		} else {
+			fmt.Fprintf(os.Stdout, "\x1b_Gm=%s;%s\x1b\\", more, chunk)
+		}
+	}
+	os.Stdout.Sync()
+}
+
+// kittyDisplayImage displays a previously uploaded image by its ID at the
+// current cursor position. This is very fast since no image data is transmitted.
+func kittyDisplayImage(id uint32) string {
+	// a=p: put (display) an already-uploaded image by ID
+	// C=1: cursor does not move
+	return fmt.Sprintf("\x1b_Ga=p,i=%d,q=2,C=1\x1b\\", id)
+}
+
+// iterm2ImageEscapeOnly returns only the iTerm2 image protocol escape sequence
+// without any row placeholders. Used for out-of-band rendering to stdout.
+func iterm2ImageEscapeOnly(payload string) string {
+	if payload == "" {
+		return ""
+	}
+	return fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07", payload)
+}
+
+// RenderImageToStdout writes an image directly to stdout at the given screen
+// row using cursor positioning. This bypasses bubbletea's cell-based renderer
+// which cannot handle graphics protocol escape sequences.
+//
+// For Kitty-protocol terminals, images are uploaded once and then displayed by
+// ID on subsequent calls, making scroll rendering nearly instant.
+func RenderImageToStdout(placement *ImagePlacement, screenRow int) {
+	if placement.Base64 == "" {
+		return
+	}
+
+	useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported()
+	useIterm2 := iterm2Supported() || warpSupported()
+
+	if useKitty {
+		// Upload once, display by ID on subsequent renders
+		if !placement.Uploaded {
+			placement.ID = allocImageID()
+			kittyUploadImage(placement.Base64, placement.ID)
+			placement.Uploaded = true
+		}
+		seq := kittyDisplayImage(placement.ID)
+		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
+		os.Stdout.Sync()
+	} else if useIterm2 {
+		seq := iterm2ImageEscapeOnly(placement.Base64)
+		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
+		os.Stdout.Sync()
+	}
+}
+
 // expandImageRowPlaceholders replaces image row placeholders with actual newlines.
 func expandImageRowPlaceholders(text string) string {
 	re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
@@ -476,8 +594,20 @@ type InlineImage struct {
 	Base64 string
 }
 
+// ImagePlacement holds the data needed to render an image at a specific
+// line in the email body. Images are rendered directly to stdout (bypassing
+// bubbletea's cell-based renderer which cannot handle graphics protocols).
+type ImagePlacement struct {
+	Line     int    // Line number in the processed body text where the image starts
+	Base64   string // Base64-encoded image data (PNG)
+	Rows     int    // Number of terminal rows the image occupies
+	Uploaded bool   // Whether the image has been uploaded to the terminal via Kitty ID
+	ID       uint32 // Kitty image ID for display-by-reference
+}
+
 // ProcessBodyWithInline renders the body and resolves CID inline images when provided.
-func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) {
+// Returns the rendered body text, image placements for out-of-band rendering, and any error.
+func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 	inlineMap := make(map[string]string, len(inline))
 	for _, img := range inline {
 		cid := strings.TrimSpace(img.CID)
@@ -494,11 +624,11 @@ func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Styl
 
 // ProcessBody takes a raw email body, decodes it, and formats it as plain
 // text with terminal hyperlinks.
-func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) {
+func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 	return processBody(rawBody, nil, h1Style, h2Style, bodyStyle, disableImages)
 }
 
-func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) {
+func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 	decodedBody, err := decodeQuotedPrintable(rawBody)
 	if err != nil {
 		decodedBody = rawBody
@@ -508,7 +638,7 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 
 	doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
 	if err != nil {
-		return "", fmt.Errorf("could not parse email body: %w", err)
+		return "", nil, fmt.Errorf("could not parse email body: %w", err)
 	}
 
 	doc.Find("style, script").Remove()
@@ -571,7 +701,16 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 		s.ReplaceWithHtml(placeholder)
 	})
 
-	// Format links and images
+	// Format links and images.
+	// Collect image placements for out-of-band rendering (bubbletea v2's
+	// ultraviolet renderer cannot pass through graphics protocol sequences).
+	var imgIndex int
+	var pendingImages []struct {
+		index   int
+		payload string
+		rows    int
+	}
+
 	doc.Find("a").Each(func(i int, s *goquery.Selection) {
 		href, exists := s.Attr("href")
 		if !exists {
@@ -608,15 +747,24 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 			}
 
 			if payload != "" {
-				if rendered := renderInlineImage(payload); rendered != "" {
-					debugImageProtocol("rendered inline image src=%s len=%d dataURI=%t cid=%t (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"), kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
-					s.ReplaceWithHtml("\n" + rendered + "\n")
-					return
-				}
-				debugImageProtocol("payload present but renderer returned empty src=%s len=%d", src, len(payload))
-			} else {
-				debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
+				rows := imageRows(payload)
+				debugImageProtocol("collected image placement src=%s rows=%d (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, rows, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
+
+				idx := imgIndex
+				imgIndex++
+				pendingImages = append(pendingImages, struct {
+					index   int
+					payload string
+					rows    int
+				}{idx, payload, rows})
+
+				// Insert a placeholder with blank lines for spacing.
+				// The image placement marker lets us find the line number later.
+				placeholder := fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
+				s.ReplaceWithHtml(fmt.Sprintf("\n[[MATCHA_IMG:%d]]%s", idx, placeholder))
+				return
 			}
+			debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
 		} else {
 			debugImageProtocol("image protocol not supported for src=%s (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
 		}
@@ -636,6 +784,32 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 	// Now expand the image row placeholders to actual newlines
 	text = expandImageRowPlaceholders(text)
 
+	// Build image placements by finding the line numbers of image markers.
+	var placements []ImagePlacement
+	if len(pendingImages) > 0 {
+		lines := strings.Split(text, "\n")
+		imgMarkerRegex := regexp.MustCompile(`\[\[MATCHA_IMG:(\d+)\]\]`)
+		for lineNum, line := range lines {
+			if matches := imgMarkerRegex.FindStringSubmatch(line); matches != nil {
+				var idx int
+				fmt.Sscanf(matches[1], "%d", &idx)
+				for _, pi := range pendingImages {
+					if pi.index == idx {
+						placements = append(placements, ImagePlacement{
+							Line:   lineNum,
+							Base64: pi.payload,
+							Rows:   pi.rows,
+						})
+						break
+					}
+				}
+			}
+		}
+
+		// Remove the image markers from the text (leave the spacing)
+		text = imgMarkerRegex.ReplaceAllString(text, "")
+	}
+
 	// Replace quote placeholders with styled quote boxes
 	quoteRegex := regexp.MustCompile(`\[\[MATCHA_QUOTE:(\d+)\]\]`)
 	text = quoteRegex.ReplaceAllStringFunc(text, func(match string) string {
@@ -652,7 +826,7 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 	// Style quoted reply sections (for plain text > quotes)
 	text = styleQuotedReplies(text)
 
-	return bodyStyle.Render(text), nil
+	return bodyStyle.Render(text), placements, nil
 }
 
 // quoteBoxStyle is the style for the quoted reply box border

view/html_test.go 🔗

@@ -517,7 +517,7 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			tc.setupHyperlinks()
 
-			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
+			processed, _, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
 			if err != nil {
 				t.Fatalf("ProcessBody() failed: %v", err)
 			}
@@ -568,9 +568,10 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
 		input               string
 		expectedContains    string
 		expectedNotContains string
+		expectPlacements    bool
 	}{
 		{
-			name: "Data URI image with Kitty support",
+			name: "Data URI image with Kitty support returns placement",
 			setupImageProtocol: func() {
 				os.Setenv("TERM", "xterm-kitty")
 			},
@@ -581,11 +582,11 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
 				os.Unsetenv("WEZTERM_EXECUTABLE")
 			},
 			input:               `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
-			expectedContains:    "\x1b_Gf=100,a=T,q=2,C=1,m=0;",
 			expectedNotContains: "[Image: test image,",
+			expectPlacements:    true,
 		},
 		{
-			name: "Data URI image with iTerm2 support",
+			name: "Data URI image with iTerm2 support returns placement",
 			setupImageProtocol: func() {
 				os.Setenv("TERM", "xterm")
 				os.Setenv("TERM_PROGRAM", "iterm.app")
@@ -597,8 +598,8 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
 				os.Unsetenv("WEZTERM_EXECUTABLE")
 			},
 			input:               `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
-			expectedContains:    "\x1b]1337;File=inline=1:",
 			expectedNotContains: "[Image: test image,",
+			expectPlacements:    true,
 		},
 		{
 			name: "Data URI image without protocol support",
@@ -643,14 +644,27 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
 			tc.clearAllImageEnv()
 			tc.setupImageProtocol()
 
-			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
+			processed, placements, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
 			if err != nil {
 				t.Fatalf("ProcessBody() failed: %v", err)
 			}
 
+			if tc.expectPlacements {
+				if len(placements) == 0 {
+					t.Errorf("Expected image placements but got none")
+				} else {
+					if placements[0].Base64 == "" {
+						t.Errorf("Expected non-empty Base64 in placement")
+					}
+					if placements[0].Rows < 1 {
+						t.Errorf("Expected Rows >= 1, got %d", placements[0].Rows)
+					}
+				}
+			}
+
 			cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
 
-			if !strings.Contains(cleanProcessed, tc.expectedContains) {
+			if tc.expectedContains != "" && !strings.Contains(cleanProcessed, tc.expectedContains) {
 				t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
 			}
 
@@ -697,7 +711,7 @@ func TestProcessBody(t *testing.T) {
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
+			processed, _, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
 			if err != nil {
 				t.Fatalf("ProcessBody() failed: %v", err)
 			}