markdown_images.go (4835B)
1 package tui 2 3 import ( 4 "fmt" 5 "image/color" 6 "net/http" 7 "regexp" 8 "strings" 9 "sync" 10 "time" 11 12 "github.com/charmbracelet/x/ansi" 13 "github.com/eliukblau/pixterm/pkg/ansimage" 14 ) 15 16 // README images are pulled out before markdown rendering and drawn here, 17 // scaled to fit a capped box, because go-term-markdown scales every image to 18 // the full line width with no height limit. 19 20 const ( 21 imageToken = "PRIME8IMAGE" 22 imageMaxCols = 96 23 imageMaxRows = 24 24 ) 25 26 var ( 27 mdImageRE = regexp.MustCompile(`!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)`) 28 htmlImgSrcRE = regexp.MustCompile(`(?i)<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>`) 29 htmlTagRE = regexp.MustCompile(`<[^>]*>`) 30 mdLinkWrapRE = regexp.MustCompile(`\[\s*(!\[[^\]]*\]\([^)]*\))\s*\]\([^)]*\)`) 31 imageTokenRE = regexp.MustCompile(`^\s*` + imageToken + `(\d+)\s*$`) 32 imageCache sync.Map // key "url|cols" -> []string 33 imageClient = &http.Client{Timeout: 5 * time.Second} 34 imageFetchLock sync.Mutex 35 ) 36 37 // imageSources returns the image URLs on a line that holds nothing but 38 // images (markdown, linked badges, or HTML <img>), or nil. 39 func imageSources(line string) []string { 40 rest := mdLinkWrapRE.ReplaceAllString(line, "$1") 41 var srcs []string 42 for _, m := range mdImageRE.FindAllStringSubmatch(rest, -1) { 43 srcs = append(srcs, m[2]) 44 } 45 rest = mdImageRE.ReplaceAllString(rest, "") 46 for _, m := range htmlImgSrcRE.FindAllStringSubmatch(rest, -1) { 47 srcs = append(srcs, m[1]) 48 } 49 rest = htmlImgSrcRE.ReplaceAllString(rest, "") 50 if strings.TrimSpace(htmlTagRE.ReplaceAllString(rest, "")) != "" { 51 return nil 52 } 53 return srcs 54 } 55 56 // extractImages swaps every image-only line (outside code fences) for a 57 // placeholder paragraph and returns the rendered images in token order. 58 func extractImages(markdown string, width int) (string, [][]string) { 59 lines := strings.Split(markdown, "\n") 60 out := make([]string, 0, len(lines)) 61 var blocks [][]string 62 fence := "" 63 for _, line := range lines { 64 if m := fenceOpenRE.FindStringSubmatch(line); m != nil { 65 switch { 66 case fence == "": 67 fence = m[1][:1] 68 case strings.HasPrefix(m[1], fence): 69 fence = "" 70 } 71 out = append(out, line) 72 continue 73 } 74 if fence != "" { 75 out = append(out, line) 76 continue 77 } 78 srcs := imageSources(line) 79 if len(srcs) == 0 { 80 out = append(out, line) 81 continue 82 } 83 var block []string 84 for _, src := range srcs { 85 if len(block) > 0 { 86 block = append(block, "") 87 } 88 block = append(block, renderImageBlock(src, width)...) 89 } 90 out = append(out, "", fmt.Sprintf("%s%d", imageToken, len(blocks)), "") 91 blocks = append(blocks, block) 92 } 93 return strings.Join(out, "\n"), blocks 94 } 95 96 // imageBox is the cell box an image is fitted into: the pane width and a 97 // third of it in rows (a cell is about twice as tall as wide, so roughly a 98 // 3:2 box), capped at imageMaxCols x imageMaxRows. 99 func imageBox(width int) (cols, rows int) { 100 cols = max(min(width, imageMaxCols), 8) 101 rows = max(min(cols/3, imageMaxRows), 4) 102 return cols, rows 103 } 104 105 // renderImageBlock draws one image fitted into imageBox(width), keeping its 106 // aspect, or a dim link line when it can't be fetched or decoded. 107 func renderImageBlock(src string, width int) []string { 108 cols, rows := imageBox(width) 109 key := fmt.Sprintf("%s|%d", src, cols) 110 if v, ok := imageCache.Load(key); ok { 111 return v.([]string) 112 } 113 lines := fetchImageLines(src, cols, rows) 114 if lines == nil { 115 lines = []string{dimStyle.Render("[image] " + src)} 116 } 117 imageCache.Store(key, lines) 118 return lines 119 } 120 121 func fetchImageLines(src string, cols, rows int) []string { 122 if !strings.HasPrefix(src, "http://") && !strings.HasPrefix(src, "https://") { 123 return nil 124 } 125 imageFetchLock.Lock() 126 defer imageFetchLock.Unlock() 127 res, err := imageClient.Get(src) 128 if err != nil { 129 return nil 130 } 131 defer res.Body.Close() 132 if res.StatusCode != http.StatusOK { 133 return nil 134 } 135 // ansimage sizes are in pixels; a half-block cell is 1 wide, 2 tall. 136 img, err := ansimage.NewScaledFromReader(res.Body, rows*2, cols, 137 color.Black, ansimage.ScaleModeFit, ansimage.NoDithering) 138 if err != nil { 139 return nil 140 } 141 return strings.Split(strings.TrimRight(img.Render(), "\n"), "\n") 142 } 143 144 func restoreImages(rendered string, blocks [][]string) string { 145 if len(blocks) == 0 { 146 return rendered 147 } 148 lines := strings.Split(rendered, "\n") 149 out := make([]string, 0, len(lines)) 150 for _, line := range lines { 151 plain := ansi.Strip(line) 152 m := imageTokenRE.FindStringSubmatch(plain) 153 var idx int 154 if m == nil { 155 out = append(out, line) 156 continue 157 } 158 if _, err := fmt.Sscanf(m[1], "%d", &idx); err != nil || idx >= len(blocks) { 159 out = append(out, line) 160 continue 161 } 162 indent := plain[:len(plain)-len(strings.TrimLeft(plain, " "))] 163 for _, l := range blocks[idx] { 164 out = append(out, indent+l) 165 } 166 } 167 return strings.Join(out, "\n") 168 }