prime8dev

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

commit 93746b2e2a3d6a005aa2f0990833dd2c9360c6d4
parent 3d6f3ef46761df8d2cade4dba2a74ded8fc75301
Author: Damian Myrda <damian@prime8.dev>
Date:   Thu, 22 Oct 2026 01:39:43 +0000

tui: README images fit a 60x14 cell box

go-term-markdown scales every image to the full line width with no height
limit. Image-only lines (markdown, linked badges, HTML <img>) are now drawn
by us with ansimage in ScaleModeFit, cached per url and width.

Diffstat:
Minternal/tui/markdown.go | 24+++++-------------------
Ainternal/tui/markdown_images.go | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/markdown_images_test.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 219 insertions(+), 19 deletions(-)

diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go @@ -14,8 +14,6 @@ var ( horizontalRuleLineRE = regexp.MustCompile(`^\s*─+\s*$`) alphaNumericRE = regexp.MustCompile(`[A-Za-z0-9]`) ansiArtGlyphRE = regexp.MustCompile(`[█▓▒░▄▀▌▐▖▗▘▙▚▛▜▝▞▟⣿⣀-⣿]`) - markdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\([^\)]+\)`) - htmlImageRE = regexp.MustCompile(`(?i)<img\b`) ) func renderMarkdown(markdown string, width int) string { @@ -25,14 +23,15 @@ func renderMarkdown(markdown string, width int) string { } width = max(width, 20) - renderWidth := markdownRenderWidth(markdown, width) - source, wide := extractWideBlocks(markdown, renderWidth) - out := termmarkdown.Render(source, renderWidth, 0) + source, images := extractImages(markdown, width) + source, wide := extractWideBlocks(source, width) + out := termmarkdown.Render(source, width, 0) if len(out) == 0 { return markdown } - return restoreWideBlocks(styleRenderedMarkdown(strings.TrimRight(string(out), "\n")), wide) + styled := styleRenderedMarkdown(strings.TrimRight(string(out), "\n")) + return restoreImages(restoreWideBlocks(styled, wide), images) } func styleRenderedMarkdown(rendered string) string { @@ -81,19 +80,6 @@ func styleRenderedMarkdown(rendered string) string { return strings.Join(lines, "\n") } -func markdownRenderWidth(markdown string, width int) int { - if !markdownHasImages(markdown) { - return width - } - - reduced := int(float64(width) * 0.72) - return max(min(reduced, width), 20) -} - -func markdownHasImages(markdown string) bool { - return markdownImageRE.MatchString(markdown) || htmlImageRE.MatchString(markdown) -} - func shouldPreserveANSILine(raw string, plain string) bool { if !ansiEscapeRE.MatchString(raw) { return false diff --git a/internal/tui/markdown_images.go b/internal/tui/markdown_images.go @@ -0,0 +1,159 @@ +package tui + +import ( + "fmt" + "image/color" + "net/http" + "regexp" + "strings" + "sync" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/eliukblau/pixterm/pkg/ansimage" +) + +// README images are pulled out before markdown rendering and drawn here, +// scaled to fit a capped box, because go-term-markdown scales every image to +// the full line width with no height limit. + +const ( + imageToken = "PRIME8IMAGE" + imageMaxCols = 60 + imageMaxRows = 14 +) + +var ( + mdImageRE = regexp.MustCompile(`!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)`) + htmlImgSrcRE = regexp.MustCompile(`(?i)<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>`) + htmlTagRE = regexp.MustCompile(`<[^>]*>`) + mdLinkWrapRE = regexp.MustCompile(`\[\s*(!\[[^\]]*\]\([^)]*\))\s*\]\([^)]*\)`) + imageTokenRE = regexp.MustCompile(`^\s*` + imageToken + `(\d+)\s*$`) + imageCache sync.Map // key "url|cols" -> []string + imageClient = &http.Client{Timeout: 5 * time.Second} + imageFetchLock sync.Mutex +) + +// imageSources returns the image URLs on a line that holds nothing but +// images (markdown, linked badges, or HTML <img>), or nil. +func imageSources(line string) []string { + rest := mdLinkWrapRE.ReplaceAllString(line, "$1") + var srcs []string + for _, m := range mdImageRE.FindAllStringSubmatch(rest, -1) { + srcs = append(srcs, m[2]) + } + rest = mdImageRE.ReplaceAllString(rest, "") + for _, m := range htmlImgSrcRE.FindAllStringSubmatch(rest, -1) { + srcs = append(srcs, m[1]) + } + rest = htmlImgSrcRE.ReplaceAllString(rest, "") + if strings.TrimSpace(htmlTagRE.ReplaceAllString(rest, "")) != "" { + return nil + } + return srcs +} + +// extractImages swaps every image-only line (outside code fences) for a +// placeholder paragraph and returns the rendered images in token order. +func extractImages(markdown string, width int) (string, [][]string) { + lines := strings.Split(markdown, "\n") + out := make([]string, 0, len(lines)) + var blocks [][]string + fence := "" + for _, line := range lines { + if m := fenceOpenRE.FindStringSubmatch(line); m != nil { + switch { + case fence == "": + fence = m[1][:1] + case strings.HasPrefix(m[1], fence): + fence = "" + } + out = append(out, line) + continue + } + if fence != "" { + out = append(out, line) + continue + } + srcs := imageSources(line) + if len(srcs) == 0 { + out = append(out, line) + continue + } + var block []string + for _, src := range srcs { + if len(block) > 0 { + block = append(block, "") + } + block = append(block, renderImageBlock(src, width)...) + } + out = append(out, "", fmt.Sprintf("%s%d", imageToken, len(blocks)), "") + blocks = append(blocks, block) + } + return strings.Join(out, "\n"), blocks +} + +// renderImageBlock draws one image within imageMaxCols x imageMaxRows (and +// the pane width), or a dim link line when it can't be fetched or decoded. +func renderImageBlock(src string, width int) []string { + cols := max(min(width, imageMaxCols), 8) + key := fmt.Sprintf("%s|%d", src, cols) + if v, ok := imageCache.Load(key); ok { + return v.([]string) + } + lines := fetchImageLines(src, cols) + if lines == nil { + lines = []string{dimStyle.Render("[image] " + src)} + } + imageCache.Store(key, lines) + return lines +} + +func fetchImageLines(src string, cols int) []string { + if !strings.HasPrefix(src, "http://") && !strings.HasPrefix(src, "https://") { + return nil + } + imageFetchLock.Lock() + defer imageFetchLock.Unlock() + res, err := imageClient.Get(src) + if err != nil { + return nil + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil + } + // ansimage sizes are in pixels; a half-block cell is 1 wide, 2 tall. + img, err := ansimage.NewScaledFromReader(res.Body, imageMaxRows*2, cols, + color.Black, ansimage.ScaleModeFit, ansimage.NoDithering) + if err != nil { + return nil + } + return strings.Split(strings.TrimRight(img.Render(), "\n"), "\n") +} + +func restoreImages(rendered string, blocks [][]string) string { + if len(blocks) == 0 { + return rendered + } + lines := strings.Split(rendered, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + plain := ansi.Strip(line) + m := imageTokenRE.FindStringSubmatch(plain) + var idx int + if m == nil { + out = append(out, line) + continue + } + if _, err := fmt.Sscanf(m[1], "%d", &idx); err != nil || idx >= len(blocks) { + out = append(out, line) + continue + } + indent := plain[:len(plain)-len(strings.TrimLeft(plain, " "))] + for _, l := range blocks[idx] { + out = append(out, indent+l) + } + } + return strings.Join(out, "\n") +} diff --git a/internal/tui/markdown_images_test.go b/internal/tui/markdown_images_test.go @@ -0,0 +1,55 @@ +package tui + +import ( + "bytes" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestReadmeImagesAreCapped(t *testing.T) { + for _, size := range [][2]int{{2000, 400}, {400, 2000}, {1200, 1200}} { + img := image.NewRGBA(image.Rect(0, 0, size[0], size[1])) + for y := 0; y < size[1]; y++ { + for x := 0; x < size[0]; x++ { + img.Set(x, y, color.RGBA{200, 100, 50, 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(buf.Bytes()) + })) + md := "# demo\n\nsome text\n\n![shot](" + srv.URL + "/a.png)\n\n<p align=\"center\"><img src=\"" + srv.URL + "/b.png\" width=\"900\"></p>\n\nmore text\n" + out := renderMarkdown(md, 200) + srv.Close() + + rows := 0 + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "▀") || strings.Contains(line, "▄") { + rows++ + if w := ansi.StringWidth(line); w > imageMaxCols { + t.Errorf("%v: image row %d cells wide", size, w) + } + } + } + if rows == 0 { + t.Fatalf("%v: no image rendered:\n%s", size, out) + } + if rows > 2*imageMaxRows { + t.Errorf("%v: %d image rows for two images, cap is %d each", size, rows, imageMaxRows) + } + if !strings.Contains(ansi.Strip(out), "more text") { + t.Errorf("text after images lost") + } + } +}