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:
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\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")
+ }
+ }
+}