prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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 }