#23696: feat(discord): render markdown tables as PNG images
channel: discord
scripts
channel: twitch
size: L
## Summary
When Discord users receive AI responses containing GFM markdown tables, the raw pipe-character formatting is unreadable — especially on mobile. This PR adds an opt-in `tableMode: "image"` config option that renders tables as styled PNG images using satori + @resvg/resvg-js (~4MB total, pure JS — no native canvas dependency).
## How it works
1. **Table splitter** (`src/markdown/table-split.ts`) uses markdown-it to segment a reply into interleaved text and table blocks
2. **Table renderer** (`src/media/table-image.ts`) parses GFM tables, builds a satori element tree (GitHub-dark theme), and renders to PNG via resvg
3. **Delivery pipeline** (`src/discord/monitor/reply-delivery.ts`) sends text chunks normally and table PNGs as direct file attachments (bypasses `loadWebMedia()` which would recompress PNG→JPEG)
4. Falls back to code-block formatting on any failure (missing fonts, render error, upload failure)
## Key decisions
- **satori + resvg over @napi-rs/canvas**: ~4MB vs ~32MB, no native compilation, works everywhere Node runs
- **Bundled fonts** (4 Noto TTFs in `src/media/fonts/`): fully self-contained, no system font probing
- **Direct `rest.post` with `files`**: avoids the `loadWebMedia()` path in `send.shared.ts` that recompresses PNG to JPEG
- **Opt-in via config**: `tableMode: "image"` — default stays `"code"` so nothing changes for existing users
- **No retry on upload failure**: falls back to text immediately, which is faster than retrying a file upload
## Changes
- `src/media/table-image.ts` — GFM parser + satori/resvg renderer (~377 lines)
- `src/markdown/table-split.ts` — markdown-it based table segmenter (~92 lines)
- `src/discord/monitor/reply-delivery.ts` — image table delivery pipeline, `sendDiscordFileBuffer`, `sendChunkedTextWithFallback` helper (integrated with existing webhook/persona/thread-binding system)
- `src/discord/monitor/message-handler.process.ts` — fold `tableMode === "image"` into `hasMedia` to skip preview-edit path
- `src/config/types.base.ts`, `src/config/markdown-tables.ts`, `src/config/zod-schema.core.ts` — add `"image"` to MarkdownTableMode
- `extensions/twitch/src/monitor.ts` — sync local table mode type
- `scripts/copy-table-fonts.ts` — build step to copy fonts to dist/
- `package.json` — added satori + @resvg/resvg-js deps
## Known limitation
Table image PNGs in thread-bound subagent sessions are sent via direct REST (not the webhook path), so they appear under the bot's identity rather than the subagent's persona. Text chunks between tables still route through the webhook correctly. This only affects subagent threads — main agent delivery is unaffected.
## Tests
- 7 parser tests (alignment, escaping, `\\|`, size limits, truncation)
- 6 splitter tests (segmentation, edge cases)
- 9 delivery tests (existing webhook/voice/replyTo tests + 2 new table image tests)
All 22 tests passing. Lint, format, and typecheck clean.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
Adds opt-in `tableMode: "image"` config to render GFM markdown tables as PNG images via satori + resvg (~4MB total). This solves the readability problem of raw pipe-character tables on Discord mobile.
**Key changes:**
- `table-image.ts` — GFM parser + satori/resvg renderer with GitHub-dark theme, size guards (max 60 rows, 20 cols, 2400×4000px), and bundled Noto fonts
- `table-split.ts` — markdown-it based table segmenter that preserves text/table ordering
- `reply-delivery.ts` — delivers tables as PNG attachments via direct REST API (bypasses JPEG recompression), falls back to code-block formatting on failure
- Config updates add `"image"` to `MarkdownTableMode` union across type definitions and zod schema
- Build script copies bundled fonts from `src/media/fonts/` to `dist/fonts/`
- 22 tests passing (7 parser, 6 splitter, 9 delivery including 2 new image tests)
**Implementation quality:**
- Lazy-loading of satori/resvg dependencies minimizes impact when feature is disabled
- Graceful fallbacks at every layer (missing fonts, render failure, upload failure)
- Size guards prevent OOM from malicious/huge tables
- Direct `rest.post` with `files` parameter correctly avoids the `loadWebMedia()` JPEG recompression path
**Known limitation:**
Table PNGs in thread-bound subagent sessions appear under bot identity rather than subagent persona because they use direct REST instead of webhook delivery. This only affects subagent threads; main agent delivery is unaffected.
<h3>Confidence Score: 5/5</h3>
- Safe to merge with minimal risk
- Well-architected opt-in feature with comprehensive fallback handling, thorough testing, size guards against OOM, and graceful degradation paths. No breaking changes.
- No files require special attention
<sub>Last reviewed commit: b28d2bd</sub>
<!-- greptile_other_comments_section -->
<sub>(4/5) You can add custom instructions or style guidelines for the agent [here](https://app.greptile.com/review/github)!</sub>
<!-- /greptile_comment -->
Most Similar PRs
#13917: fix(feishu): card rendering for tables, blockquotes, images, and ou...
by yaoting · 2026-02-11
75.6%
#10902: fix(msteams): fix inline pasted image downloads
by jlian · 2026-02-07
74.9%
#20913: fix: intercept Discord embed images to enforce mediaMaxMb
by MumuTW · 2026-02-19
74.8%
#15251: feat(web-fetch): send Accept: text/markdown header for Cloudflare M...
by wujieli0207 · 2026-02-13
73.6%
#20419: fix(webchat): explicitly pass gfm and breaks options to marked.parse()
by Limitless2023 · 2026-02-18
72.8%
#17629: fix(telegram): fall back to plain text when HTML formatter produces...
by Glucksberg · 2026-02-16
72.8%
#18655: fix(mattermost): preserve markdown formatting and native tables
by echo931 · 2026-02-16
72.7%
#20046: docs: animated SVG banner, contributors marquee & footer [AI-assist...
by Tryboy869 · 2026-02-18
72.3%
#12257: fix(mattermost): default table mode to 'off' for native Markdown re...
by mcaxtr · 2026-02-09
72.2%
#20081: feat: post-compaction triage UX — fuzzy ok + stage-2 gate + Discord...
by PrivacySmurf · 2026-02-18
71.1%