From bc45d1b828752f15cd6e785ddd4e8ce26000f6aa Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Wed, 4 Mar 2026 13:25:41 +0400 Subject: [PATCH] fix: images display (#237) * fix: images display (#223) Signed-off-by: drew * fix: improve image rendering Signed-off-by: drew --------- Signed-off-by: drew --- main.go | 4 + tui/email_view.go | 84 ++++++++++++------- view/html.go | 202 ++++++++++++++++++++++++++++++++++++++++++---- view/html_test.go | 30 +++++-- 4 files changed, 270 insertions(+), 50 deletions(-) diff --git a/main.go b/main.go index 7e49dbe3be28ad54e476252089aff4eb526e5c41..139652f5984eaaa971f727e0372ce73e1649a86e 100644 --- a/main.go +++ b/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...") diff --git a/tui/email_view.go b/tui/email_view.go index 57983084959db6386459372850a23ec766c3d6ca..9e4cbe514dca05224ba715fe0cb157e918e2f58d 100644 --- a/tui/email_view.go +++ b/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)) } diff --git a/view/html.go b/view/html.go index ae25e63203723c92f23fba6fab946225470936fb..54c4ecb599f145049323f358262c0271b35363c3 100644 --- a/view/html.go +++ b/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 diff --git a/view/html_test.go b/view/html_test.go index 36c0a9016b644aacd47f61fad6de70b94ecee823..31f69b9bd0f0ffabf9a8f734ae45dc528c69c866 100644 --- a/view/html_test.go +++ b/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: `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: `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) }