feat: kitty protocol for images (#70) (#79)

Drew Smirnoff created

Change summary

fetcher/fetcher.go | 144 ++++++++++++++++++++-----
go.mod             |   2 
tui/email_view.go  |  32 ++++-
tui/inbox.go       |   2 
view/html.go       | 268 +++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 406 insertions(+), 42 deletions(-)

Detailed changes

fetcher/fetcher.go 🔗

@@ -8,6 +8,7 @@ import (
 	"io/ioutil"
 	"mime"
 	"mime/quotedprintable"
+	"os"
 	"strings"
 	"time"
 
@@ -21,10 +22,13 @@ import (
 
 // Attachment holds data for an email attachment.
 type Attachment struct {
-	Filename string
-	PartID   string // Keep PartID to fetch on demand
-	Data     []byte
-	Encoding string // Store encoding for proper decoding
+	Filename  string
+	PartID    string // Keep PartID to fetch on demand
+	Data      []byte
+	Encoding  string // Store encoding for proper decoding
+	MIMEType  string // Full MIME type (e.g., image/png)
+	ContentID string // Content-ID for inline assets (e.g., cid: references)
+	Inline    bool   // True when the part is meant to be displayed inline
 }
 
 type Email struct {
@@ -86,6 +90,18 @@ func decodeHeader(header string) string {
 	return decoded
 }
 
+func decodeAttachmentData(rawBytes []byte, encoding string) ([]byte, error) {
+	switch strings.ToLower(encoding) {
+	case "base64":
+		decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
+		return ioutil.ReadAll(decoder)
+	case "quoted-printable":
+		return ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
+	default:
+		return rawBytes, nil
+	}
+}
+
 func connect(account *config.Account) (*client.Client, error) {
 	imapServer := account.GetIMAPServer()
 	imapPort := account.GetIMAPPort()
@@ -234,6 +250,41 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint
 	seqset := new(imap.SeqSet)
 	seqset.AddNum(uid)
 
+	fetchInlinePart := func(partID, encoding string) ([]byte, error) {
+		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
+		section, err := imap.ParseBodySectionName(fetchItem)
+		if err != nil {
+			return nil, err
+		}
+
+		partMessages := make(chan *imap.Message, 1)
+		partDone := make(chan error, 1)
+		go func() {
+			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
+		}()
+
+		if err := <-partDone; err != nil {
+			return nil, err
+		}
+
+		partMsg := <-partMessages
+		if partMsg == nil {
+			return nil, fmt.Errorf("could not fetch inline part %s", partID)
+		}
+
+		literal := partMsg.GetBody(section)
+		if literal == nil {
+			return nil, fmt.Errorf("could not get inline part body %s", partID)
+		}
+
+		rawBytes, err := ioutil.ReadAll(literal)
+		if err != nil {
+			return nil, err
+		}
+
+		return decodeAttachmentData(rawBytes, encoding)
+	}
+
 	messages := make(chan *imap.Message, 1)
 	done := make(chan error, 1)
 	fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
@@ -250,13 +301,24 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint
 		return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
 	}
 
-	var textPartID string
+	var plainPartID string
+	var htmlPartID string
 	var attachments []Attachment
 	var checkPart func(part *imap.BodyStructure, partID string)
 	checkPart = func(part *imap.BodyStructure, partID string) {
-		// Check for text content
-		if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
-			textPartID = partID
+		// Check for text content (prefer html over plain)
+		if part.MIMEType == "text" {
+			sub := strings.ToLower(part.MIMESubType)
+			switch sub {
+			case "html":
+				if htmlPartID == "" {
+					htmlPartID = partID
+				}
+			case "plain":
+				if plainPartID == "" {
+					plainPartID = partID
+				}
+			}
 		}
 
 		// Check for attachments using multiple methods
@@ -284,13 +346,31 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint
 			}
 		}
 
-		// Add as attachment if it has a disposition or a filename (and not just plain text)
-		if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") {
-			attachments = append(attachments, Attachment{
-				Filename: filename,
-				PartID:   partID,
-				Encoding: part.Encoding, // Store encoding for proper decoding
-			})
+		// Add as attachment if it has a disposition or a filename (and not just plain text).
+		// Allow inline parts without filenames (common for cid images).
+		contentID := strings.Trim(part.Id, "<>")
+		mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
+		isCID := contentID != ""
+		isInline := part.Disposition == "inline" || isCID
+
+		if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
+			filename = "inline"
+		}
+		if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
+			att := Attachment{
+				Filename:  filename,
+				PartID:    partID,
+				Encoding:  part.Encoding, // Store encoding for proper decoding
+				MIMEType:  mimeType,
+				ContentID: contentID,
+				Inline:    isInline,
+			}
+			if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
+				if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
+					att.Data = data
+				}
+			}
+			attachments = append(attachments, att)
 		}
 	}
 
@@ -323,6 +403,22 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint
 	findParts(msg.BodyStructure, "")
 
 	var body string
+	textPartID := ""
+	if htmlPartID != "" {
+		textPartID = htmlPartID
+	} else if plainPartID != "" {
+		textPartID = plainPartID
+	}
+	if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
+		msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
+		fmt.Print(msg)
+		if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
+			if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
+				_, _ = f.WriteString(msg)
+				_ = f.Close()
+			}
+		}
+	}
 	if textPartID != "" {
 		partMessages := make(chan *imap.Message, 1)
 		partDone := make(chan error, 1)
@@ -430,23 +526,11 @@ func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uin
 		return nil, err
 	}
 
-	switch strings.ToLower(encoding) {
-	case "base64":
-		decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
-		decoded, err := ioutil.ReadAll(decoder)
-		if err == nil {
-			return decoded, nil
-		}
-		return rawBytes, nil
-	case "quoted-printable":
-		decoded, err := ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
-		if err == nil {
-			return decoded, nil
-		}
-		return rawBytes, nil
-	default:
+	decoded, err := decodeAttachmentData(rawBytes, encoding)
+	if err != nil {
 		return rawBytes, nil
 	}
+	return decoded, nil
 }
 
 func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {

go.mod 🔗

@@ -11,6 +11,7 @@ require (
 	github.com/emersion/go-message v0.18.2
 	github.com/google/uuid v1.6.0
 	github.com/yuin/goldmark v1.7.13
+	golang.org/x/sys v0.38.0
 	golang.org/x/text v0.32.0
 )
 
@@ -35,5 +36,4 @@ require (
 	github.com/sahilm/fuzzy v0.1.1 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	golang.org/x/net v0.47.0 // indirect
-	golang.org/x/sys v0.38.0 // indirect
 )

tui/email_view.go 🔗

@@ -1,6 +1,7 @@
 package tui
 
 import (
+	"encoding/base64"
 	"fmt"
 	"strings"
 
@@ -28,7 +29,8 @@ type EmailView struct {
 
 func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind) *EmailView {
 	// Pass the styles from the tui package to the view package
-	body, err := view.ProcessBody(email.Body, H1Style, H2Style, BodyStyle)
+	inlineImages := inlineImagesFromAttachments(email.Attachments)
+	body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle)
 	if err != nil {
 		body = fmt.Sprintf("Error rendering body: %v", err)
 	}
@@ -42,12 +44,10 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
 		attachmentHeight = len(email.Attachments) + 2
 	}
 
-	// Build viewport with initial size and set wrapped content to fit the width.
+	// Build viewport with initial size and set wrapped content.
 	vp := viewport.New(width, height-headerHeight-attachmentHeight)
-
-	// Wrap the body to the viewport width so lines don't run off-screen.
 	wrapped := wrapBodyToWidth(body, vp.Width)
-	vp.SetContent(wrapped)
+	vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
 
 	return &EmailView{
 		viewport:   vp,
@@ -74,6 +74,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.focusOnAttachments = false
 				return m, nil
 			}
+			m.viewport.SetContent("\x1b_Ga=d\x1b\\")
 			return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} }
 		}
 
@@ -139,13 +140,14 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.viewport.Width = msg.Width
 		m.viewport.Height = msg.Height - headerHeight - attachmentHeight
 
-		// When the window size changes, rewrap the body to the new width
-		body, err := view.ProcessBody(m.email.Body, H1Style, H2Style, BodyStyle)
+		// When the window size changes, wrap and clear kitty images to keep placement stable
+		inlineImages := inlineImagesFromAttachments(m.email.Attachments)
+		body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle)
 		if err != nil {
 			body = fmt.Sprintf("Error rendering body: %v", err)
 		}
 		wrapped := wrapBodyToWidth(body, m.viewport.Width)
-		m.viewport.SetContent(wrapped)
+		m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
 	}
 
 	m.viewport, cmd = m.viewport.Update(msg)
@@ -190,6 +192,20 @@ func (m *EmailView) GetAccountID() string {
 	return m.accountID
 }
 
+func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
+	var imgs []view.InlineImage
+	for _, att := range atts {
+		if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
+			continue
+		}
+		imgs = append(imgs, view.InlineImage{
+			CID:    att.ContentID,
+			Base64: base64.StdEncoding.EncodeToString(att.Data),
+		})
+	}
+	return imgs
+}
+
 func wrapBodyToWidth(body string, width int) string {
 	return BodyStyle.Width(width).Render(body)
 }

tui/inbox.go 🔗

@@ -458,7 +458,7 @@ func (m *Inbox) View() string {
 	}
 
 	b.WriteString(m.list.View())
-	return "\n" + b.String()
+	return "\x1b_Ga=d\x1b\\\\\n" + b.String()
 }
 
 // GetCurrentAccountID returns the currently selected account ID

view/html.go 🔗

@@ -2,18 +2,82 @@ package view
 
 import (
 	"bytes"
+	"encoding/base64"
 	"fmt"
+	"image"
+	"image/png"
 	"io"
 	"mime/quotedprintable"
+	"net/http"
+	"os"
 	"regexp"
 	"strings"
+	"time"
+
+	_ "image/gif"
+	_ "image/jpeg"
 
 	"github.com/PuerkitoBio/goquery"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/renderer/html"
+	"golang.org/x/sys/unix"
 )
 
+// getTerminalCellSize returns the height of a terminal cell in pixels.
+// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
+// Falls back to a default of 18 pixels if the query fails.
+func getTerminalCellSize() int {
+	const defaultCellHeight = 18
+
+	// Try stdout, stdin, stderr, then /dev/tty as last resort
+	fds := []int{int(os.Stdout.Fd()), int(os.Stdin.Fd()), int(os.Stderr.Fd())}
+
+	for _, fd := range fds {
+		if cellHeight := getCellHeightFromFd(fd); cellHeight > 0 {
+			return cellHeight
+		}
+	}
+
+	// Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea)
+	if tty, err := os.Open("/dev/tty"); err == nil {
+		defer tty.Close()
+		if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 {
+			return cellHeight
+		}
+	}
+
+	debugKitty("using default cell height: %d pixels", defaultCellHeight)
+	return defaultCellHeight
+}
+
+// getCellHeightFromFd attempts to get the terminal cell height from a file descriptor.
+// Returns 0 if it fails or if pixel dimensions are not available.
+func getCellHeightFromFd(fd int) int {
+	ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
+	if err != nil {
+		return 0
+	}
+
+	// ws.Row = number of character rows
+	// ws.Ypixel = height in pixels
+	// Some terminals don't report pixel dimensions (return 0)
+	if ws.Row > 0 && ws.Ypixel > 0 {
+		cellHeight := int(ws.Ypixel) / int(ws.Row)
+		if cellHeight > 0 {
+			debugKitty("terminal cell height: %d pixels (rows=%d, ypixel=%d, fd=%d)", cellHeight, ws.Row, ws.Ypixel, fd)
+			return cellHeight
+		}
+	}
+
+	// Terminal reported dimensions but no pixel info - this is common
+	if ws.Row > 0 && ws.Ypixel == 0 {
+		debugKitty("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row)
+	}
+
+	return 0
+}
+
 // hyperlink formats a string as a terminal-clickable hyperlink.
 func hyperlink(url, text string) string {
 	if text == "" {
@@ -45,9 +109,176 @@ func markdownToHTML(md []byte) []byte {
 	return buf.Bytes()
 }
 
+func kittySupported() bool {
+	term := strings.ToLower(os.Getenv("TERM"))
+	if strings.Contains(term, "kitty") {
+		return true
+	}
+	return os.Getenv("KITTY_WINDOW_ID") != ""
+}
+
+func debugKitty(format string, args ...interface{}) {
+	if os.Getenv("DEBUG_KITTY_IMAGES") == "" {
+		return
+	}
+	msg := fmt.Sprintf("[kitty-img] "+format+"\n", args...)
+	fmt.Print(msg)
+	if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
+		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
+			_, _ = f.WriteString(msg)
+			_ = f.Close()
+		}
+	}
+}
+
+func fetchRemoteBase64(url string) string {
+	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
+		return ""
+	}
+	client := &http.Client{Timeout: 5 * time.Second}
+	resp, err := client.Get(url)
+	if err != nil {
+		debugKitty("remote fetch failed url=%s err=%v", url, err)
+		return ""
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		debugKitty("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
+		return ""
+	}
+	data, err := io.ReadAll(resp.Body)
+	if err != nil {
+		debugKitty("remote fetch read error url=%s err=%v", url, err)
+		return ""
+	}
+
+	img, _, err := image.Decode(bytes.NewReader(data))
+	if err != nil {
+		debugKitty("remote decode failed url=%s err=%v", url, err)
+		return ""
+	}
+
+	var buf bytes.Buffer
+	if err := png.Encode(&buf, img); err != nil {
+		debugKitty("remote png encode failed url=%s err=%v", url, err)
+		return ""
+	}
+
+	encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
+	debugKitty("remote fetch ok url=%s len=%d", url, len(encoded))
+	return encoded
+}
+
+func dataURIBase64(uri string) string {
+	if !strings.HasPrefix(uri, "data:") {
+		return ""
+	}
+	comma := strings.Index(uri, ",")
+	if comma == -1 || comma+1 >= len(uri) {
+		return ""
+	}
+	return uri[comma+1:]
+}
+
+// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
+// This prevents the newline-collapsing regex from removing intentional spacing.
+// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
+const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
+const imageRowPlaceholderSuffix = "]]"
+
+func kittyInlineImage(payload string) string {
+	if payload == "" {
+		return ""
+	}
+
+	const chunkSize = 4096
+	var b strings.Builder
+
+	// Calculate how many terminal rows the image occupies to advance text after it.
+	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
+			}
+			debugKitty("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
+		}
+	}
+
+	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 {
+			// C=1 means cursor does NOT move after image render (stays at top-left of image position)
+			// This is needed for proper TUI rendering, but we must add newlines to push text below
+			b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
+		} else {
+			b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
+		}
+	}
+
+	// Add newlines to push cursor below the image.
+	// Use a placeholder that won't be collapsed by the newline regex.
+	b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
+
+	return b.String()
+}
+
+// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
+func expandImageRowPlaceholders(text string) string {
+	re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
+	return re.ReplaceAllStringFunc(text, func(match string) string {
+		// Extract the number of rows from the placeholder
+		numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
+		numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
+		rows := 1
+		if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
+			rows = 1
+		}
+		// Return the newlines needed to push content below the image
+		return strings.Repeat("\n", rows)
+	})
+}
+
+type InlineImage struct {
+	CID    string
+	Base64 string
+}
+
+// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
+func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
+	inlineMap := make(map[string]string, len(inline))
+	for _, img := range inline {
+		cid := strings.TrimSpace(img.CID)
+		cid = strings.TrimPrefix(cid, "<")
+		cid = strings.TrimSuffix(cid, ">")
+		cid = strings.TrimPrefix(cid, "cid:")
+		if cid == "" || img.Base64 == "" {
+			continue
+		}
+		inlineMap[cid] = img.Base64
+	}
+	return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle)
+}
+
 // 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) (string, error) {
+	return processBody(rawBody, nil, h1Style, h2Style, bodyStyle)
+}
+
+func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
 	decodedBody, err := decodeQuotedPrintable(rawBody)
 	if err != nil {
 		decodedBody = rawBody
@@ -73,7 +304,6 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st
 	})
 
 	// Add newlines after block elements for better spacing.
-	// THIS IS THE KEY FIX: Include h1 and h2 in the selector.
 	doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
 		s.After("\n\n")
 	})
@@ -101,15 +331,49 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st
 		if alt == "" {
 			alt = "Does not contain alt text"
 		}
+
+		if kittySupported() {
+			var payload string
+			if strings.HasPrefix(src, "data:image/") {
+				payload = dataURIBase64(src)
+			} else if strings.HasPrefix(src, "cid:") {
+				cid := strings.TrimPrefix(src, "cid:")
+				cid = strings.Trim(cid, "<>")
+				if inline != nil {
+					payload = inline[cid]
+					debugKitty("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
+				} else {
+					debugKitty("cid lookup skipped inline map nil for %s", cid)
+				}
+			} else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
+				payload = fetchRemoteBase64(src)
+			}
+
+			if payload != "" {
+				if rendered := kittyInlineImage(payload); rendered != "" {
+					debugKitty("rendered inline image src=%s len=%d dataURI=%t cid=%t", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
+					s.ReplaceWithHtml("\n" + rendered + "\n")
+					return
+				}
+				debugKitty("payload present but renderer returned empty src=%s len=%d", src, len(payload))
+			} else {
+				debugKitty("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
+			}
+		} else {
+			debugKitty("kitty not detected for src=%s", src)
+		}
+
 		s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
 	})
 
 	text := doc.Text()
 
+	// Collapse excessive newlines, but not the image row placeholders
 	re := regexp.MustCompile(`\n{3,}`)
 	text = re.ReplaceAllString(text, "\n\n")
 
-	text = strings.TrimSpace(text)
+	// Now expand the image row placeholders to actual newlines
+	text = expandImageRowPlaceholders(text)
 
 	return bodyStyle.Render(text), nil
 }