Files
aerc-inline-images/app/compositeview.go
T
Mortdecai e6af1a6208 fix(composite): recover from sixel encoder panics, clean up debug logging
The go-sixel library panics on certain image dimensions during Resize().
Added panic recovery in drawImage() that falls back to alt text display.
Cleaned up all debug logging from the investigation.
2026-04-07 21:48:37 -04:00

302 lines
6.5 KiB
Go

package app
import (
"fmt"
"image"
"os"
"strconv"
"strings"
"sync"
"git.sr.ht/~rockorager/vaxis"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/parse"
"git.sr.ht/~rjarry/aerc/lib/ui"
_ "image/jpeg"
_ "image/png"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
)
const (
blockText = 0
blockImage = 1
defaultImageHeight = 15
)
type contentBlock struct {
blockType int
lines []string
imageIdx int
}
type compositeContent struct {
blocks []contentBlock
images []parse.ImageRef
width int
mu sync.Mutex
decoded map[int]image.Image
decErrs map[int]bool // true = decode attempted and failed
graphics map[int]vaxis.Image
invalidate func() // callback to trigger redraw after async decode
}
func splitContentBlocks(text string, images []parse.ImageRef) []contentBlock {
imageSet := make(map[int]bool, len(images))
for _, img := range images {
imageSet[img.Index] = true
}
var blocks []contentBlock
var currentLines []string
lines := strings.Split(text, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
for _, line := range lines {
if idx, ok := parsePlaceholder(line); ok && imageSet[idx] {
if len(currentLines) > 0 {
blocks = append(blocks, contentBlock{
blockType: blockText,
lines: currentLines,
})
currentLines = nil
}
blocks = append(blocks, contentBlock{
blockType: blockImage,
imageIdx: idx,
})
} else {
currentLines = append(currentLines, line)
}
}
if len(currentLines) > 0 {
blocks = append(blocks, contentBlock{
blockType: blockText,
lines: currentLines,
})
}
return blocks
}
func parsePlaceholder(line string) (int, bool) {
if !strings.HasPrefix(line, "\x00IMG:") || !strings.HasSuffix(line, "\x00") {
return 0, false
}
numStr := line[len("\x00IMG:") : len(line)-1]
idx, err := strconv.Atoi(numStr)
if err != nil {
return 0, false
}
return idx, true
}
func newCompositeContent(text string, images []parse.ImageRef, width int, invalidate func()) *compositeContent {
cc := &compositeContent{
blocks: splitContentBlocks(text, images),
images: images,
width: width,
decoded: make(map[int]image.Image),
decErrs: make(map[int]bool),
graphics: make(map[int]vaxis.Image),
invalidate: invalidate,
}
// Decode all images in background
go cc.decodeAllImages()
return cc
}
// decodeAllImages decodes all referenced images in a background goroutine.
// Calls invalidate() when done so the UI redraws with images.
func (cc *compositeContent) decodeAllImages() {
defer log.PanicHandler()
for i, img := range cc.images {
f, err := os.Open(img.Path)
if err != nil {
log.Debugf("composite: open image %d: %v", i, err)
cc.mu.Lock()
cc.decErrs[i] = true
cc.mu.Unlock()
continue
}
decoded, _, err := image.Decode(f)
f.Close()
if err != nil {
log.Debugf("composite: decode image %d: %v", i, err)
cc.mu.Lock()
cc.decErrs[i] = true
cc.mu.Unlock()
continue
}
cc.mu.Lock()
cc.decoded[i] = decoded
cc.mu.Unlock()
}
if cc.invalidate != nil {
cc.invalidate()
}
}
func (cc *compositeContent) totalHeight() int {
h := 0
for _, b := range cc.blocks {
switch b.blockType {
case blockText:
h += len(b.lines)
case blockImage:
h += cc.imageHeight(b.imageIdx)
}
}
return h
}
func (cc *compositeContent) imageHeight(idx int) int {
cc.mu.Lock()
img, ok := cc.decoded[idx]
cc.mu.Unlock()
if ok {
bounds := img.Bounds()
if bounds.Dx() == 0 {
return defaultImageHeight
}
ratio := float64(bounds.Dy()) / float64(bounds.Dx())
h := int(float64(cc.width) * ratio / 2.0)
if h < 3 {
h = 3
}
if h > defaultImageHeight {
h = defaultImageHeight
}
return h
}
return defaultImageHeight
}
func (cc *compositeContent) Destroy() {
cc.mu.Lock()
defer cc.mu.Unlock()
for _, g := range cc.graphics {
g.Destroy()
}
cc.graphics = make(map[int]vaxis.Image)
}
func (cc *compositeContent) CleanupFiles() {
for _, img := range cc.images {
os.Remove(img.Path)
}
}
// Draw renders composite content. Only renders images that are already decoded.
func (cc *compositeContent) Draw(ctx *ui.Context, scroll int, style ...vaxis.Style) {
width := ctx.Width()
height := ctx.Height()
row := -scroll
defaultStyle := vaxis.Style{}
if len(style) > 0 {
defaultStyle = style[0]
}
for _, b := range cc.blocks {
switch b.blockType {
case blockText:
for _, line := range b.lines {
if row >= height {
return
}
if row >= 0 {
ctx.Printf(0, row, defaultStyle, "%s",
truncateLine(line, width))
}
row++
}
case blockImage:
imgH := cc.imageHeight(b.imageIdx)
if row+imgH > 0 && row < height {
cc.drawImage(ctx, b.imageIdx, row, width, imgH, defaultStyle)
}
row += imgH
}
}
}
func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height int, style vaxis.Style) {
cc.mu.Lock()
img, decoded := cc.decoded[idx]
failed := cc.decErrs[idx]
cc.mu.Unlock()
// Show alt text if not yet decoded or failed
if !decoded {
alt := "[loading image...]"
if failed {
alt = "[image]"
if idx < len(cc.images) && cc.images[idx].Alt != "" {
alt = fmt.Sprintf("[%s]", cc.images[idx].Alt)
}
}
if row >= 0 && row < ctx.Height() {
ctx.Printf(0, row, style, "%s", alt)
}
return
}
vx := ctx.Window().Vx
graphic, ok := cc.graphics[idx]
// Recover from panics in vaxis image encoding (e.g. sixel encoder bugs)
defer func() {
if r := recover(); r != nil {
log.Errorf("composite: panic rendering image %d: %v", idx, r)
alt := "[image: render error]"
if idx < len(cc.images) && cc.images[idx].Alt != "" {
alt = fmt.Sprintf("[%s]", cc.images[idx].Alt)
}
if row >= 0 && row < ctx.Height() {
ctx.Printf(0, row, style, "%s", alt)
}
}
}()
if !ok {
var err error
graphic, err = vx.NewImage(img)
if err != nil {
log.Errorf("composite: vaxis NewImage %d: %v", idx, err)
if row >= 0 && row < ctx.Height() {
alt := "[image: render error]"
if idx < len(cc.images) && cc.images[idx].Alt != "" {
alt = fmt.Sprintf("[%s]", cc.images[idx].Alt)
}
ctx.Printf(0, row, style, "%s", alt)
}
return
}
cc.graphics[idx] = graphic
}
graphic.Resize(width, height)
startRow := row
if startRow < 0 {
startRow = 0
}
win := ctx.Window().New(0, startRow, width, height)
graphic.Draw(win)
}
func truncateLine(s string, width int) string {
runes := []rune(s)
if len(runes) <= width {
return s
}
return string(runes[:width])
}