Render display math environments in rich mode
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user