html.go

  1package view
  2
  3import (
  4	"bytes"
  5	"encoding/base64"
  6	"fmt"
  7	"image"
  8	"image/png"
  9	"io"
 10	"mime/quotedprintable"
 11	"net/http"
 12	"os"
 13	"regexp"
 14	"strings"
 15	"time"
 16
 17	_ "image/gif"
 18	_ "image/jpeg"
 19
 20	"github.com/PuerkitoBio/goquery"
 21	"github.com/charmbracelet/lipgloss"
 22	"github.com/yuin/goldmark"
 23	"github.com/yuin/goldmark/renderer/html"
 24	"golang.org/x/sys/unix"
 25)
 26
 27// getTerminalCellSize returns the height of a terminal cell in pixels.
 28// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
 29// Falls back to a default of 18 pixels if the query fails.
 30func getTerminalCellSize() int {
 31	const defaultCellHeight = 18
 32
 33	// Try stdout, stdin, stderr, then /dev/tty as last resort
 34	fds := []int{int(os.Stdout.Fd()), int(os.Stdin.Fd()), int(os.Stderr.Fd())}
 35
 36	for _, fd := range fds {
 37		if cellHeight := getCellHeightFromFd(fd); cellHeight > 0 {
 38			return cellHeight
 39		}
 40	}
 41
 42	// Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea)
 43	if tty, err := os.Open("/dev/tty"); err == nil {
 44		defer tty.Close()
 45		if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 {
 46			return cellHeight
 47		}
 48	}
 49
 50	debugKitty("using default cell height: %d pixels", defaultCellHeight)
 51	return defaultCellHeight
 52}
 53
 54// getCellHeightFromFd attempts to get the terminal cell height from a file descriptor.
 55// Returns 0 if it fails or if pixel dimensions are not available.
 56func getCellHeightFromFd(fd int) int {
 57	ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
 58	if err != nil {
 59		return 0
 60	}
 61
 62	// ws.Row = number of character rows
 63	// ws.Ypixel = height in pixels
 64	// Some terminals don't report pixel dimensions (return 0)
 65	if ws.Row > 0 && ws.Ypixel > 0 {
 66		cellHeight := int(ws.Ypixel) / int(ws.Row)
 67		if cellHeight > 0 {
 68			debugKitty("terminal cell height: %d pixels (rows=%d, ypixel=%d, fd=%d)", cellHeight, ws.Row, ws.Ypixel, fd)
 69			return cellHeight
 70		}
 71	}
 72
 73	// Terminal reported dimensions but no pixel info - this is common
 74	if ws.Row > 0 && ws.Ypixel == 0 {
 75		debugKitty("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row)
 76	}
 77
 78	return 0
 79}
 80
 81// hyperlink formats a string as a terminal-clickable hyperlink.
 82func hyperlink(url, text string) string {
 83	if text == "" {
 84		text = url
 85	}
 86	return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, text)
 87}
 88
 89func decodeQuotedPrintable(s string) (string, error) {
 90	reader := quotedprintable.NewReader(strings.NewReader(s))
 91	body, err := io.ReadAll(reader)
 92	if err != nil {
 93		return "", err
 94	}
 95	return string(body), nil
 96}
 97
 98// markdownToHTML converts a Markdown string to an HTML string.
 99func markdownToHTML(md []byte) []byte {
100	var buf bytes.Buffer
101	p := goldmark.New(
102		goldmark.WithRendererOptions(
103			html.WithUnsafe(), // Allow raw HTML in email.
104		),
105	)
106	if err := p.Convert(md, &buf); err != nil {
107		return md // Fallback to original markdown.
108	}
109	return buf.Bytes()
110}
111
112func kittySupported() bool {
113	term := strings.ToLower(os.Getenv("TERM"))
114	if strings.Contains(term, "kitty") {
115		return true
116	}
117	return os.Getenv("KITTY_WINDOW_ID") != ""
118}
119
120func debugKitty(format string, args ...interface{}) {
121	if os.Getenv("DEBUG_KITTY_IMAGES") == "" {
122		return
123	}
124	msg := fmt.Sprintf("[kitty-img] "+format+"\n", args...)
125	fmt.Print(msg)
126	if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
127		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
128			_, _ = f.WriteString(msg)
129			_ = f.Close()
130		}
131	}
132}
133
134func fetchRemoteBase64(url string) string {
135	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
136		return ""
137	}
138	client := &http.Client{Timeout: 5 * time.Second}
139	resp, err := client.Get(url)
140	if err != nil {
141		debugKitty("remote fetch failed url=%s err=%v", url, err)
142		return ""
143	}
144	defer resp.Body.Close()
145	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
146		debugKitty("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
147		return ""
148	}
149	data, err := io.ReadAll(resp.Body)
150	if err != nil {
151		debugKitty("remote fetch read error url=%s err=%v", url, err)
152		return ""
153	}
154
155	img, _, err := image.Decode(bytes.NewReader(data))
156	if err != nil {
157		debugKitty("remote decode failed url=%s err=%v", url, err)
158		return ""
159	}
160
161	var buf bytes.Buffer
162	if err := png.Encode(&buf, img); err != nil {
163		debugKitty("remote png encode failed url=%s err=%v", url, err)
164		return ""
165	}
166
167	encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
168	debugKitty("remote fetch ok url=%s len=%d", url, len(encoded))
169	return encoded
170}
171
172func dataURIBase64(uri string) string {
173	if !strings.HasPrefix(uri, "data:") {
174		return ""
175	}
176	comma := strings.Index(uri, ",")
177	if comma == -1 || comma+1 >= len(uri) {
178		return ""
179	}
180	return uri[comma+1:]
181}
182
183// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
184// This prevents the newline-collapsing regex from removing intentional spacing.
185// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
186const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
187const imageRowPlaceholderSuffix = "]]"
188
189func kittyInlineImage(payload string) string {
190	if payload == "" {
191		return ""
192	}
193
194	const chunkSize = 4096
195	var b strings.Builder
196
197	// Calculate how many terminal rows the image occupies to advance text after it.
198	rows := 1
199	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
200		if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
201			cellHeight := getTerminalCellSize()
202			h := img.Bounds().Dy()
203			rows = (h + cellHeight - 1) / cellHeight
204			if rows < 1 {
205				rows = 1
206			}
207			debugKitty("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
208		}
209	}
210
211	for offset := 0; offset < len(payload); offset += chunkSize {
212		end := offset + chunkSize
213		if end > len(payload) {
214			end = len(payload)
215		}
216		more := "0"
217		if end < len(payload) {
218			more = "1"
219		}
220
221		chunk := payload[offset:end]
222		if offset == 0 {
223			// C=1 means cursor does NOT move after image render (stays at top-left of image position)
224			// This is needed for proper TUI rendering, but we must add newlines to push text below
225			b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
226		} else {
227			b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
228		}
229	}
230
231	// Add newlines to push cursor below the image.
232	// Use a placeholder that won't be collapsed by the newline regex.
233	b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
234
235	return b.String()
236}
237
238// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
239func expandImageRowPlaceholders(text string) string {
240	re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
241	return re.ReplaceAllStringFunc(text, func(match string) string {
242		// Extract the number of rows from the placeholder
243		numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
244		numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
245		rows := 1
246		if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
247			rows = 1
248		}
249		// Return the newlines needed to push content below the image
250		return strings.Repeat("\n", rows)
251	})
252}
253
254type InlineImage struct {
255	CID    string
256	Base64 string
257}
258
259// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
260func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
261	inlineMap := make(map[string]string, len(inline))
262	for _, img := range inline {
263		cid := strings.TrimSpace(img.CID)
264		cid = strings.TrimPrefix(cid, "<")
265		cid = strings.TrimSuffix(cid, ">")
266		cid = strings.TrimPrefix(cid, "cid:")
267		if cid == "" || img.Base64 == "" {
268			continue
269		}
270		inlineMap[cid] = img.Base64
271	}
272	return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle)
273}
274
275// ProcessBody takes a raw email body, decodes it, and formats it as plain
276// text with terminal hyperlinks.
277func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
278	return processBody(rawBody, nil, h1Style, h2Style, bodyStyle)
279}
280
281func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
282	decodedBody, err := decodeQuotedPrintable(rawBody)
283	if err != nil {
284		decodedBody = rawBody
285	}
286
287	htmlBody := markdownToHTML([]byte(decodedBody))
288
289	doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
290	if err != nil {
291		return "", fmt.Errorf("could not parse email body: %w", err)
292	}
293
294	doc.Find("style, script").Remove()
295
296	// Style headers by setting their text content.
297	// We use SetText so the h1/h2 tags remain in the document for spacing logic.
298	doc.Find("h1").Each(func(i int, s *goquery.Selection) {
299		s.SetText(h1Style.Render(s.Text()))
300	})
301
302	doc.Find("h2").Each(func(i int, s *goquery.Selection) {
303		s.SetText(h2Style.Render(s.Text()))
304	})
305
306	// Add newlines after block elements for better spacing.
307	doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
308		s.After("\n\n")
309	})
310
311	// Replace <br> tags with newlines
312	doc.Find("br").Each(func(i int, s *goquery.Selection) {
313		s.ReplaceWithHtml("\n")
314	})
315
316	// Format links and images
317	doc.Find("a").Each(func(i int, s *goquery.Selection) {
318		href, exists := s.Attr("href")
319		if !exists {
320			return
321		}
322		s.ReplaceWithHtml(hyperlink(href, s.Text()))
323	})
324
325	doc.Find("img").Each(func(i int, s *goquery.Selection) {
326		src, exists := s.Attr("src")
327		if !exists {
328			return
329		}
330		alt, _ := s.Attr("alt")
331		if alt == "" {
332			alt = "Does not contain alt text"
333		}
334
335		if kittySupported() {
336			var payload string
337			if strings.HasPrefix(src, "data:image/") {
338				payload = dataURIBase64(src)
339			} else if strings.HasPrefix(src, "cid:") {
340				cid := strings.TrimPrefix(src, "cid:")
341				cid = strings.Trim(cid, "<>")
342				if inline != nil {
343					payload = inline[cid]
344					debugKitty("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
345				} else {
346					debugKitty("cid lookup skipped inline map nil for %s", cid)
347				}
348			} else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
349				payload = fetchRemoteBase64(src)
350			}
351
352			if payload != "" {
353				if rendered := kittyInlineImage(payload); rendered != "" {
354					debugKitty("rendered inline image src=%s len=%d dataURI=%t cid=%t", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
355					s.ReplaceWithHtml("\n" + rendered + "\n")
356					return
357				}
358				debugKitty("payload present but renderer returned empty src=%s len=%d", src, len(payload))
359			} else {
360				debugKitty("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
361			}
362		} else {
363			debugKitty("kitty not detected for src=%s", src)
364		}
365
366		s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
367	})
368
369	text := doc.Text()
370
371	// Collapse excessive newlines, but not the image row placeholders
372	re := regexp.MustCompile(`\n{3,}`)
373	text = re.ReplaceAllString(text, "\n\n")
374
375	// Now expand the image row placeholders to actual newlines
376	text = expandImageRowPlaceholders(text)
377
378	return bodyStyle.Render(text), nil
379}