html.go

   1package view
   2
   3import (
   4	"encoding/base64"
   5	"fmt"
   6	"io"
   7	"mime/quotedprintable"
   8	"os"
   9	"regexp"
  10	"strings"
  11	"sync/atomic"
  12	"time"
  13
  14	"charm.land/lipgloss/v2"
  15	"github.com/floatpane/matcha/clib"
  16	"github.com/floatpane/matcha/internal/httpclient"
  17	"github.com/floatpane/matcha/theme"
  18	lru "github.com/hashicorp/golang-lru/v2"
  19)
  20
  21func linkStyle() lipgloss.Style {
  22	return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Link)
  23}
  24
  25// getTerminalCellSize returns the height of a terminal cell in pixels.
  26// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
  27// Falls back to a default of 18 pixels if the query fails.
  28func getTerminalCellSize() int {
  29	const defaultCellHeight = 18
  30
  31	// Try stdout, stdin, stderr, then /dev/tty as last resort
  32	fds := []int{int(os.Stdout.Fd()), int(os.Stdin.Fd()), int(os.Stderr.Fd())}
  33
  34	for _, fd := range fds {
  35		if cellHeight := getCellHeightFromFd(fd); cellHeight > 0 {
  36			return cellHeight
  37		}
  38	}
  39
  40	// Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea)
  41	if tty, err := os.Open("/dev/tty"); err == nil {
  42		defer tty.Close()
  43		if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 {
  44			return cellHeight
  45		}
  46	}
  47
  48	debugImageProtocol("using default cell height: %d pixels", defaultCellHeight)
  49	return defaultCellHeight
  50}
  51
  52// hyperlinkSupported checks if the terminal supports OSC 8 hyperlinks.
  53func hyperlinkSupported() bool {
  54	term := strings.ToLower(os.Getenv("TERM"))
  55
  56	// Terminals known to support OSC 8 hyperlinks
  57	supportedTerms := []string{
  58		"kitty",
  59		"ghostty",
  60		"wezterm",
  61		"alacritty",
  62		"foot",
  63		"tmux",
  64		"screen",
  65	}
  66
  67	for _, supported := range supportedTerms {
  68		if strings.Contains(term, supported) {
  69			return true
  70		}
  71	}
  72
  73	// Check for specific terminal programs
  74	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
  75	supportedPrograms := []string{
  76		"iterm.app",
  77		"hyper",
  78		"vscode",
  79		"ghostty",
  80		"wezterm",
  81	}
  82
  83	for _, supported := range supportedPrograms {
  84		if strings.Contains(termProgram, supported) {
  85			return true
  86		}
  87	}
  88
  89	// Check for VTE-based terminals (GNOME Terminal, etc.)
  90	if os.Getenv("VTE_VERSION") != "" {
  91		return true
  92	}
  93
  94	// Check for specific environment variables that indicate hyperlink support
  95	if os.Getenv("KITTY_WINDOW_ID") != "" ||
  96		os.Getenv("GHOSTTY_RESOURCES_DIR") != "" ||
  97		os.Getenv("WEZTERM_EXECUTABLE") != "" ||
  98		os.Getenv("WT_SESSION") != "" {
  99		return true
 100	}
 101
 102	return false
 103}
 104
 105// hyperlink formats a string as either a terminal-clickable hyperlink or plain text with URL.
 106func hyperlink(url, text string) string {
 107	if text == "" {
 108		text = url
 109	}
 110
 111	supported := hyperlinkSupported()
 112
 113	if supported {
 114		// Use OSC 8 hyperlink sequence for supported terminals
 115		return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle().Render(text))
 116	} else {
 117		// Fallback to plain text format for unsupported terminals
 118		if text == url {
 119			return fmt.Sprintf("<%s>", linkStyle().Render(url))
 120		}
 121		return fmt.Sprintf("%s <%s>", linkStyle().Render(text), linkStyle().Render(url))
 122	}
 123}
 124
 125func decodeQuotedPrintable(s string) (string, error) {
 126	reader := quotedprintable.NewReader(strings.NewReader(s))
 127	body, err := io.ReadAll(reader)
 128	if err != nil {
 129		return "", err
 130	}
 131	return string(body), nil
 132}
 133
 134// markdownToHTML converts a Markdown string to an HTML string using md4c (C).
 135func markdownToHTML(md []byte) []byte {
 136	return clib.MarkdownToHTML(md)
 137}
 138
 139func kittySupported() bool {
 140	term := strings.ToLower(os.Getenv("TERM"))
 141	if strings.Contains(term, "kitty") {
 142		return true
 143	}
 144	return os.Getenv("KITTY_WINDOW_ID") != ""
 145}
 146
 147func ghosttySupported() bool {
 148	// Check for TERM containing ghostty
 149	term := strings.ToLower(os.Getenv("TERM"))
 150	if strings.Contains(term, "ghostty") {
 151		return true
 152	}
 153
 154	// Check for Ghostty-specific environment variables
 155	if os.Getenv("TERM_PROGRAM") == "ghostty" {
 156		return true
 157	}
 158
 159	// Check for GHOSTTY_RESOURCES_DIR which Ghostty sets
 160	return os.Getenv("GHOSTTY_RESOURCES_DIR") != ""
 161}
 162
 163func iterm2Supported() bool {
 164	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 165	if termProgram == "iterm.app" {
 166		return true
 167	}
 168
 169	// Check for iTerm2-specific environment variables
 170	if os.Getenv("ITERM_SESSION_ID") != "" || os.Getenv("ITERM_PROFILE") != "" {
 171		return true
 172	}
 173
 174	return false
 175}
 176
 177func weztermSupported() bool {
 178	// Check for WezTerm-specific environment variables
 179	if os.Getenv("WEZTERM_EXECUTABLE") != "" || os.Getenv("WEZTERM_CONFIG_FILE") != "" {
 180		return true
 181	}
 182
 183	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 184	if termProgram == "wezterm" {
 185		return true
 186	}
 187
 188	term := strings.ToLower(os.Getenv("TERM"))
 189	if strings.Contains(term, "wezterm") {
 190		return true
 191	}
 192
 193	return false
 194}
 195
 196func waystSupported() bool {
 197	term := strings.ToLower(os.Getenv("TERM"))
 198	if strings.Contains(term, "wayst") {
 199		return true
 200	}
 201
 202	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 203	if termProgram == "wayst" {
 204		return true
 205	}
 206
 207	return false
 208}
 209
 210func warpSupported() bool {
 211	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 212	if termProgram == "warp" {
 213		return true
 214	}
 215
 216	// Check for Warp-specific environment variables
 217	if os.Getenv("WARP_IS_LOCAL_SHELL_SESSION") != "" || os.Getenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED") != "" {
 218		return true
 219	}
 220
 221	return false
 222}
 223
 224func konsoleSupported() bool {
 225	// Check for Konsole-specific environment variables
 226	if os.Getenv("KONSOLE_DBUS_SESSION") != "" || os.Getenv("KONSOLE_VERSION") != "" {
 227		return true
 228	}
 229
 230	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 231	if termProgram == "konsole" {
 232		return true
 233	}
 234
 235	return false
 236}
 237
 238func zellijSupported() bool {
 239	return os.Getenv("ZELLIJ") != "" || os.Getenv("ZELLIJ_SESSION_NAME") != ""
 240}
 241
 242func sixelSupported() bool {
 243	// Zellij always supports Sixel
 244	if zellijSupported() {
 245		return true
 246	}
 247
 248	// Native Sixel terminals
 249	term := strings.ToLower(os.Getenv("TERM"))
 250	return strings.Contains(term, "mlterm") ||
 251		strings.Contains(term, "foot") ||
 252		(strings.Contains(term, "xterm") && os.Getenv("SIXEL") == "1")
 253}
 254
 255// ImageProtocolSupported checks if any supported image protocol terminal is detected.
 256func ImageProtocolSupported() bool {
 257	return imageProtocolSupported()
 258}
 259
 260// SixelSupported returns true if the terminal uses the Sixel graphics protocol.
 261func SixelSupported() bool {
 262	return sixelSupported()
 263}
 264
 265// imageProtocolSupported checks if any supported image protocol terminal is detected.
 266func imageProtocolSupported() bool {
 267	return sixelSupported() || kittySupported() || ghosttySupported() || iterm2Supported() ||
 268		weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
 269}
 270
 271func debugImageProtocol(format string, args ...interface{}) {
 272	if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
 273		return
 274	}
 275	msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
 276	fmt.Print(msg)
 277	if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" {
 278		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
 279			_, _ = f.WriteString(msg)
 280			_ = f.Close()
 281		}
 282	} else if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
 283		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
 284			_, _ = f.WriteString(msg)
 285			_ = f.Close()
 286		}
 287	}
 288}
 289
 290const remoteImageCacheSize = 20
 291
 292// remoteImageCache caches fetched remote images (URL -> base64 PNG string).
 293var remoteImageCache *lru.Cache[string, string]
 294
 295func init() {
 296	c, err := lru.New[string, string](remoteImageCacheSize)
 297	if err != nil {
 298		panic(err) // only fails on size <= 0
 299	}
 300	remoteImageCache = c
 301}
 302
 303// nextImageID is an auto-incrementing counter for Kitty image IDs.
 304var nextImageID uint32 = 1000
 305
 306// allocImageID returns a unique Kitty image ID.
 307func allocImageID() uint32 {
 308	return atomic.AddUint32(&nextImageID, 1)
 309}
 310
 311func fetchRemoteBase64(url string) string {
 312	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
 313		return ""
 314	}
 315
 316	// Check cache first
 317	if cached, ok := remoteImageCache.Get(url); ok {
 318		debugImageProtocol("remote cache hit url=%s", url)
 319		return cached
 320	}
 321
 322	client := httpclient.New(httpclient.RemoteImageTimeout)
 323	resp, err := client.Get(url)
 324	if err != nil {
 325		debugImageProtocol("remote fetch failed url=%s err=%v", url, err)
 326		return ""
 327	}
 328	defer resp.Body.Close()
 329	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
 330		debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
 331		return ""
 332	}
 333	// Limit response body to 10 MB to prevent memory exhaustion from
 334	// malicious or very large images.
 335	const maxImageSize = 10 << 20 // 10 MB
 336	data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize))
 337	if err != nil {
 338		debugImageProtocol("remote fetch read error url=%s err=%v", url, err)
 339		return ""
 340	}
 341
 342	result, ok := clib.DecodeToPNG(data)
 343	if !ok {
 344		debugImageProtocol("remote decode failed url=%s", url)
 345		return ""
 346	}
 347
 348	encoded := base64.StdEncoding.EncodeToString(result.PNGData)
 349	debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
 350	remoteImageCache.Add(url, encoded)
 351	return encoded
 352}
 353
 354func dataURIBase64(uri string) string {
 355	if !strings.HasPrefix(uri, "data:") {
 356		return ""
 357	}
 358	comma := strings.Index(uri, ",")
 359	if comma == -1 || comma+1 >= len(uri) {
 360		return ""
 361	}
 362	return uri[comma+1:]
 363}
 364
 365// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
 366// This prevents the newline-collapsing regex from removing intentional spacing.
 367// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
 368const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
 369const imageRowPlaceholderSuffix = "]]"
 370
 371func kittyInlineImage(payload string) string {
 372	if payload == "" {
 373		return ""
 374	}
 375
 376	const chunkSize = 4096
 377	var b strings.Builder
 378
 379	// Calculate how many terminal rows the image occupies to advance text after it.
 380	rows := 1
 381	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
 382		if _, h, ok := clib.ImageDimensions(data); ok {
 383			cellHeight := getTerminalCellSize()
 384			rows = (h + cellHeight - 1) / cellHeight
 385			if rows < 1 {
 386				rows = 1
 387			}
 388			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
 389		}
 390	}
 391
 392	for offset := 0; offset < len(payload); offset += chunkSize {
 393		end := offset + chunkSize
 394		if end > len(payload) {
 395			end = len(payload)
 396		}
 397		more := "0"
 398		if end < len(payload) {
 399			more = "1"
 400		}
 401
 402		chunk := payload[offset:end]
 403		if offset == 0 {
 404			// C=1 means cursor does NOT move after image render (stays at top-left of image position)
 405			// This is needed for proper TUI rendering, but we must add newlines to push text below
 406			b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
 407		} else {
 408			b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
 409		}
 410	}
 411
 412	// Add newlines to push cursor below the image.
 413	// Use a placeholder that won't be collapsed by the newline regex.
 414	b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
 415
 416	return b.String()
 417}
 418
 419// iterm2InlineImage renders an image using iTerm2's image protocol
 420func iterm2InlineImage(payload string) string {
 421	if payload == "" {
 422		return ""
 423	}
 424
 425	// Calculate rows for cursor positioning
 426	rows := 1
 427	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
 428		if _, h, ok := clib.ImageDimensions(data); ok {
 429			cellHeight := getTerminalCellSize()
 430			rows = (h + cellHeight - 1) / cellHeight
 431			if rows < 1 {
 432				rows = 1
 433			}
 434			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
 435		}
 436	}
 437
 438	// iTerm2 image protocol: ESC]1337;File=inline=1:<base64_data>BEL
 439	result := fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07\n", payload)
 440
 441	// Add placeholder for row spacing
 442	result += fmt.Sprintf("%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
 443
 444	return result
 445}
 446
 447// sixelInlineImage returns Sixel escape sequence + newline placeholders
 448func sixelInlineImage(base64PNG string) string {
 449	data, err := base64.StdEncoding.DecodeString(base64PNG)
 450	if err != nil {
 451		return ""
 452	}
 453
 454	cellHeight := getTerminalCellSize()
 455	sixel, rows, err := clib.EncodePNGToSixel(data, cellHeight)
 456	if err != nil {
 457		debugImageProtocol("Sixel encoding failed: %v", err)
 458		return ""
 459	}
 460
 461	debugImageProtocol("Sixel: encoded %d bytes, %d rows", len(sixel), rows)
 462
 463	// Sixel sequences don't auto-advance cursor
 464	// Add newlines to preserve layout
 465	return sixel + strings.Repeat("\n", rows)
 466}
 467
 468// sixelImageEscapeOnly returns raw Sixel for out-of-band rendering
 469func sixelImageEscapeOnly(base64PNG string) string {
 470	data, err := base64.StdEncoding.DecodeString(base64PNG)
 471	if err != nil {
 472		return ""
 473	}
 474
 475	cellHeight := getTerminalCellSize()
 476	sixel, _, err := clib.EncodePNGToSixel(data, cellHeight)
 477	if err != nil {
 478		return ""
 479	}
 480
 481	return sixel
 482}
 483
 484// renderInlineImage renders an image using the appropriate protocol for the detected terminal
 485func renderInlineImage(payload string) string {
 486	if payload == "" {
 487		return ""
 488	}
 489
 490	// Priority: Sixel in multiplexers overrides native protocols
 491	if sixelSupported() {
 492		return sixelInlineImage(payload)
 493	}
 494
 495	if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() {
 496		// These terminals use the Kitty graphics protocol
 497		return kittyInlineImage(payload)
 498	} else if iterm2Supported() || warpSupported() {
 499		// iTerm2 and Warp use the iTerm2 image protocol
 500		return iterm2InlineImage(payload)
 501	}
 502
 503	return ""
 504}
 505
 506// imageRows calculates the number of terminal rows an image occupies.
 507func imageRows(payload string) int {
 508	rows := 1
 509	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
 510		if _, h, ok := clib.ImageDimensions(data); ok {
 511			cellHeight := getTerminalCellSize()
 512			if cellHeight == 0 {
 513				cellHeight = 16
 514			}
 515			rows = (h + cellHeight - 1) / cellHeight
 516			if rows < 1 {
 517				rows = 1
 518			}
 519			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
 520		}
 521	}
 522	return rows
 523}
 524
 525// kittyUploadImage uploads image data to the terminal with a unique ID using
 526// the Kitty graphics protocol transmit action (a=t). The image is stored in
 527// the terminal's memory and can be displayed later by ID without re-sending data.
 528func kittyUploadImage(payload string, id uint32) {
 529	if payload == "" {
 530		return
 531	}
 532
 533	const chunkSize = 4096
 534	for offset := 0; offset < len(payload); offset += chunkSize {
 535		end := offset + chunkSize
 536		if end > len(payload) {
 537			end = len(payload)
 538		}
 539		more := "0"
 540		if end < len(payload) {
 541			more = "1"
 542		}
 543
 544		chunk := payload[offset:end]
 545		if offset == 0 {
 546			// a=t: transmit (upload) only, don't display yet
 547			// i=ID: assign this image ID
 548			fmt.Fprintf(os.Stdout, "\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk)
 549		} else {
 550			fmt.Fprintf(os.Stdout, "\x1b_Gm=%s;%s\x1b\\", more, chunk)
 551		}
 552	}
 553	os.Stdout.Sync()
 554}
 555
 556// kittyDisplayImage displays a previously uploaded image by its ID at the
 557// current cursor position. This is very fast since no image data is transmitted.
 558func kittyDisplayImage(id uint32) string {
 559	// a=p: put (display) an already-uploaded image by ID
 560	// C=1: cursor does not move
 561	return fmt.Sprintf("\x1b_Ga=p,i=%d,q=2,C=1\x1b\\", id)
 562}
 563
 564// iterm2ImageEscapeOnly returns only the iTerm2 image protocol escape sequence
 565// without any row placeholders. Used for out-of-band rendering to stdout.
 566func iterm2ImageEscapeOnly(payload string) string {
 567	if payload == "" {
 568		return ""
 569	}
 570	return fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07", payload)
 571}
 572
 573// RenderImageToStdout writes an image directly to stdout at the given screen
 574// row using cursor positioning. This bypasses bubbletea's cell-based renderer
 575// which cannot handle graphics protocol escape sequences.
 576//
 577// For Kitty-protocol terminals, images are uploaded once and then displayed by
 578// ID on subsequent calls, making scroll rendering nearly instant.
 579func RenderImageToStdout(placement *ImagePlacement, screenRow int, screenCol ...int) {
 580	if placement.Base64 == "" {
 581		return
 582	}
 583
 584	col := 1
 585	if len(screenCol) > 0 && screenCol[0] > 0 {
 586		col = screenCol[0]
 587	}
 588
 589	// Priority: Sixel in multiplexers
 590	if sixelSupported() {
 591		debugImageProtocol("Sixel: RenderImageToStdout row=%d col=%d base64len=%d", screenRow, col, len(placement.Base64))
 592
 593		// Encode once, reuse cached Sixel on subsequent renders (like Kitty's upload-once pattern)
 594		if placement.SixelEncoded == "" {
 595			placement.SixelEncoded = sixelImageEscapeOnly(placement.Base64)
 596			if placement.SixelEncoded == "" {
 597				debugImageProtocol("Sixel: sixelImageEscapeOnly returned empty")
 598				return
 599			}
 600		}
 601
 602		debugImageProtocol("Sixel: rendering %d bytes at row=%d col=%d", len(placement.SixelEncoded), screenRow+1, col)
 603		// Position cursor + render Sixel
 604		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u",
 605			screenRow+1, col, placement.SixelEncoded)
 606		os.Stdout.Sync()
 607		return
 608	}
 609
 610	useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported()
 611	useIterm2 := iterm2Supported() || warpSupported()
 612
 613	if useKitty {
 614		// Upload once, display by ID on subsequent renders
 615		if !placement.Uploaded {
 616			placement.ID = allocImageID()
 617			kittyUploadImage(placement.Base64, placement.ID)
 618			placement.Uploaded = true
 619		}
 620		seq := kittyDisplayImage(placement.ID)
 621		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq)
 622		os.Stdout.Sync()
 623	} else if useIterm2 {
 624		seq := iterm2ImageEscapeOnly(placement.Base64)
 625		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq)
 626		os.Stdout.Sync()
 627	}
 628}
 629
 630// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
 631func expandImageRowPlaceholders(text string) string {
 632	re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
 633	return re.ReplaceAllStringFunc(text, func(match string) string {
 634		// Extract the number of rows from the placeholder
 635		numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
 636		numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
 637		rows := 1
 638		if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
 639			rows = 1
 640		}
 641		// Return the newlines needed to push content below the image
 642		return strings.Repeat("\n", rows)
 643	})
 644}
 645
 646type InlineImage struct {
 647	CID    string
 648	Base64 string
 649}
 650
 651// ImagePlacement holds the data needed to render an image at a specific
 652// line in the email body. Images are rendered directly to stdout (bypassing
 653// bubbletea's cell-based renderer which cannot handle graphics protocols).
 654type ImagePlacement struct {
 655	Line         int    // Line number in the processed body text where the image starts
 656	Base64       string // Base64-encoded image data (PNG)
 657	Rows         int    // Number of terminal rows the image occupies
 658	Uploaded     bool   // Whether the image has been uploaded to the terminal via Kitty ID
 659	ID           uint32 // Kitty image ID for display-by-reference
 660	SixelEncoded string // Cached Sixel escape sequence (encode once, reuse on scroll)
 661}
 662
 663// BodyMIMEType values understood by ProcessBody/ProcessBodyWithInline. Empty
 664// string means "unknown" — the renderer falls back to running markdownToHTML
 665// before HTML parsing, which is correct for plaintext-with-markdown bodies but
 666// can mangle complex HTML (e.g. tables with attribute-heavy <td style="...">).
 667const (
 668	BodyMIMETypeHTML  = "text/html"
 669	BodyMIMETypePlain = "text/plain"
 670)
 671
 672// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
 673// Returns the rendered body text, image placements for out-of-band rendering, and any error.
 674// mimeType is "text/html", "text/plain", or "" (unknown — falls back to legacy markdown→HTML pre-pass).
 675func ProcessBodyWithInline(rawBody, mimeType string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 676	inlineMap := make(map[string]string, len(inline))
 677	for _, img := range inline {
 678		cid := strings.TrimSpace(img.CID)
 679		cid = strings.TrimPrefix(cid, "<")
 680		cid = strings.TrimSuffix(cid, ">")
 681		cid = strings.TrimPrefix(cid, "cid:")
 682		if cid == "" || img.Base64 == "" {
 683			continue
 684		}
 685		inlineMap[cid] = img.Base64
 686	}
 687	return processBody(rawBody, mimeType, inlineMap, h1Style, h2Style, bodyStyle, disableImages)
 688}
 689
 690// ProcessBody takes a raw email body, decodes it, and formats it as plain
 691// text with terminal hyperlinks.
 692// mimeType is "text/html", "text/plain", or "" (unknown — falls back to legacy markdown→HTML pre-pass).
 693func ProcessBody(rawBody, mimeType string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 694	return processBody(rawBody, mimeType, nil, h1Style, h2Style, bodyStyle, disableImages)
 695}
 696
 697func processBody(rawBody, mimeType string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 698	decodedBody, err := decodeQuotedPrintable(rawBody)
 699	if err != nil {
 700		decodedBody = rawBody
 701	}
 702
 703	// HTML bodies skip the markdown pre-pass — md4c can mangle attribute-heavy
 704	// or indented HTML (#602-style raw-tag bleed-through). Empty mimeType keeps
 705	// legacy behavior for cached/legacy callers that don't supply one.
 706	directHTML := mimeType == BodyMIMETypeHTML
 707	var htmlBody []byte
 708	if directHTML {
 709		htmlBody = []byte(decodedBody)
 710	} else {
 711		htmlBody = markdownToHTML([]byte(decodedBody))
 712	}
 713
 714	result, placements, err := renderHTMLToText(htmlBody, inline, h1Style, h2Style, disableImages)
 715	if err != nil {
 716		return "", nil, err
 717	}
 718
 719	// Some real-world HTML emails (newsletters with table-only layouts and no
 720	// <th>, AWeber-shape bodies) emit no visible content from htmlconv. Pre-
 721	// c11de45, every body went through markdownToHTML first, which happened to
 722	// keep these alive. Retry through the markdown pre-pass when the direct
 723	// HTML path produces nothing.
 724	if directHTML && strings.TrimSpace(result) == "" {
 725		result, placements, err = renderHTMLToText(markdownToHTML([]byte(decodedBody)), inline, h1Style, h2Style, disableImages)
 726		if err != nil {
 727			return "", nil, err
 728		}
 729	}
 730
 731	result = styleQuotedReplies(result)
 732	return bodyStyle.Render(result), placements, nil
 733}
 734
 735func renderHTMLToText(htmlBody []byte, inline map[string]string, h1Style, h2Style lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 736	// Parse HTML into structured elements using C parser.
 737	elements, ok := clib.HTMLToElements(string(htmlBody))
 738	if !ok {
 739		return "", nil, fmt.Errorf("could not parse email body")
 740	}
 741
 742	// Process elements: apply styles and collect image placements.
 743	var text strings.Builder
 744	var imgIndex int
 745	var pendingImages []struct {
 746		index   int
 747		payload string
 748		rows    int
 749	}
 750
 751	onWroteRegex := regexp.MustCompile(`On\s+(.+?),\s+(.+?)\s+wrote:`)
 752
 753	for _, elem := range elements {
 754		switch elem.Type {
 755		case clib.HElemText:
 756			text.WriteString(elem.Text)
 757
 758		case clib.HElemH1:
 759			text.WriteString(h1Style.Render(elem.Text))
 760			text.WriteString("\n\n")
 761
 762		case clib.HElemH2:
 763			text.WriteString(h2Style.Render(elem.Text))
 764			text.WriteString("\n\n")
 765
 766		case clib.HElemLink:
 767			text.WriteString(hyperlink(elem.Attr1, elem.Text))
 768
 769		case clib.HElemImage:
 770			src := elem.Attr1
 771			alt := elem.Attr2
 772
 773			if !disableImages && imageProtocolSupported() {
 774				var payload string
 775				if strings.HasPrefix(src, "data:image/") {
 776					payload = dataURIBase64(src)
 777				} else if strings.HasPrefix(src, "cid:") {
 778					cid := strings.TrimPrefix(src, "cid:")
 779					cid = strings.Trim(cid, "<>")
 780					if inline != nil {
 781						payload = inline[cid]
 782						debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
 783					} else {
 784						debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
 785					}
 786				} else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
 787					payload = fetchRemoteBase64(src)
 788				}
 789
 790				if payload != "" {
 791					rows := imageRows(payload)
 792					debugImageProtocol("collected image placement src=%s rows=%d", src, rows)
 793
 794					idx := imgIndex
 795					imgIndex++
 796					pendingImages = append(pendingImages, struct {
 797						index   int
 798						payload string
 799						rows    int
 800					}{idx, payload, rows})
 801
 802					text.WriteString(fmt.Sprintf("\n[[MATCHA_IMG:%d]]", idx))
 803					text.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
 804					continue
 805				}
 806				debugImageProtocol("no payload for src=%s", src)
 807			}
 808			if hyperlinkSupported() {
 809				text.WriteString(fmt.Sprintf("\n %s \n", hyperlink(src, fmt.Sprintf("[Click here to view image: %s]", alt))))
 810			} else {
 811				text.WriteString(fmt.Sprintf("\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
 812			}
 813
 814		case clib.HElemTable:
 815			headerRows := 0
 816			if elem.Attr1 != "" {
 817				fmt.Sscanf(elem.Attr1, "%d", &headerRows)
 818			}
 819			text.WriteString("\n")
 820			text.WriteString(renderTable(elem.Text, headerRows))
 821			text.WriteString("\n")
 822
 823		case clib.HElemBlockquote:
 824			var from, date string
 825			prevText := elem.Attr2
 826			cite := elem.Attr1
 827
 828			if matches := onWroteRegex.FindStringSubmatch(prevText); matches != nil {
 829				date = parseDateForDisplay(matches[1])
 830				from = matches[2]
 831			} else if matches := onWroteRegex.FindStringSubmatch(cite); matches != nil {
 832				date = parseDateForDisplay(matches[1])
 833				from = matches[2]
 834			}
 835
 836			text.WriteString(renderQuoteBox(from, date, strings.Split(elem.Text, "\n")))
 837		}
 838	}
 839
 840	result := text.String()
 841
 842	// Collapse excessive newlines, but not the image row placeholders
 843	re := regexp.MustCompile(`\n{3,}`)
 844	result = re.ReplaceAllString(result, "\n\n")
 845
 846	// Now expand the image row placeholders to actual newlines
 847	result = expandImageRowPlaceholders(result)
 848
 849	// Build image placements by finding the line numbers of image markers.
 850	var placements []ImagePlacement
 851	if len(pendingImages) > 0 {
 852		lines := strings.Split(result, "\n")
 853		imgMarkerRegex := regexp.MustCompile(`\[\[MATCHA_IMG:(\d+)\]\]`)
 854		for lineNum, line := range lines {
 855			if matches := imgMarkerRegex.FindStringSubmatch(line); matches != nil {
 856				var idx int
 857				fmt.Sscanf(matches[1], "%d", &idx)
 858				for _, pi := range pendingImages {
 859					if pi.index == idx {
 860						placements = append(placements, ImagePlacement{
 861							Line:   lineNum,
 862							Base64: pi.payload,
 863							Rows:   pi.rows,
 864						})
 865						break
 866					}
 867				}
 868			}
 869		}
 870
 871		// Remove the image markers from the text (leave the spacing)
 872		result = imgMarkerRegex.ReplaceAllString(result, "")
 873	}
 874
 875	return result, placements, nil
 876}
 877
 878func tableHeaderStyle() lipgloss.Style {
 879	return lipgloss.NewStyle().Bold(true).Foreground(theme.ActiveTheme.Accent)
 880}
 881
 882func tableBorderStyle() lipgloss.Style {
 883	return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Secondary)
 884}
 885
 886// renderTable renders table data as a Unicode box-drawing table.
 887// data is tab-separated cells, newline-separated rows.
 888// headerRows is the number of header rows.
 889func renderTable(data string, headerRows int) string {
 890	rows := strings.Split(data, "\n")
 891	if len(rows) == 0 {
 892		return ""
 893	}
 894
 895	// Parse into 2D grid and trim cell whitespace
 896	var grid [][]string
 897	maxCols := 0
 898	for _, row := range rows {
 899		cells := strings.Split(row, "\t")
 900		trimmed := make([]string, len(cells))
 901		for i, c := range cells {
 902			trimmed[i] = strings.TrimSpace(c)
 903		}
 904		grid = append(grid, trimmed)
 905		if len(trimmed) > maxCols {
 906			maxCols = len(trimmed)
 907		}
 908	}
 909
 910	// Normalize: ensure all rows have the same number of columns
 911	for i := range grid {
 912		for len(grid[i]) < maxCols {
 913			grid[i] = append(grid[i], "")
 914		}
 915	}
 916
 917	// Calculate column widths
 918	colWidths := make([]int, maxCols)
 919	for _, row := range grid {
 920		for j, cell := range row {
 921			if len(cell) > colWidths[j] {
 922				colWidths[j] = len(cell)
 923			}
 924		}
 925	}
 926
 927	// Minimum width per column
 928	for i := range colWidths {
 929		if colWidths[i] < 3 {
 930			colWidths[i] = 3
 931		}
 932	}
 933
 934	bs := tableBorderStyle()
 935	hs := tableHeaderStyle()
 936
 937	// Build horizontal borders
 938	buildBorder := func(left, mid, right, fill string) string {
 939		var b strings.Builder
 940		b.WriteString(bs.Render(left))
 941		for j, w := range colWidths {
 942			b.WriteString(bs.Render(strings.Repeat(fill, w+2)))
 943			if j < len(colWidths)-1 {
 944				b.WriteString(bs.Render(mid))
 945			}
 946		}
 947		b.WriteString(bs.Render(right))
 948		return b.String()
 949	}
 950
 951	topBorder := buildBorder("┌", "┬", "┐", "─")
 952	midBorder := buildBorder("├", "┼", "┤", "─")
 953	botBorder := buildBorder("└", "┴", "┘", "─")
 954
 955	var out strings.Builder
 956	out.WriteString(topBorder)
 957	out.WriteString("\n")
 958
 959	for i, row := range grid {
 960		out.WriteString(bs.Render("│"))
 961		for j, cell := range row {
 962			padded := cell + strings.Repeat(" ", colWidths[j]-len(cell))
 963			if i < headerRows {
 964				out.WriteString(" " + hs.Render(padded) + " ")
 965			} else {
 966				out.WriteString(" " + padded + " ")
 967			}
 968			out.WriteString(bs.Render("│"))
 969		}
 970		out.WriteString("\n")
 971
 972		if i < headerRows && (i+1 == headerRows || i+1 == len(grid)) {
 973			out.WriteString(midBorder)
 974			out.WriteString("\n")
 975		}
 976	}
 977
 978	out.WriteString(botBorder)
 979	return out.String()
 980}
 981
 982func quoteBoxStyle() lipgloss.Style {
 983	return lipgloss.NewStyle().
 984		Border(lipgloss.RoundedBorder()).
 985		BorderForeground(theme.ActiveTheme.Secondary).
 986		Padding(0, 1).
 987		Foreground(theme.ActiveTheme.Secondary)
 988}
 989
 990func quoteHeaderStyle() lipgloss.Style {
 991	return lipgloss.NewStyle().
 992		Foreground(theme.ActiveTheme.Secondary)
 993}
 994
 995// styleQuotedReplies detects quoted reply sections and styles them in a box
 996func styleQuotedReplies(text string) string {
 997	lines := strings.Split(text, "\n")
 998	var result []string
 999	var quoteBlock []string
1000	var quoteFrom, quoteDate string
1001	inQuote := false
1002
1003	// Regex to match "On DATE, EMAIL wrote:" pattern
1004	// Matches various date formats
1005	onWroteRegex := regexp.MustCompile(`^On\s+(.+?),\s+(.+?)\s+wrote:$`)
1006
1007	for i := 0; i < len(lines); i++ {
1008		line := lines[i]
1009		trimmedLine := strings.TrimSpace(line)
1010
1011		// Check for "On DATE, EMAIL wrote:" header
1012		if matches := onWroteRegex.FindStringSubmatch(trimmedLine); matches != nil {
1013			// If we were already in a quote block, render it first
1014			if inQuote && len(quoteBlock) > 0 {
1015				result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
1016				quoteBlock = nil
1017			}
1018
1019			// Parse the date and email from the match
1020			dateStr := matches[1]
1021			quoteFrom = matches[2]
1022			quoteDate = parseDateForDisplay(dateStr)
1023			inQuote = true
1024			continue
1025		}
1026
1027		// Check if line starts with ">" (quoted text)
1028		if strings.HasPrefix(trimmedLine, ">") {
1029			if !inQuote {
1030				// Start a new quote block without header info
1031				inQuote = true
1032				quoteFrom = ""
1033				quoteDate = ""
1034			}
1035			// Remove the leading "> " and add to quote block
1036			quotedContent := strings.TrimPrefix(trimmedLine, ">")
1037			quotedContent = strings.TrimPrefix(quotedContent, " ")
1038			quoteBlock = append(quoteBlock, quotedContent)
1039		} else if inQuote {
1040			// End of quote block - check if it's just whitespace
1041			if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") {
1042				// Empty line within quote block, keep it
1043				quoteBlock = append(quoteBlock, "")
1044			} else if trimmedLine == "" && len(quoteBlock) == 0 {
1045				// Empty line before any quoted content, skip
1046				continue
1047			} else {
1048				// End of quote block
1049				if len(quoteBlock) > 0 {
1050					result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
1051					quoteBlock = nil
1052				}
1053				inQuote = false
1054				quoteFrom = ""
1055				quoteDate = ""
1056				result = append(result, line)
1057			}
1058		} else {
1059			result = append(result, line)
1060		}
1061	}
1062
1063	// Handle any remaining quote block
1064	if inQuote && len(quoteBlock) > 0 {
1065		result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
1066	}
1067
1068	return strings.Join(result, "\n")
1069}
1070
1071// parseDateForDisplay converts various date formats to DD:MM:YY HH:MM
1072func parseDateForDisplay(dateStr string) string {
1073	// Common date formats to try
1074	formats := []string{
1075		"Jan 2, 2006 at 3:04 PM",
1076		"02:01:06 15:04",
1077		"2006-01-02 15:04:05",
1078		"Mon, 02 Jan 2006 15:04:05 -0700",
1079		"Mon, 2 Jan 2006 15:04:05 -0700",
1080		"2 Jan 2006 15:04:05",
1081		"January 2, 2006 at 3:04 PM",
1082		"Jan 2, 2006 3:04 PM",
1083		time.RFC1123Z,
1084		time.RFC1123,
1085		time.RFC822Z,
1086		time.RFC822,
1087	}
1088
1089	for _, format := range formats {
1090		if t, err := time.Parse(format, dateStr); err == nil {
1091			return t.Format("02:01:06 15:04")
1092		}
1093	}
1094
1095	// Return original if parsing fails
1096	return dateStr
1097}
1098
1099// renderQuoteBox renders a quoted section in a styled box
1100func renderQuoteBox(from, date string, lines []string) string {
1101	// Build header with email on left and date on right
1102	var header string
1103	if from != "" || date != "" {
1104		if from != "" && date != "" {
1105			header = quoteHeaderStyle().Render(from + "  " + date)
1106		} else if from != "" {
1107			header = quoteHeaderStyle().Render(from)
1108		} else {
1109			header = quoteHeaderStyle().Render(date)
1110		}
1111	}
1112
1113	// Join the quoted content
1114	content := strings.Join(lines, "\n")
1115
1116	// Build the box content
1117	var boxContent string
1118	if header != "" {
1119		boxContent = header + "\n\n" + content
1120	} else {
1121		boxContent = content
1122	}
1123
1124	return quoteBoxStyle().Render(boxContent)
1125}