Work around truncated Telegram inline photos
This commit is contained in:
@@ -39,7 +39,7 @@ const (
|
|||||||
|
|
||||||
// Included in content hashes so renderer/template changes never reuse
|
// Included in content hashes so renderer/template changes never reuse
|
||||||
// stale Telegram file_ids or S3 objects from an older deployment.
|
// 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.
|
// outputFormat is one of the three generation formats.
|
||||||
@@ -131,6 +131,9 @@ type refsEntry struct {
|
|||||||
// identical inline queries skip rendering and uploading again.
|
// identical inline queries skip rendering and uploading again.
|
||||||
type urlEntry struct {
|
type urlEntry struct {
|
||||||
photoURL string
|
photoURL string
|
||||||
|
thumbnailURL string
|
||||||
|
photoWidth int
|
||||||
|
photoHeight int
|
||||||
pdfURL string
|
pdfURL string
|
||||||
expires time.Time
|
expires time.Time
|
||||||
}
|
}
|
||||||
@@ -480,7 +483,7 @@ func (b *Bot) savePhotoID(hash, fileID string) {
|
|||||||
if b.cache == nil {
|
if b.cache == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.savePhotoID(hash, fileID)
|
b.cache.SavePhotoID(hash, fileID)
|
||||||
b.refsMu.Lock()
|
b.refsMu.Lock()
|
||||||
entry := b.refsCache[hash]
|
entry := b.refsCache[hash]
|
||||||
entry.refs.PhotoID = fileID
|
entry.refs.PhotoID = fileID
|
||||||
@@ -493,7 +496,7 @@ func (b *Bot) saveDocID(hash, fileID string) {
|
|||||||
if b.cache == nil {
|
if b.cache == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.saveDocID(hash, fileID)
|
b.cache.SaveDocID(hash, fileID)
|
||||||
b.refsMu.Lock()
|
b.refsMu.Lock()
|
||||||
entry := b.refsCache[hash]
|
entry := b.refsCache[hash]
|
||||||
entry.refs.DocID = fileID
|
entry.refs.DocID = fileID
|
||||||
@@ -502,28 +505,29 @@ func (b *Bot) saveDocID(hash, fileID string) {
|
|||||||
b.refsMu.Unlock()
|
b.refsMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// cachedURLs returns the presigned URLs of a recent render of the same
|
// cachedURLs returns the presigned URLs and photo geometry of a recent render
|
||||||
// content, so repeated identical queries skip render and upload entirely.
|
// of the same content, so repeated identical queries skip render and upload.
|
||||||
func (b *Bot) cachedURLs(hash string) (photoURL, pdfURL string, ok bool) {
|
func (b *Bot) cachedURLs(hash string) (urlEntry, bool) {
|
||||||
b.urlsMu.Lock()
|
b.urlsMu.Lock()
|
||||||
defer b.urlsMu.Unlock()
|
defer b.urlsMu.Unlock()
|
||||||
entry, found := b.urlsCache[hash]
|
entry, found := b.urlsCache[hash]
|
||||||
if !found || time.Now().After(entry.expires) {
|
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
|
ttl := b.cfg.S3PresignTTL - 10*time.Minute
|
||||||
if ttl < 5*time.Minute {
|
if ttl < 5*time.Minute {
|
||||||
ttl = 5 * time.Minute
|
ttl = 5 * time.Minute
|
||||||
}
|
}
|
||||||
|
entry.expires = time.Now().Add(ttl)
|
||||||
b.urlsMu.Lock()
|
b.urlsMu.Lock()
|
||||||
if b.urlsCache == nil {
|
if b.urlsCache == nil {
|
||||||
b.urlsCache = map[string]urlEntry{}
|
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()
|
b.urlsMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,18 +561,30 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) []
|
|||||||
refs := b.fileRefs(hash)
|
refs := b.fileRefs(hash)
|
||||||
needPhoto := refs.PhotoID == ""
|
needPhoto := refs.PhotoID == ""
|
||||||
needDoc := refs.DocID == ""
|
needDoc := refs.DocID == ""
|
||||||
var photoURL, pdfURL string
|
var urls urlEntry
|
||||||
if needPhoto || needDoc {
|
if needPhoto || needDoc {
|
||||||
if cachedPhoto, cachedPDF, ok := b.cachedURLs(hash); ok {
|
if cached, ok := b.cachedURLs(hash); ok {
|
||||||
photoURL, pdfURL = cachedPhoto, cachedPDF
|
urls = cached
|
||||||
needPhoto, needDoc = false, false
|
if cached.photoURL != "" && cached.thumbnailURL != "" {
|
||||||
|
needPhoto = false
|
||||||
|
}
|
||||||
|
if cached.pdfURL != "" {
|
||||||
|
needDoc = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if b.files != nil && (needPhoto || needDoc) {
|
if b.files != nil && (needPhoto || needDoc) {
|
||||||
renderCtx, cancel := context.WithTimeout(ctx, b.cfg.RenderTimeout)
|
renderCtx, cancel := context.WithTimeout(ctx, b.cfg.RenderTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
started := time.Now()
|
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{
|
stat := store.RenderStat{
|
||||||
Source: "inline", Format: "inline", UserID: query.From.ID,
|
Source: "inline", Format: "inline", UserID: query.From.ID,
|
||||||
DPI: dpi, ExpressionLen: len(expr), OK: err == nil,
|
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)
|
go b.rec.RecordRender(stat)
|
||||||
|
|
||||||
// Upload both formats concurrently: each Put is an object store
|
// Upload the requested formats concurrently: each Put is an object
|
||||||
// round-trip.
|
// 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 wg sync.WaitGroup
|
||||||
var photoPutURL, pdfPutURL string
|
var photoPutURL, thumbnailPutURL, pdfPutURL string
|
||||||
if needPhoto {
|
if needPhoto {
|
||||||
wg.Add(1)
|
wg.Add(2)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
storedURL, putErr := b.files.Put(ctx, "jpg", "image/jpeg", result.JPEG)
|
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
|
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 {
|
if needDoc {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -610,14 +636,17 @@ func (b *Bot) inlineResults(ctx context.Context, query *tgbotapi.InlineQuery) []
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
if photoPutURL != "" {
|
if photoPutURL != "" && thumbnailPutURL != "" {
|
||||||
photoURL = photoPutURL
|
urls.photoURL = photoPutURL
|
||||||
|
urls.thumbnailURL = thumbnailPutURL
|
||||||
|
urls.photoWidth = result.JPEGWidth
|
||||||
|
urls.photoHeight = result.JPEGHeight
|
||||||
}
|
}
|
||||||
if pdfPutURL != "" {
|
if pdfPutURL != "" {
|
||||||
pdfURL = pdfPutURL
|
urls.pdfURL = pdfPutURL
|
||||||
}
|
}
|
||||||
if photoPutURL != "" || pdfPutURL != "" {
|
if urls.photoURL != "" || urls.pdfURL != "" {
|
||||||
b.cacheURLs(hash, photoURL, 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 := tgbotapi.NewInlineQueryResultCachedPhoto("photo-"+id, refs.PhotoID)
|
||||||
photo.Title = "PNG"
|
photo.Title = "PNG"
|
||||||
results = append(results, photo)
|
results = append(results, photo)
|
||||||
} else if photoURL != "" {
|
} else if urls.photoURL != "" && urls.thumbnailURL != "" {
|
||||||
photo := tgbotapi.NewInlineQueryResultPhoto("photo-"+id, photoURL)
|
results = append(results, inlineQueryResultPhoto{
|
||||||
photo.ThumbURL = photoURL
|
Type: "photo",
|
||||||
photo.Title = "PNG"
|
ID: "photo-" + id,
|
||||||
results = append(results, photo)
|
PhotoURL: urls.photoURL,
|
||||||
|
ThumbnailURL: urls.thumbnailURL,
|
||||||
|
PhotoWidth: urls.photoWidth,
|
||||||
|
PhotoHeight: urls.photoHeight,
|
||||||
|
Title: "PNG",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if refs.DocID != "" {
|
if refs.DocID != "" {
|
||||||
document := tgbotapi.NewInlineQueryResultCachedDocument("doc-"+id, refs.DocID, "PDF")
|
document := tgbotapi.NewInlineQueryResultCachedDocument("doc-"+id, refs.DocID, "PDF")
|
||||||
document.Title = "PDF"
|
document.Title = "PDF"
|
||||||
results = append(results, document)
|
results = append(results, document)
|
||||||
} else if pdfURL != "" {
|
} else if urls.pdfURL != "" {
|
||||||
document := tgbotapi.NewInlineQueryResultDocument("doc-"+id, pdfURL, "formula.pdf", "application/pdf")
|
document := tgbotapi.NewInlineQueryResultDocument("doc-"+id, urls.pdfURL, "formula.pdf", "application/pdf")
|
||||||
document.Title = "PDF"
|
document.Title = "PDF"
|
||||||
results = append(results, document)
|
results = append(results, document)
|
||||||
}
|
}
|
||||||
@@ -668,6 +702,18 @@ type inputRichMessageContent struct {
|
|||||||
RichMessage *rich.Message `json:"rich_message"`
|
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
|
// sendPhoto sends a rendered picture to the chat and returns its reusable
|
||||||
// Telegram file_id.
|
// Telegram file_id.
|
||||||
func (b *Bot) sendPhoto(chatID int64, png []byte) string {
|
func (b *Bot) sendPhoto(chatID int64, png []byte) string {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package bot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -76,7 +77,11 @@ func testBot() *Bot {
|
|||||||
return &Bot{
|
return &Bot{
|
||||||
api: &tgbotapi.BotAPI{Self: tgbotapi.User{UserName: "latexgrambot"}},
|
api: &tgbotapi.BotAPI{Self: tgbotapi.User{UserName: "latexgrambot"}},
|
||||||
cfg: &config.Config{MaxExpressionLen: 4000, RichTextEnabled: true, RichDefaultMath: true, RenderDPI: 600},
|
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")}},
|
ren: &stubRenderer{results: &render.Result{
|
||||||
|
PNG: []byte("png"), JPEG: []byte("jpg"),
|
||||||
|
JPEGWidth: 1600, JPEGHeight: 600,
|
||||||
|
Thumbnail: []byte("thumbnail"), PDF: []byte("pdf"),
|
||||||
|
}},
|
||||||
user: &fakeSettings{},
|
user: &fakeSettings{},
|
||||||
rec: &fakeRecorder{},
|
rec: &fakeRecorder{},
|
||||||
files: &fakeFiles{},
|
files: &fakeFiles{},
|
||||||
@@ -103,16 +108,29 @@ func TestInlineResultsUsesPresignedFiles(t *testing.T) {
|
|||||||
t.Fatalf("got %d results, want 3", len(results))
|
t.Fatalf("got %d results, want 3", len(results))
|
||||||
}
|
}
|
||||||
|
|
||||||
photo, ok := results[0].(tgbotapi.InlineQueryResultPhoto)
|
photo, ok := results[0].(inlineQueryResultPhoto)
|
||||||
if !ok {
|
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 {
|
if !strings.Contains(photo.PhotoURL, ".jpg?") || !strings.Contains(photo.ThumbnailURL, ".thumb.jpg?") {
|
||||||
t.Errorf("photo URL/thumb = %q/%q", photo.URL, photo.ThumbURL)
|
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" {
|
if photo.Title != "PNG" {
|
||||||
t.Errorf("photo title = %q", photo.Title)
|
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)
|
document, ok := results[1].(tgbotapi.InlineQueryResultDocument)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -145,7 +163,7 @@ func TestInlineResultsUsesPresignedFiles(t *testing.T) {
|
|||||||
t.Fatalf("unexpected inline math node: %+v", parts[1])
|
t.Fatalf("unexpected inline math node: %+v", parts[1])
|
||||||
}
|
}
|
||||||
files := b.files.(*fakeFiles)
|
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)
|
t.Fatalf("stored files = %#v", files.puts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,13 +174,13 @@ func TestInlineResultsCachesPresignedURLs(t *testing.T) {
|
|||||||
first := b.inlineResults(context.Background(), query)
|
first := b.inlineResults(context.Background(), query)
|
||||||
second := b.inlineResults(context.Background(), query)
|
second := b.inlineResults(context.Background(), query)
|
||||||
files := b.files.(*fakeFiles)
|
files := b.files.(*fakeFiles)
|
||||||
if files.putCalls != 2 {
|
if files.putCalls != 3 {
|
||||||
t.Fatalf("put calls = %d, want 2 (one per format, cached afterwards)", files.putCalls)
|
t.Fatalf("put calls = %d, want 3 (photo, thumbnail and PDF, cached afterwards)", files.putCalls)
|
||||||
}
|
}
|
||||||
firstPhoto := first[0].(tgbotapi.InlineQueryResultPhoto)
|
firstPhoto := first[0].(inlineQueryResultPhoto)
|
||||||
secondPhoto := second[0].(tgbotapi.InlineQueryResultPhoto)
|
secondPhoto := second[0].(inlineQueryResultPhoto)
|
||||||
if firstPhoto.URL != secondPhoto.URL {
|
if firstPhoto.PhotoURL != secondPhoto.PhotoURL || firstPhoto.ThumbnailURL != secondPhoto.ThumbnailURL {
|
||||||
t.Fatalf("second query re-rendered: %q vs %q", firstPhoto.URL, secondPhoto.URL)
|
t.Fatalf("second query re-rendered: %+v vs %+v", firstPhoto, secondPhoto)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,8 +219,22 @@ func TestInlineResultsRichDisabled(t *testing.T) {
|
|||||||
if len(results) != 2 {
|
if len(results) != 2 {
|
||||||
t.Fatalf("got %d results, want 2", len(results))
|
t.Fatalf("got %d results, want 2", len(results))
|
||||||
}
|
}
|
||||||
if _, ok := results[0].(tgbotapi.InlineQueryResultPhoto); !ok {
|
if _, ok := results[0].(inlineQueryResultPhoto); !ok {
|
||||||
t.Fatalf("result 0 is %T, want InlineQueryResultPhoto", results[0])
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const (
|
|||||||
|
|
||||||
// maxRenderedSide guards against pathologically tall formulas.
|
// maxRenderedSide guards against pathologically tall formulas.
|
||||||
maxRenderedSide = 4096
|
maxRenderedSide = 4096
|
||||||
|
|
||||||
|
// inlineThumbnailSide is Telegram's conventional maximum thumbnail side.
|
||||||
|
inlineThumbnailSide = 320
|
||||||
)
|
)
|
||||||
|
|
||||||
// documentClass is the fixed preview layout: the standalone class crops
|
// documentClass is the fixed preview layout: the standalone class crops
|
||||||
@@ -115,6 +118,9 @@ func DefaultPreamble() string {
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
PNG []byte
|
PNG []byte
|
||||||
JPEG []byte
|
JPEG []byte
|
||||||
|
JPEGWidth int
|
||||||
|
JPEGHeight int
|
||||||
|
Thumbnail []byte
|
||||||
PDF []byte
|
PDF []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,11 +199,12 @@ const (
|
|||||||
FormatPNG Formats = 1 << iota
|
FormatPNG Formats = 1 << iota
|
||||||
FormatJPEG
|
FormatJPEG
|
||||||
FormatPDF
|
FormatPDF
|
||||||
|
FormatThumbnail
|
||||||
)
|
)
|
||||||
|
|
||||||
// Render compiles the expression and produces every format.
|
// Render compiles the expression and produces every format.
|
||||||
func (r *Renderer) Render(ctx context.Context, preamble, expression string, dpi int) (*Result, error) {
|
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
|
// 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 {
|
if err := r.renderRaster(ctx, workdir, dpi, pdfPath, jpegPath, "jpeg"); err != nil {
|
||||||
return nil, err
|
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)
|
result.JPEG, err = os.ReadFile(jpegPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read rendered jpeg: %w", err)
|
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
|
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 {
|
func (r *Renderer) renderRaster(ctx context.Context, workdir string, dpi int, pdfPath, outPath, format string) error {
|
||||||
base := filepath.Join(workdir, "document")
|
base := filepath.Join(workdir, "document")
|
||||||
args := []string{"-" + format, "-r", strconv.Itoa(dpi), "-singlefile", "-f", "1", "-l", "1"}
|
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 {
|
if err := r.run(ctx, workdir, r.Pdftoppm, append(args, pdfPath, base)...); err != nil {
|
||||||
return err
|
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)...)
|
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) {
|
func imageSize(path string) (int, int, error) {
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -107,17 +107,38 @@ func TestRenderIntegration(t *testing.T) {
|
|||||||
if len(result.JPEG) == 0 || !strings.HasPrefix(string(result.JPEG), "\xff\xd8") {
|
if len(result.JPEG) == 0 || !strings.HasPrefix(string(result.JPEG), "\xff\xd8") {
|
||||||
t.Fatal("output is not a JPEG file")
|
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)
|
linked, err := r.Render(ctx, "", `\hypertarget{details}{Details}\par\hyperlink{details}{Go to details}`, 150)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("hyperlink render failed: %v", err)
|
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")
|
t.Fatal("hyperlink render returned an empty format")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pngSizeBytes(data []byte) (int, int, error) {
|
func pngSizeBytes(data []byte) (int, int, error) {
|
||||||
|
return imageSizeBytes(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageSizeBytes(data []byte) (int, int, error) {
|
||||||
config, _, err := image.DecodeConfig(bytes.NewReader(data))
|
config, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
|
|||||||
Reference in New Issue
Block a user