diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 29a3695..91beaf9 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -39,7 +39,7 @@ const ( // Included in content hashes so renderer/template changes never reuse // stale Telegram file_ids or S3 objects from an older deployment. - renderCacheVersion = "presigned-v1" + renderCacheVersion = "presigned-v2-inline-thumbnail" ) // outputFormat is one of the three generation formats. @@ -130,9 +130,12 @@ type refsEntry struct { // urlEntry caches the presigned URLs of a recent render so repeated // identical inline queries skip rendering and uploading again. type urlEntry struct { - photoURL string - pdfURL string - expires time.Time + photoURL string + thumbnailURL string + photoWidth int + photoHeight int + pdfURL string + expires time.Time } // New authenticates the bot and instruments its HTTP client. @@ -480,7 +483,7 @@ func (b *Bot) savePhotoID(hash, fileID string) { if b.cache == nil { return } - b.savePhotoID(hash, fileID) + b.cache.SavePhotoID(hash, fileID) b.refsMu.Lock() entry := b.refsCache[hash] entry.refs.PhotoID = fileID @@ -493,7 +496,7 @@ func (b *Bot) saveDocID(hash, fileID string) { if b.cache == nil { return } - b.saveDocID(hash, fileID) + b.cache.SaveDocID(hash, fileID) b.refsMu.Lock() entry := b.refsCache[hash] entry.refs.DocID = fileID @@ -502,28 +505,29 @@ func (b *Bot) saveDocID(hash, fileID string) { b.refsMu.Unlock() } -// cachedURLs returns the presigned URLs of a recent render of the same -// content, so repeated identical queries skip render and upload entirely. -func (b *Bot) cachedURLs(hash string) (photoURL, pdfURL string, ok bool) { +// cachedURLs returns the presigned URLs and photo geometry of a recent render +// of the same content, so repeated identical queries skip render and upload. +func (b *Bot) cachedURLs(hash string) (urlEntry, bool) { b.urlsMu.Lock() defer b.urlsMu.Unlock() entry, found := b.urlsCache[hash] if !found || time.Now().After(entry.expires) { - return "", "", false + return urlEntry{}, false } - return entry.photoURL, entry.pdfURL, true + return entry, true } -func (b *Bot) cacheURLs(hash, photoURL, pdfURL string) { +func (b *Bot) cacheURLs(hash string, entry urlEntry) { ttl := b.cfg.S3PresignTTL - 10*time.Minute if ttl < 5*time.Minute { ttl = 5 * time.Minute } + entry.expires = time.Now().Add(ttl) b.urlsMu.Lock() if b.urlsCache == nil { b.urlsCache = map[string]urlEntry{} } - b.urlsCache[hash] = urlEntry{photoURL: photoURL, pdfURL: pdfURL, expires: time.Now().Add(ttl)} + b.urlsCache[hash] = entry b.urlsMu.Unlock() } @@ -557,18 +561,30 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) [] refs := b.fileRefs(hash) needPhoto := refs.PhotoID == "" needDoc := refs.DocID == "" - var photoURL, pdfURL string + var urls urlEntry if needPhoto || needDoc { - if cachedPhoto, cachedPDF, ok := b.cachedURLs(hash); ok { - photoURL, pdfURL = cachedPhoto, cachedPDF - needPhoto, needDoc = false, false + if cached, ok := b.cachedURLs(hash); ok { + urls = cached + if cached.photoURL != "" && cached.thumbnailURL != "" { + needPhoto = false + } + if cached.pdfURL != "" { + needDoc = false + } } } if b.files != nil && (needPhoto || needDoc) { renderCtx, cancel := context.WithTimeout(ctx, b.cfg.RenderTimeout) defer cancel() started := time.Now() - result, err := b.ren.RenderFormats(renderCtx, settings.Preamble, expr, dpi, render.FormatJPEG|render.FormatPDF) + var formats render.Formats + if needPhoto { + formats |= render.FormatJPEG | render.FormatThumbnail + } + if needDoc { + formats |= render.FormatPDF + } + result, err := b.ren.RenderFormats(renderCtx, settings.Preamble, expr, dpi, formats) stat := store.RenderStat{ Source: "inline", Format: "inline", UserID: query.From.ID, DPI: dpi, ExpressionLen: len(expr), OK: err == nil, @@ -581,12 +597,13 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) [] } go b.rec.RecordRender(stat) - // Upload both formats concurrently: each Put is an object store - // round-trip. + // Upload the requested formats concurrently: each Put is an object + // store round-trip. A photo is only offered if both its full image and + // its dedicated thumbnail were stored successfully. var wg sync.WaitGroup - var photoPutURL, pdfPutURL string + var photoPutURL, thumbnailPutURL, pdfPutURL string if needPhoto { - wg.Add(1) + wg.Add(2) go func() { defer wg.Done() storedURL, putErr := b.files.Put(ctx, "jpg", "image/jpeg", result.JPEG) @@ -596,6 +613,15 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) [] } photoPutURL = storedURL }() + go func() { + defer wg.Done() + storedURL, putErr := b.files.Put(ctx, "thumb.jpg", "image/jpeg", result.Thumbnail) + if putErr != nil { + log.Printf("store inline thumbnail: %v", putErr) + return + } + thumbnailPutURL = storedURL + }() } if needDoc { wg.Add(1) @@ -610,14 +636,17 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) [] }() } wg.Wait() - if photoPutURL != "" { - photoURL = photoPutURL + if photoPutURL != "" && thumbnailPutURL != "" { + urls.photoURL = photoPutURL + urls.thumbnailURL = thumbnailPutURL + urls.photoWidth = result.JPEGWidth + urls.photoHeight = result.JPEGHeight } if pdfPutURL != "" { - pdfURL = pdfPutURL + urls.pdfURL = pdfPutURL } - if photoPutURL != "" || pdfPutURL != "" { - b.cacheURLs(hash, photoURL, pdfURL) + if urls.photoURL != "" || urls.pdfURL != "" { + b.cacheURLs(hash, urls) } } @@ -626,18 +655,23 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) [] photo := tgbotapi.NewInlineQueryResultCachedPhoto("photo-"+id, refs.PhotoID) photo.Title = "PNG" results = append(results, photo) - } else if photoURL != "" { - photo := tgbotapi.NewInlineQueryResultPhoto("photo-"+id, photoURL) - photo.ThumbURL = photoURL - photo.Title = "PNG" - results = append(results, photo) + } else if urls.photoURL != "" && urls.thumbnailURL != "" { + results = append(results, inlineQueryResultPhoto{ + Type: "photo", + ID: "photo-" + id, + PhotoURL: urls.photoURL, + ThumbnailURL: urls.thumbnailURL, + PhotoWidth: urls.photoWidth, + PhotoHeight: urls.photoHeight, + Title: "PNG", + }) } if refs.DocID != "" { document := tgbotapi.NewInlineQueryResultCachedDocument("doc-"+id, refs.DocID, "PDF") document.Title = "PDF" results = append(results, document) - } else if pdfURL != "" { - document := tgbotapi.NewInlineQueryResultDocument("doc-"+id, pdfURL, "formula.pdf", "application/pdf") + } else if urls.pdfURL != "" { + document := tgbotapi.NewInlineQueryResultDocument("doc-"+id, urls.pdfURL, "formula.pdf", "application/pdf") document.Title = "PDF" results = append(results, document) } @@ -668,6 +702,18 @@ type inputRichMessageContent struct { RichMessage *rich.Message `json:"rich_message"` } +// inlineQueryResultPhoto follows the current Bot API field names. The pinned +// Telegram library predates thumbnail_url and still serializes thumb_url. +type inlineQueryResultPhoto struct { + Type string `json:"type"` + ID string `json:"id"` + PhotoURL string `json:"photo_url"` + ThumbnailURL string `json:"thumbnail_url"` + PhotoWidth int `json:"photo_width,omitempty"` + PhotoHeight int `json:"photo_height,omitempty"` + Title string `json:"title,omitempty"` +} + // sendPhoto sends a rendered picture to the chat and returns its reusable // Telegram file_id. func (b *Bot) sendPhoto(chatID int64, png []byte) string { diff --git a/internal/bot/bot_test.go b/internal/bot/bot_test.go index 15792a2..9e48696 100644 --- a/internal/bot/bot_test.go +++ b/internal/bot/bot_test.go @@ -2,6 +2,7 @@ package bot import ( "context" + "encoding/json" "strings" "sync" "testing" @@ -74,9 +75,13 @@ func (f *fakeFiles) Put(_ context.Context, ext, _ string, data []byte) (string, func testBot() *Bot { return &Bot{ - api: &tgbotapi.BotAPI{Self: tgbotapi.User{UserName: "latexgrambot"}}, - cfg: &config.Config{MaxExpressionLen: 4000, RichTextEnabled: true, RichDefaultMath: true, RenderDPI: 600}, - ren: &stubRenderer{results: &render.Result{PNG: []byte("png"), JPEG: []byte("jpg"), PDF: []byte("pdf")}}, + api: &tgbotapi.BotAPI{Self: tgbotapi.User{UserName: "latexgrambot"}}, + cfg: &config.Config{MaxExpressionLen: 4000, RichTextEnabled: true, RichDefaultMath: true, RenderDPI: 600}, + ren: &stubRenderer{results: &render.Result{ + PNG: []byte("png"), JPEG: []byte("jpg"), + JPEGWidth: 1600, JPEGHeight: 600, + Thumbnail: []byte("thumbnail"), PDF: []byte("pdf"), + }}, user: &fakeSettings{}, rec: &fakeRecorder{}, files: &fakeFiles{}, @@ -103,16 +108,29 @@ func TestInlineResultsUsesPresignedFiles(t *testing.T) { t.Fatalf("got %d results, want 3", len(results)) } - photo, ok := results[0].(tgbotapi.InlineQueryResultPhoto) + photo, ok := results[0].(inlineQueryResultPhoto) if !ok { - t.Fatalf("result 0 is %T, want InlineQueryResultPhoto", results[0]) + t.Fatalf("result 0 is %T, want inlineQueryResultPhoto", results[0]) } - if !strings.Contains(photo.URL, ".jpg?") || photo.ThumbURL != photo.URL { - t.Errorf("photo URL/thumb = %q/%q", photo.URL, photo.ThumbURL) + if !strings.Contains(photo.PhotoURL, ".jpg?") || !strings.Contains(photo.ThumbnailURL, ".thumb.jpg?") { + t.Errorf("photo URL/thumbnail = %q/%q", photo.PhotoURL, photo.ThumbnailURL) + } + if photo.PhotoURL == photo.ThumbnailURL { + t.Error("full photo and thumbnail must use different objects") + } + if photo.PhotoWidth != 1600 || photo.PhotoHeight != 600 { + t.Errorf("photo dimensions = %dx%d", photo.PhotoWidth, photo.PhotoHeight) } if photo.Title != "PNG" { t.Errorf("photo title = %q", photo.Title) } + encoded, err := json.Marshal(photo) + if err != nil { + t.Fatalf("marshal photo result: %v", err) + } + if !strings.Contains(string(encoded), `"thumbnail_url"`) || strings.Contains(string(encoded), `"thumb_url"`) { + t.Errorf("unexpected photo JSON: %s", encoded) + } document, ok := results[1].(tgbotapi.InlineQueryResultDocument) if !ok { @@ -145,7 +163,7 @@ func TestInlineResultsUsesPresignedFiles(t *testing.T) { t.Fatalf("unexpected inline math node: %+v", parts[1]) } files := b.files.(*fakeFiles) - if string(files.puts["jpg"]) != "jpg" || string(files.puts["pdf"]) != "pdf" { + if string(files.puts["jpg"]) != "jpg" || string(files.puts["thumb.jpg"]) != "thumbnail" || string(files.puts["pdf"]) != "pdf" { t.Fatalf("stored files = %#v", files.puts) } } @@ -156,13 +174,13 @@ func TestInlineResultsCachesPresignedURLs(t *testing.T) { first := b.inlineResults(context.Background(), query) second := b.inlineResults(context.Background(), query) files := b.files.(*fakeFiles) - if files.putCalls != 2 { - t.Fatalf("put calls = %d, want 2 (one per format, cached afterwards)", files.putCalls) + if files.putCalls != 3 { + t.Fatalf("put calls = %d, want 3 (photo, thumbnail and PDF, cached afterwards)", files.putCalls) } - firstPhoto := first[0].(tgbotapi.InlineQueryResultPhoto) - secondPhoto := second[0].(tgbotapi.InlineQueryResultPhoto) - if firstPhoto.URL != secondPhoto.URL { - t.Fatalf("second query re-rendered: %q vs %q", firstPhoto.URL, secondPhoto.URL) + firstPhoto := first[0].(inlineQueryResultPhoto) + secondPhoto := second[0].(inlineQueryResultPhoto) + if firstPhoto.PhotoURL != secondPhoto.PhotoURL || firstPhoto.ThumbnailURL != secondPhoto.ThumbnailURL { + t.Fatalf("second query re-rendered: %+v vs %+v", firstPhoto, secondPhoto) } } @@ -201,8 +219,22 @@ func TestInlineResultsRichDisabled(t *testing.T) { if len(results) != 2 { t.Fatalf("got %d results, want 2", len(results)) } - if _, ok := results[0].(tgbotapi.InlineQueryResultPhoto); !ok { - t.Fatalf("result 0 is %T, want InlineQueryResultPhoto", results[0]) + if _, ok := results[0].(inlineQueryResultPhoto); !ok { + t.Fatalf("result 0 is %T, want inlineQueryResultPhoto", results[0]) + } +} + +func TestSaveFileIDsUsesPersistentCache(t *testing.T) { + b := testBot() + cache := &fakeCache{} + b.cache = cache + b.savePhotoID("hash", "photo-id") + b.saveDocID("hash", "doc-id") + if cache.refs.PhotoID != "photo-id" || cache.refs.DocID != "doc-id" { + t.Fatalf("saved refs = %+v", cache.refs) + } + if got := b.refsCache["hash"].refs; got != cache.refs { + t.Fatalf("memory refs = %+v, persistent refs = %+v", got, cache.refs) } } diff --git a/internal/render/render.go b/internal/render/render.go index 2dd238b..3525fb7 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -39,6 +39,9 @@ const ( // maxRenderedSide guards against pathologically tall formulas. maxRenderedSide = 4096 + + // inlineThumbnailSide is Telegram's conventional maximum thumbnail side. + inlineThumbnailSide = 320 ) // documentClass is the fixed preview layout: the standalone class crops @@ -113,9 +116,12 @@ func DefaultPreamble() string { // Result carries the compiled output. JPEG is used for URL-based Telegram // inline photos, while PNG is used for direct chat replies. type Result struct { - PNG []byte - JPEG []byte - PDF []byte + PNG []byte + JPEG []byte + JPEGWidth int + JPEGHeight int + Thumbnail []byte + PDF []byte } // LatexError wraps a LaTeX compile failure and carries the log excerpt shown @@ -193,11 +199,12 @@ const ( FormatPNG Formats = 1 << iota FormatJPEG FormatPDF + FormatThumbnail ) // Render compiles the expression and produces every format. func (r *Renderer) Render(ctx context.Context, preamble, expression string, dpi int) (*Result, error) { - return r.RenderFormats(ctx, preamble, expression, dpi, FormatPNG|FormatJPEG|FormatPDF) + return r.RenderFormats(ctx, preamble, expression, dpi, FormatPNG|FormatJPEG|FormatPDF|FormatThumbnail) } // RenderFormats compiles the expression inside the given preamble and @@ -316,11 +323,25 @@ func (r *Renderer) RenderFormats(ctx context.Context, preamble, expression strin if err := r.renderRaster(ctx, workdir, dpi, pdfPath, jpegPath, "jpeg"); err != nil { return nil, err } + result.JPEGWidth, result.JPEGHeight, err = imageSize(jpegPath) + if err != nil { + return nil, fmt.Errorf("inspect rendered jpeg: %w", err) + } result.JPEG, err = os.ReadFile(jpegPath) if err != nil { return nil, fmt.Errorf("read rendered jpeg: %w", err) } } + if formats&FormatThumbnail != 0 { + thumbnailPath := filepath.Join(workdir, "document-thumb.jpg") + if err := r.renderThumbnail(ctx, workdir, dpi, pdfPath, thumbnailPath); err != nil { + return nil, err + } + result.Thumbnail, err = os.ReadFile(thumbnailPath) + if err != nil { + return nil, fmt.Errorf("read rendered thumbnail: %w", err) + } + } return result, nil } @@ -331,6 +352,12 @@ func (r *Renderer) RenderFormats(ctx context.Context, preamble, expression strin func (r *Renderer) renderRaster(ctx context.Context, workdir string, dpi int, pdfPath, outPath, format string) error { base := filepath.Join(workdir, "document") args := []string{"-" + format, "-r", strconv.Itoa(dpi), "-singlefile", "-f", "1", "-l", "1"} + if format == "jpeg" { + // Baseline JPEGs avoid partial/progressive decode artifacts in some + // Telegram clients while remaining comfortably below the 5 MB inline + // photo limit for normal formula renders. + args = append(args, "-jpegopt", "quality=90,progressive=n,optimize=y") + } if err := r.run(ctx, workdir, r.Pdftoppm, append(args, pdfPath, base)...); err != nil { return err } @@ -356,6 +383,30 @@ func (r *Renderer) renderRaster(ctx context.Context, workdir string, dpi int, pd return r.run(ctx, workdir, r.Pdftoppm, append(args, pdfPath, base)...) } +// renderThumbnail creates the dedicated small JPEG used only by Telegram's +// inline-result picker. Reusing the full-resolution photo URL as its thumbnail +// can leave clients displaying a 320-pixel partial decode over the full photo +// canvas even though the downloaded media itself is intact. +func (r *Renderer) renderThumbnail(ctx context.Context, workdir string, dpi int, pdfPath, outPath string) error { + base := strings.TrimSuffix(outPath, filepath.Ext(outPath)) + args := []string{ + "-jpeg", "-jpegopt", "quality=82,progressive=n,optimize=y", + "-r", strconv.Itoa(dpi), "-singlefile", "-f", "1", "-l", "1", + "-scale-to", strconv.Itoa(inlineThumbnailSide), pdfPath, base, + } + if err := r.run(ctx, workdir, r.Pdftoppm, args...); err != nil { + return fmt.Errorf("render inline thumbnail: %w", err) + } + width, height, err := imageSize(outPath) + if err != nil { + return fmt.Errorf("inspect inline thumbnail: %w", err) + } + if width > inlineThumbnailSide || height > inlineThumbnailSide { + return fmt.Errorf("inline thumbnail is unexpectedly large: %dx%d", width, height) + } + return nil +} + func imageSize(path string) (int, int, error) { file, err := os.Open(path) if err != nil { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index f5244cc..1d6de7a 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -107,17 +107,38 @@ func TestRenderIntegration(t *testing.T) { if len(result.JPEG) == 0 || !strings.HasPrefix(string(result.JPEG), "\xff\xd8") { t.Fatal("output is not a JPEG file") } + jpegWidth, jpegHeight, err := imageSizeBytes(result.JPEG) + if err != nil { + t.Fatalf("decode jpeg size: %v", err) + } + if result.JPEGWidth != jpegWidth || result.JPEGHeight != jpegHeight { + t.Errorf("reported JPEG size %dx%d, decoded %dx%d", result.JPEGWidth, result.JPEGHeight, jpegWidth, jpegHeight) + } + if len(result.Thumbnail) == 0 || !strings.HasPrefix(string(result.Thumbnail), "\xff\xd8") { + t.Fatal("output is not a JPEG thumbnail") + } + thumbWidth, thumbHeight, err := imageSizeBytes(result.Thumbnail) + if err != nil { + t.Fatalf("decode thumbnail size: %v", err) + } + if thumbWidth > inlineThumbnailSide || thumbHeight > inlineThumbnailSide { + t.Errorf("thumbnail is %dx%d, want each side <= %d", thumbWidth, thumbHeight, inlineThumbnailSide) + } linked, err := r.Render(ctx, "", `\hypertarget{details}{Details}\par\hyperlink{details}{Go to details}`, 150) if err != nil { t.Fatalf("hyperlink render failed: %v", err) } - if len(linked.PDF) == 0 || len(linked.PNG) == 0 || len(linked.JPEG) == 0 { + if len(linked.PDF) == 0 || len(linked.PNG) == 0 || len(linked.JPEG) == 0 || len(linked.Thumbnail) == 0 { t.Fatal("hyperlink render returned an empty format") } } func pngSizeBytes(data []byte) (int, int, error) { + return imageSizeBytes(data) +} + +func imageSizeBytes(data []byte) (int, int, error) { config, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return 0, 0, err