diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 91675cd..6ac2c00 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-v3-square-thumbnail" + renderCacheVersion = "presigned-v4-rich-environments" ) // outputFormat is one of the three generation formats. diff --git a/internal/render/render.go b/internal/render/render.go index 69ae1c3..c989dbe 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -9,9 +9,7 @@ import ( "errors" "fmt" "image" - "image/color" - "image/draw" - "image/jpeg" + _ "image/jpeg" _ "image/png" "math" "os" @@ -386,12 +384,10 @@ func (r *Renderer) renderRaster(ctx context.Context, workdir string, dpi int, pd } // renderThumbnail creates the dedicated small JPEG used only by Telegram's -// inline-result picker. It centers the aspect-correct preview on a square -// canvas because some clients force inline thumbnails into a square slot. -// Supplying the rectangular preview directly makes those clients stretch it. +// inline-result picker. It preserves the formula's aspect ratio and only +// shrinks oversized previews; short expressions are never enlarged. func (r *Renderer) renderThumbnail(ctx context.Context, workdir string, dpi int, pdfPath, outPath string) error { - base := strings.TrimSuffix(outPath, filepath.Ext(outPath)) + "-source" - sourcePath := base + ".jpg" + 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", @@ -399,7 +395,7 @@ func (r *Renderer) renderThumbnail(ctx context.Context, workdir string, dpi int, if err := r.run(ctx, workdir, r.Pdftoppm, append(args, pdfPath, base)...); err != nil { return fmt.Errorf("render inline thumbnail: %w", err) } - width, height, err := imageSize(sourcePath) + width, height, err := imageSize(outPath) if err != nil { return fmt.Errorf("inspect inline thumbnail: %w", err) } @@ -410,7 +406,7 @@ func (r *Renderer) renderThumbnail(ctx context.Context, workdir string, dpi int, if err := r.run(ctx, workdir, r.Pdftoppm, append(scaledArgs, pdfPath, base)...); err != nil { return fmt.Errorf("scale inline thumbnail: %w", err) } - width, height, err = imageSize(sourcePath) + width, height, err = imageSize(outPath) if err != nil { return fmt.Errorf("inspect scaled inline thumbnail: %w", err) } @@ -418,38 +414,9 @@ func (r *Renderer) renderThumbnail(ctx context.Context, workdir string, dpi int, if width > inlineThumbnailSide || height > inlineThumbnailSide { return fmt.Errorf("inline thumbnail is unexpectedly large: %dx%d", width, height) } - - sourceData, err := os.ReadFile(sourcePath) - if err != nil { - return fmt.Errorf("read inline thumbnail: %w", err) - } - source, err := jpeg.Decode(bytes.NewReader(sourceData)) - if err != nil { - return fmt.Errorf("decode inline thumbnail: %w", err) - } - thumbnail := squareThumbnail(source) - var encoded bytes.Buffer - if err := jpeg.Encode(&encoded, thumbnail, &jpeg.Options{Quality: 82}); err != nil { - return fmt.Errorf("encode inline thumbnail: %w", err) - } - if err := os.WriteFile(outPath, encoded.Bytes(), 0o600); err != nil { - return fmt.Errorf("write inline thumbnail: %w", err) - } return nil } -// squareThumbnail pads rather than stretches the already-scaled preview. -func squareThumbnail(source image.Image) *image.RGBA { - canvas := image.NewRGBA(image.Rect(0, 0, inlineThumbnailSide, inlineThumbnailSide)) - draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) - bounds := source.Bounds() - x := (inlineThumbnailSide - bounds.Dx()) / 2 - y := (inlineThumbnailSide - bounds.Dy()) / 2 - target := image.Rect(x, y, x+bounds.Dx(), y+bounds.Dy()) - draw.Draw(canvas, target, source, bounds.Min, draw.Src) - return canvas -} - 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 aa91572..7099b51 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -4,8 +4,6 @@ import ( "bytes" "context" "image" - "image/color" - "image/draw" _ "image/png" "os/exec" "strings" @@ -123,8 +121,8 @@ func TestRenderIntegration(t *testing.T) { if err != nil { t.Fatalf("decode thumbnail size: %v", err) } - if thumbWidth != inlineThumbnailSide || thumbHeight != inlineThumbnailSide { - t.Errorf("thumbnail is %dx%d, want a %dx%d padded square", thumbWidth, thumbHeight, inlineThumbnailSide, inlineThumbnailSide) + if thumbWidth > inlineThumbnailSide || thumbHeight > inlineThumbnailSide { + t.Errorf("thumbnail is %dx%d, want no side beyond %d", thumbWidth, thumbHeight, inlineThumbnailSide) } linked, err := r.Render(ctx, "", `\hypertarget{details}{Details}\par\hyperlink{details}{Go to details}`, 150) @@ -136,29 +134,6 @@ func TestRenderIntegration(t *testing.T) { } } -func TestSquareThumbnailPadsWithoutStretching(t *testing.T) { - source := image.NewRGBA(image.Rect(0, 0, 160, 80)) - draw.Draw(source, source.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src) - - thumbnail := squareThumbnail(source) - if thumbnail.Bounds() != image.Rect(0, 0, inlineThumbnailSide, inlineThumbnailSide) { - t.Fatalf("thumbnail bounds = %v", thumbnail.Bounds()) - } - for _, sample := range []struct { - point image.Point - want color.RGBA - }{ - {image.Pt(160, 119), color.RGBA{255, 255, 255, 255}}, - {image.Pt(160, 120), color.RGBA{0, 0, 0, 255}}, - {image.Pt(79, 160), color.RGBA{255, 255, 255, 255}}, - {image.Pt(80, 160), color.RGBA{0, 0, 0, 255}}, - } { - if got := thumbnail.RGBAAt(sample.point.X, sample.point.Y); got != sample.want { - t.Errorf("pixel at %v = %v, want %v", sample.point, got, sample.want) - } - } -} - func pngSizeBytes(data []byte) (int, int, error) { return imageSizeBytes(data) } diff --git a/internal/rich/latex.go b/internal/rich/latex.go index 84d1911..c56bbdc 100644 --- a/internal/rich/latex.go +++ b/internal/rich/latex.go @@ -105,7 +105,7 @@ var wrapperEnvironments = map[string]bool{ // source in math mode. var mathEnvironments = map[string]bool{ "equation": true, "equation*": true, "align": true, "align*": true, - "aligned": true, "gather": true, "gather*": true, "multline": true, + "aligned": true, "gather": true, "gather*": true, "gathered": true, "multline": true, "multline*": true, "eqnarray": true, "eqnarray*": true, "array": true, "matrix": true, "pmatrix": true, "bmatrix": true, "vmatrix": true, "Vmatrix": true, "Bmatrix": true, "cases": true, "split": true, @@ -177,7 +177,11 @@ func Document(source string, defaultMath bool) *Message { if trimmed == "" { return &Message{Blocks: []Block{{Type: "paragraph", Text: " "}}} } - if defaultMath && isPureMath(trimmed) { + // Math environments need block-aware parsing even when the whole source is + // mathematical. Document-level environments such as gather and align are + // not understood reliably by Telegram's native math renderer; parseBlocks + // unwraps or splits them into expressions it can render. + if defaultMath && len(environmentsIn(trimmed)) == 0 && isPureMath(trimmed) { if isSymbolsOnly(trimmed) { // Client math fonts often lack exotic symbols; the regular // text font renders them everywhere. @@ -585,7 +589,23 @@ func (p *parser) parseBlocks() []Block { for p.pos < len(p.src) && (p.src[p.pos] == ' ' || p.src[p.pos] == '\t') { p.pos++ } - case p.startsWith(`\begin{`), p.startsWith(`\end{`): + case p.startsWith(`\begin{`): + environment, body, after, ok := readCompleteEnvironment(p.src, p.pos) + if ok && mathEnvironments[environment] { + flush() + blocks = appendMathEnvironment(blocks, environment, body) + p.pos = after + continue + } + environment, after, markerOK := readEnvironment(p.src, p.pos) + if markerOK && wrapperEnvironments[environment] { + flush() + p.pos = after + } else { + text.WriteByte(p.src[p.pos]) + p.pos++ + } + case p.startsWith(`\end{`): environment, after, ok := readEnvironment(p.src, p.pos) if ok && wrapperEnvironments[environment] { flush() @@ -615,6 +635,211 @@ func (p *parser) parseBlocks() []Block { return blocks } +// readCompleteEnvironment reads a balanced environment beginning at index. +// Nested environments are kept in body and cannot terminate the outer one. +func readCompleteEnvironment(source string, index int) (name, body string, after int, ok bool) { + if !strings.HasPrefix(source[index:], `\begin{`) { + return "", "", index, false + } + name, bodyStart, ok := readEnvironment(source, index) + if !ok { + return "", "", index, false + } + stack := []string{name} + for position := bodyStart; position < len(source); { + beginOffset := strings.Index(source[position:], `\begin{`) + endOffset := strings.Index(source[position:], `\end{`) + if beginOffset < 0 && endOffset < 0 { + break + } + isBegin := beginOffset >= 0 && (endOffset < 0 || beginOffset < endOffset) + offset := endOffset + if isBegin { + offset = beginOffset + } + markerStart := position + offset + environment, markerAfter, markerOK := readEnvironment(source, markerStart) + if !markerOK { + position = markerStart + 1 + continue + } + if isBegin { + stack = append(stack, environment) + } else { + if len(stack) == 0 || stack[len(stack)-1] != environment { + return "", "", index, false + } + stack = stack[:len(stack)-1] + if len(stack) == 0 { + return name, source[bodyStart:markerStart], markerAfter, true + } + } + position = markerAfter + } + return "", "", index, false +} + +func appendMathEnvironment(blocks []Block, environment, body string) []Block { + rowEnvironment := false + removeAlignment := false + switch environment { + case "gather", "gather*", "multline", "multline*": + rowEnvironment = true + case "align", "align*", "eqnarray", "eqnarray*": + rowEnvironment = true + removeAlignment = true + case "equation", "equation*", "displaymath", "math": + return appendMathBlock(blocks, body) + default: + // Matrix-like environments carry semantics in their wrapper, so keep + // the complete environment inside a single native math expression. + return appendMathBlock(blocks, `\begin{`+environment+`}`+body+`\end{`+environment+`}`) + } + + if !rowEnvironment { + return blocks + } + for _, row := range splitMathRows(body) { + if removeAlignment { + row = removeMathAlignmentMarks(row) + } + blocks = appendMathBlock(blocks, row) + } + return blocks +} + +func appendMathBlock(blocks []Block, expression string) []Block { + expression = strings.TrimSpace(expression) + if expression == "" { + return blocks + } + if isSymbolsOnly(expression) { + return append(blocks, Block{Type: "paragraph", Text: expression}) + } + return append(blocks, Block{Type: "mathematical_expression", Expression: expression}) +} + +// splitMathRows splits document-level display environments at top-level \\. +// Row separators inside groups or nested matrix-like environments are kept. +func splitMathRows(source string) []string { + var rows []string + start := 0 + braceDepth := 0 + environmentDepth := 0 + for index := 0; index < len(source); { + switch source[index] { + case '\\': + if strings.HasPrefix(source[index:], `\begin{`) { + _, after, ok := readEnvironment(source, index) + if ok { + environmentDepth++ + index = after + continue + } + } + if strings.HasPrefix(source[index:], `\end{`) { + _, after, ok := readEnvironment(source, index) + if ok { + if environmentDepth > 0 { + environmentDepth-- + } + index = after + continue + } + } + if strings.HasPrefix(source[index:], `\\`) && braceDepth == 0 && environmentDepth == 0 { + rows = append(rows, source[start:index]) + index += 2 + if index < len(source) && source[index] == '*' { + index++ + } + index = skipOptionalBracket(source, index) + start = index + continue + } + _, next := readCommand(source, index) + index = next + case '{': + braceDepth++ + index++ + case '}': + if braceDepth > 0 { + braceDepth-- + } + index++ + default: + index++ + } + } + rows = append(rows, source[start:]) + return rows +} + +func skipOptionalBracket(source string, index int) int { + for index < len(source) && (source[index] == ' ' || source[index] == '\t') { + index++ + } + if index >= len(source) || source[index] != '[' { + return index + } + for index++; index < len(source); index++ { + switch source[index] { + case '\\': + index++ + case ']': + return index + 1 + } + } + return index +} + +func removeMathAlignmentMarks(source string) string { + var result strings.Builder + braceDepth := 0 + environmentDepth := 0 + for index := 0; index < len(source); { + if source[index] == '\\' { + if strings.HasPrefix(source[index:], `\begin{`) { + _, next, ok := readEnvironment(source, index) + if ok { + environmentDepth++ + result.WriteString(source[index:next]) + index = next + continue + } + } + if strings.HasPrefix(source[index:], `\end{`) { + _, next, ok := readEnvironment(source, index) + if ok { + if environmentDepth > 0 { + environmentDepth-- + } + result.WriteString(source[index:next]) + index = next + continue + } + } + _, next := readCommand(source, index) + result.WriteString(source[index:next]) + index = next + continue + } + switch source[index] { + case '{': + braceDepth++ + case '}': + if braceDepth > 0 { + braceDepth-- + } + } + if source[index] != '&' || braceDepth > 0 || environmentDepth > 0 { + result.WriteByte(source[index]) + } + index++ + } + return result.String() +} + // leadingSizeDeclaration strips a declaration-style font-size command at the // beginning of a block and returns the closest supported rich heading size. func leadingSizeDeclaration(source string) (content string, size int, ok bool) { diff --git a/internal/rich/latex_test.go b/internal/rich/latex_test.go index bb27c59..cd7ed1c 100644 --- a/internal/rich/latex_test.go +++ b/internal/rich/latex_test.go @@ -36,6 +36,67 @@ func TestDocumentDisplayMath(t *testing.T) { } } +func TestDocumentMixedTextAndGather(t *testing.T) { + source := `Expand $(a+b)^n$: +\begin{gather*} + (a + b)^n\\ + (a\ + \ b)^n\\ + (a\quad + \quad b)^n\\ + (a\qquad + \qquad b)^n +\end{gather*}` + message := Document(source, false) + if len(message.Blocks) != 5 { + t.Fatalf("blocks = %+v", message.Blocks) + } + parts, ok := message.Blocks[0].Text.([]any) + if !ok || len(parts) != 3 || parts[0] != "Expand " || parts[2] != ":" { + t.Fatalf("intro = %#v", message.Blocks[0].Text) + } + inline, ok := parts[1].(Inline) + if !ok || inline.Type != "mathematical_expression" || inline.Expression != `(a+b)^n` { + t.Fatalf("inline math = %#v", parts[1]) + } + want := []string{ + `(a + b)^n`, + `(a\ + \ b)^n`, + `(a\quad + \quad b)^n`, + `(a\qquad + \qquad b)^n`, + } + for index, expression := range want { + block := message.Blocks[index+1] + if block.Type != "mathematical_expression" || block.Expression != expression { + t.Errorf("row %d = %+v, want %q", index, block, expression) + } + } +} + +func TestDocumentMathEnvironmentRowsPreserveNestedEnvironments(t *testing.T) { + source := `\begin{align*} + A &= \begin{matrix} a & b \\ c & d \end{matrix}\\[1ex] + x &\leq y +\end{align*}` + message := Document(source, true) + if len(message.Blocks) != 2 { + t.Fatalf("blocks = %+v", message.Blocks) + } + if got := message.Blocks[0].Expression; got != `A = \begin{matrix} a & b \\ c & d \end{matrix}` { + t.Errorf("first row = %q", got) + } + if got := message.Blocks[1].Expression; got != `x \leq y` { + t.Errorf("second row = %q", got) + } +} + +func TestDocumentMatrixEnvironmentStaysWrapped(t *testing.T) { + message := Document(`\begin{pmatrix} a & b \\ c & d \end{pmatrix}`, true) + if len(message.Blocks) != 1 || message.Blocks[0].Type != "mathematical_expression" { + t.Fatalf("blocks = %+v", message.Blocks) + } + if got := message.Blocks[0].Expression; got != `\begin{pmatrix} a & b \\ c & d \end{pmatrix}` { + t.Fatalf("expression = %q", got) + } +} + func TestDocumentTextFormatting(t *testing.T) { message := Document(`Hello \textbf{world} and \emph{italics}`, true) if len(message.Blocks) != 1 { @@ -468,7 +529,6 @@ func TestInlineDocumentTextDefault(t *testing.T) { func TestDocumentBareMathCommands(t *testing.T) { for _, source := range []string{ `\frac{1}{2}`, - `\begin{align} a &= b \\ c &= d \end{align}`, `x = \text{hello}`, } { message := Document(source, true) @@ -476,4 +536,9 @@ func TestDocumentBareMathCommands(t *testing.T) { t.Errorf("source %q: blocks = %+v", source, message.Blocks) } } + + message := Document(`\begin{align} a &= b \\ c &= d \end{align}`, true) + if len(message.Blocks) != 2 || message.Blocks[0].Expression != `a = b` || message.Blocks[1].Expression != `c = d` { + t.Errorf("align blocks = %+v", message.Blocks) + } }