diff --git a/Makefile b/Makefile
index a64b4c2..bd40a5d 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@
 
 include config.mk
 
-SRC = st.c x.c boxdraw.c
+SRC = st.c x.c boxdraw.c rowcolumn_diacritics_helpers.c graphics.c
 OBJ = $(SRC:.c=.o)
 
 all: st
@@ -15,9 +15,10 @@ config.h:
 .c.o:
 	$(CC) $(STCFLAGS) -c $<
 
-st.o: config.h st.h win.h
-x.o: arg.h config.h st.h win.h
+st.o: config.h st.h win.h graphics.h
+x.o: arg.h config.h st.h win.h graphics.h
 boxdraw.o: config.h st.h boxdraw_data.h
+graphics.c: graphics.h khash.h kvec.h st.h
 
 $(OBJ): config.h config.mk
 
diff --git a/config.mk b/config.mk
index 2fc854e..7b22243 100644
--- a/config.mk
+++ b/config.mk
@@ -14,9 +14,12 @@ PKG_CONFIG = pkg-config
 
 # includes and libs
 INCS = -I$(X11INC) \
+       `$(PKG_CONFIG) --cflags imlib2` \
        `$(PKG_CONFIG) --cflags fontconfig` \
        `$(PKG_CONFIG) --cflags freetype2`
-LIBS = -L$(X11LIB) -lm -lrt -lX11 -lutil -lXft \
+LIBS = -L$(X11LIB) -lm -lrt -lX11 -lutil -lXft -lXrender \
+       `$(PKG_CONFIG) --libs imlib2` \
+       `$(PKG_CONFIG) --libs zlib` \
        `$(PKG_CONFIG) --libs fontconfig` \
        `$(PKG_CONFIG) --libs freetype2`
 
diff --git a/st.c b/st.c
index 64d9266..6500d9b 100644
--- a/st.c
+++ b/st.c
@@ -19,6 +19,7 @@
 
 #include "st.h"
 #include "win.h"
+#include "graphics.h"
 
 #if   defined(__linux)
  #include <pty.h>
@@ -37,6 +38,10 @@
 #define STR_ARG_SIZ   ESC_ARG_SIZ
 #define HISTSIZE      5000
 
+/* PUA character used as an image placeholder */
+#define IMAGE_PLACEHOLDER_CHAR 0x10EEEE
+#define IMAGE_PLACEHOLDER_CHAR_OLD 0xEEEE
+
 /* macros */
 #define IS_SET(flag)		((term.mode & (flag)) != 0)
 #define ISCONTROLC0(c)		(BETWEEN(c, 0, 0x1f) || (c) == 0x7f)
@@ -117,6 +122,8 @@ typedef struct {
 typedef struct {
 	int row;      /* nb row */
 	int col;      /* nb col */
+	int pixw;     /* width of the text area in pixels */
+	int pixh;     /* height of the text area in pixels */
 	Line *line;   /* screen */
 	Line *alt;    /* alternate screen */
 	Line hist[HISTSIZE]; /* history buffer */
@@ -220,7 +227,6 @@ static Rune utf8decodebyte(char, size_t *);
 static char utf8encodebyte(Rune, size_t);
 static size_t utf8validate(Rune *, size_t);
 
-static char *base64dec(const char *);
 static char base64dec_getc(const char **);
 
 static ssize_t xwrite(int, const char *, size_t);
@@ -239,6 +245,10 @@ static const uchar utfmask[UTF_SIZ + 1] = {0xC0, 0x80, 0xE0, 0xF0, 0xF8};
 static const Rune utfmin[UTF_SIZ + 1] = {       0,    0,  0x80,  0x800,  0x10000};
 static const Rune utfmax[UTF_SIZ + 1] = {0x10FFFF, 0x7F, 0x7FF, 0xFFFF, 0x10FFFF};
 
+/* Converts a diacritic to a row/column/etc number. The result is 1-base, 0
+ * means "couldn't convert". Defined in rowcolumn_diacritics_helpers.c */
+uint16_t diacritic_to_num(uint32_t code);
+
 ssize_t
 xwrite(int fd, const char *s, size_t len)
 {
@@ -623,6 +633,12 @@ getsel(void)
 			if (gp->mode & ATTR_WDUMMY)
 				continue;
 
+			if (gp->mode & ATTR_IMAGE) {
+				// TODO: Copy diacritics as well
+				ptr += utf8encode(IMAGE_PLACEHOLDER_CHAR, ptr);
+				continue;
+			}
+
 			ptr += utf8encode(gp->u, ptr);
 		}
 
@@ -831,7 +847,11 @@ ttyread(void)
 {
 	static char buf[BUFSIZ];
 	static int buflen = 0;
-	int ret, written;
+	static int already_processing = 0;
+	int ret, written = 0;
+
+	if (buflen >= LEN(buf))
+		return 0;
 
 	/* append read bytes to unprocessed bytes */
 	ret = read(cmdfd, buf+buflen, LEN(buf)-buflen);
@@ -843,7 +863,24 @@ ttyread(void)
 		die("couldn't read from shell: %s\n", strerror(errno));
 	default:
 		buflen += ret;
-		written = twrite(buf, buflen, 0);
+		if (already_processing) {
+			/* Avoid recursive call to twrite() */
+			return ret;
+		}
+		already_processing = 1;
+		while (1) {
+			int buflen_before_processing = buflen;
+			written += twrite(buf + written, buflen - written, 0);
+			// If buflen changed during the call to twrite, there is
+			// new data, and we need to keep processing, otherwise
+			// we can exit. This will not loop forever because the
+			// buffer is limited, and we don't clean it in this
+			// loop, so at some point ttywrite will have to drop
+			// some data.
+			if (buflen_before_processing == buflen)
+				break;
+		}
+		already_processing = 0;
 		buflen -= written;
 		/* keep any incomplete UTF-8 byte sequence for the next call */
 		if (buflen > 0)
@@ -889,6 +926,7 @@ ttywriteraw(const char *s, size_t n)
 	fd_set wfd, rfd;
 	ssize_t r;
 	size_t lim = 256;
+	int retries_left = 100;
 
 	/*
 	 * Remember that we are using a pty, which might be a modem line.
@@ -897,6 +935,9 @@ ttywriteraw(const char *s, size_t n)
 	 * FIXME: Migrate the world to Plan 9.
 	 */
 	while (n > 0) {
+		if (retries_left-- <= 0)
+			goto too_many_retries;
+
 		FD_ZERO(&wfd);
 		FD_ZERO(&rfd);
 		FD_SET(cmdfd, &wfd);
@@ -938,11 +979,16 @@ ttywriteraw(const char *s, size_t n)
 
 write_error:
 	die("write error on tty: %s\n", strerror(errno));
+too_many_retries:
+	fprintf(stderr, "Could not write %zu bytes to tty\n", n);
 }
 
 void
 ttyresize(int tw, int th)
 {
+	term.pixw = tw;
+	term.pixh = th;
+
 	struct winsize w;
 
 	w.ws_row = term.row;
@@ -1033,7 +1079,8 @@ treset(void)
 	term.c = (TCursor){{
 		.mode = ATTR_NULL,
 		.fg = defaultfg,
-		.bg = defaultbg
+		.bg = defaultbg,
+		.decor = DECOR_DEFAULT_COLOR
 	}, .x = 0, .y = 0, .state = CURSOR_DEFAULT};
 
 	memset(term.tabs, 0, term.col * sizeof(*term.tabs));
@@ -1056,7 +1103,9 @@ treset(void)
 void
 tnew(int col, int row)
 {
-	term = (Term){ .c = { .attr = { .fg = defaultfg, .bg = defaultbg } } };
+	term = (Term){.c = {.attr = {.fg = defaultfg,
+				     .bg = defaultbg,
+				     .decor = DECOR_DEFAULT_COLOR}}};
 	tresize(col, row);
 	treset();
 }
@@ -1290,10 +1339,25 @@ tsetchar(Rune u, const Glyph *attr, int x, int y)
 		term.line[y][x-1].mode &= ~ATTR_WIDE;
 	}
 
+	if (u == ' ' && term.line[y][x].mode & ATTR_IMAGE &&
+	    tgetisclassicplaceholder(&term.line[y][x])) {
+		// This is a workaround: don't overwrite classic placement
+		// placeholders with space symbols (unlike Unicode placeholders
+		// which must be overwritten by anything).
+		term.line[y][x].bg = attr->bg;
+		term.dirty[y] = 1;
+		return;
+	}
+
 	term.dirty[y] = 1;
 	term.line[y][x] = *attr;
 	term.line[y][x].u = u;
 
+	if (u == IMAGE_PLACEHOLDER_CHAR || u == IMAGE_PLACEHOLDER_CHAR_OLD) {
+		term.line[y][x].u = 0;
+		term.line[y][x].mode |= ATTR_IMAGE;
+	}
+
 	if (isboxdraw(u))
 		term.line[y][x].mode |= ATTR_BOXDRAW;
 }
@@ -1322,12 +1386,110 @@ tclearregion(int x1, int y1, int x2, int y2)
 				selclear();
 			gp->fg = term.c.attr.fg;
 			gp->bg = term.c.attr.bg;
+			gp->decor = term.c.attr.decor;
 			gp->mode = 0;
 			gp->u = ' ';
 		}
 	}
 }
 
+/// Fills a rectangle area with an image placeholder. The starting point is the
+/// cursor. Adds empty lines if needed. The placeholder will be marked as
+/// classic.
+void tcreateimgplaceholder(uint32_t image_id, uint32_t placement_id, int cols,
+			   int rows, char do_not_move_cursor,
+			   Glyph *text_underneath) {
+	for (int row = 0; row < rows; ++row) {
+		int y = term.c.y;
+		term.dirty[y] = 1;
+		for (int col = 0; col < cols; ++col) {
+			int x = term.c.x + col;
+			if (x >= term.col)
+				break;
+			Glyph *gp = &term.line[y][x];
+			if (selected(x, y))
+				selclear();
+			if (text_underneath) {
+				Glyph *to_save = gp;
+				// If there is already a classic placeholder,
+				// use the text underneath it. This will leave
+				// holes in images, but at least we are
+				// guaranteed to restore the original text.
+				if (gp->mode & ATTR_IMAGE &&
+				    tgetisclassicplaceholder(gp)) {
+					Glyph *under =
+						gr_get_glyph_underneath_image(
+							tgetimgid(gp),
+							tgetimgplacementid(gp),
+							tgetimgcol(gp),
+							tgetimgrow(gp));
+					if (under)
+						to_save = under;
+				}
+				text_underneath[cols * row + col] = *to_save;
+			}
+			gp->mode = ATTR_IMAGE;
+			gp->u = 0;
+			tsetimgrow(gp, row + 1);
+			tsetimgcol(gp, col + 1);
+			tsetimgid(gp, image_id);
+			tsetimgplacementid(gp, placement_id);
+			tsetimgdiacriticcount(gp, 3);
+			tsetisclassicplaceholder(gp, 1);
+		}
+		// If moving the cursor is not allowed and this is the last line
+		// of the terminal, we are done.
+		if (do_not_move_cursor && y == term.row - 1)
+			break;
+		// Move the cursor down, maybe creating a new line. The x is
+		// preserved (we never change term.c.x in the loop above).
+		if (row != rows - 1)
+			tnewline(/*first_col=*/0);
+	}
+	if (do_not_move_cursor) {
+		// Return the cursor to the original position.
+		tmoveto(term.c.x, term.c.y - rows + 1);
+	} else {
+		// Move the cursor beyond the last column, as required by the
+		// protocol. If the cursor goes beyond the screen edge, insert a
+		// newline to match the behavior of kitty.
+		if (term.c.x + cols >= term.col)
+			tnewline(/*first_col=*/1);
+		else
+			tmoveto(term.c.x + cols, term.c.y);
+	}
+}
+
+void gr_for_each_image_cell(int (*callback)(void *data, Glyph *gp),
+			    void *data) {
+	for (int row = 0; row < term.row; ++row) {
+		for (int col = 0; col < term.col; ++col) {
+			Glyph *gp = &term.line[row][col];
+			if (gp->mode & ATTR_IMAGE) {
+				if (callback(data, gp))
+					term.dirty[row] = 1;
+			}
+		}
+	}
+}
+
+void gr_schedule_image_redraw_by_id(uint32_t image_id) {
+	for (int row = 0; row < term.row; ++row) {
+		if (term.dirty[row])
+			continue;
+		for (int col = 0; col < term.col; ++col) {
+			Glyph *gp = &term.line[row][col];
+			if (gp->mode & ATTR_IMAGE) {
+				uint32_t cell_image_id = tgetimgid(gp);
+				if (cell_image_id == image_id) {
+					term.dirty[row] = 1;
+					break;
+				}
+			}
+		}
+	}
+}
+
 void
 tdeletechar(int n)
 {
@@ -1446,6 +1608,7 @@ tsetattr(const int *attr, int l)
 				ATTR_STRUCK     );
 			term.c.attr.fg = defaultfg;
 			term.c.attr.bg = defaultbg;
+			term.c.attr.decor = DECOR_DEFAULT_COLOR;
 			break;
 		case 1:
 			term.c.attr.mode |= ATTR_BOLD;
@@ -1458,6 +1621,20 @@ tsetattr(const int *attr, int l)
 			break;
 		case 4:
 			term.c.attr.mode |= ATTR_UNDERLINE;
+			if (i + 1 < l) {
+				idx = attr[++i];
+				if (BETWEEN(idx, 1, 5)) {
+					tsetdecorstyle(&term.c.attr, idx);
+				} else if (idx == 0) {
+					term.c.attr.mode &= ~ATTR_UNDERLINE;
+					tsetdecorstyle(&term.c.attr, 0);
+				} else {
+					fprintf(stderr,
+						"erresc: unknown underline "
+						"style %d\n",
+						idx);
+				}
+			}
 			break;
 		case 5: /* slow blink */
 			/* FALLTHROUGH */
@@ -1481,6 +1658,7 @@ tsetattr(const int *attr, int l)
 			break;
 		case 24:
 			term.c.attr.mode &= ~ATTR_UNDERLINE;
+			tsetdecorstyle(&term.c.attr, 0);
 			break;
 		case 25:
 			term.c.attr.mode &= ~ATTR_BLINK;
@@ -1509,10 +1687,11 @@ tsetattr(const int *attr, int l)
 			term.c.attr.bg = defaultbg;
 			break;
 		case 58:
-			/* This starts a sequence to change the color of
-			 * "underline" pixels. We don't support that and
-			 * instead eat up a following "5;n" or "2;r;g;b". */
-			tdefcolor(attr, &i, l);
+			if ((idx = tdefcolor(attr, &i, l)) >= 0)
+				tsetdecorcolor(&term.c.attr, idx);
+			break;
+		case 59:
+			tsetdecorcolor(&term.c.attr, DECOR_DEFAULT_COLOR);
 			break;
 		default:
 			if (BETWEEN(attr[i], 30, 37)) {
@@ -1901,6 +2080,39 @@ csihandle(void)
 			goto unknown;
 		}
 		break;
+	case '>':
+		switch (csiescseq.mode[1]) {
+		case 'q': /* XTVERSION -- Print terminal name and version */
+			len = snprintf(buf, sizeof(buf),
+				       "\033P>|st-graphics(%s)\033\\", VERSION);
+			ttywrite(buf, len, 0);
+			break;
+		default:
+			goto unknown;
+		}
+		break;
+	case 't': /* XTWINOPS -- Window manipulation */
+		switch (csiescseq.arg[0]) {
+		case 14: /* Report text area size in pixels. */
+			len = snprintf(buf, sizeof(buf), "\033[4;%i;%it",
+					term.pixh, term.pixw);
+			ttywrite(buf, len, 0);
+			break;
+		case 16: /* Report character cell size in pixels. */
+			len = snprintf(buf, sizeof(buf), "\033[6;%i;%it",
+					term.pixh / term.row,
+					term.pixw / term.col);
+			ttywrite(buf, len, 0);
+			break;
+		case 18: /* Report the size of the text area in characters. */
+			len = snprintf(buf, sizeof(buf), "\033[8;%i;%it",
+					term.row, term.col);
+			ttywrite(buf, len, 0);
+			break;
+		default:
+			goto unknown;
+		}
+		break;
 	}
 }
 
@@ -2063,8 +2275,27 @@ strhandle(void)
 	case 'k': /* old title set compatibility */
 		xsettitle(strescseq.args[0]);
 		return;
-	case 'P': /* DCS -- Device Control String */
 	case '_': /* APC -- Application Program Command */
+		if (gr_parse_command(strescseq.buf, strescseq.len)) {
+			GraphicsCommandResult *res = &graphics_command_result;
+			if (res->create_placeholder) {
+				tcreateimgplaceholder(
+					res->placeholder.image_id,
+					res->placeholder.placement_id,
+					res->placeholder.columns,
+					res->placeholder.rows,
+					res->placeholder.do_not_move_cursor,
+					res->placeholder.text_underneath);
+			}
+			if (res->response[0])
+				ttywrite(res->response, strlen(res->response),
+					 0);
+			if (res->redraw)
+				tfulldirt();
+			return;
+		}
+		return;
+	case 'P': /* DCS -- Device Control String */
 	case '^': /* PM -- Privacy Message */
 		return;
 	}
@@ -2571,6 +2802,41 @@ check_control_code:
 	if (selected(term.c.x, term.c.y))
 		selclear();
 
+	// wcwidth is broken on some systems, set the width to 0 if it's a known
+	// diacritic used for images.
+	uint16_t num = diacritic_to_num(u);
+	if (num != 0)
+		width = 0;
+	// Set the width to 1 if it's an image placeholder character.
+	if (u == IMAGE_PLACEHOLDER_CHAR || u == IMAGE_PLACEHOLDER_CHAR_OLD)
+		width = 1;
+
+	if (width == 0) {
+		// It's probably a combining char. Combining characters are not
+		// supported, so we just ignore them, unless it denotes the row and
+		// column of an image character.
+		if (term.c.y <= 0 && term.c.x <= 0)
+			return;
+		else if (term.c.x == 0)
+			gp = &term.line[term.c.y-1][term.col-1];
+		else if (term.c.state & CURSOR_WRAPNEXT)
+			gp = &term.line[term.c.y][term.c.x];
+		else
+			gp = &term.line[term.c.y][term.c.x-1];
+		if (num && (gp->mode & ATTR_IMAGE)) {
+			unsigned diaccount = tgetimgdiacriticcount(gp);
+			if (diaccount == 0)
+				tsetimgrow(gp, num);
+			else if (diaccount == 1)
+				tsetimgcol(gp, num);
+			else if (diaccount == 2)
+				tsetimg4thbyteplus1(gp, num);
+			tsetimgdiacriticcount(gp, diaccount + 1);
+		}
+		term.lastc = u;
+		return;
+	}
+
 	gp = &term.line[term.c.y][term.c.x];
 	if (IS_SET(MODE_WRAP) && (term.c.state & CURSOR_WRAPNEXT)) {
 		gp->mode |= ATTR_WRAP;
@@ -2745,6 +3011,8 @@ drawregion(int x1, int y1, int x2, int y2)
 {
 	int y;
 
+	xstartimagedraw(term.dirty, term.row);
+
 	for (y = y1; y < y2; y++) {
 		if (!term.dirty[y])
 			continue;
@@ -2752,6 +3020,8 @@ drawregion(int x1, int y1, int x2, int y2)
 		term.dirty[y] = 0;
 		xdrawline(TLINE(y), x1, y, x2);
 	}
+
+	xfinishimagedraw();
 }
 
 void
@@ -2787,3 +3057,9 @@ redraw(void)
 	tfulldirt();
 	draw();
 }
+
+Glyph
+getglyphat(int col, int row)
+{
+	return term.line[row][col];
+}
diff --git a/st.h b/st.h
index a584b72..d2fe17d 100644
--- a/st.h
+++ b/st.h
@@ -12,7 +12,7 @@
 #define DEFAULT(a, b)		(a) = (a) ? (a) : (b)
 #define LIMIT(x, a, b)		(x) = (x) < (a) ? (a) : (x) > (b) ? (b) : (x)
 #define ATTRCMP(a, b)		((a).mode != (b).mode || (a).fg != (b).fg || \
-				(a).bg != (b).bg)
+				(a).bg != (b).bg || (a).decor != (b).decor)
 #define TIMEDIFF(t1, t2)	((t1.tv_sec-t2.tv_sec)*1000 + \
 				(t1.tv_nsec-t2.tv_nsec)/1E6)
 #define MODBIT(x, set, bit)	((set) ? ((x) |= (bit)) : ((x) &= ~(bit)))
@@ -20,6 +20,10 @@
 #define TRUECOLOR(r,g,b)	(1 << 24 | (r) << 16 | (g) << 8 | (b))
 #define IS_TRUECOL(x)		(1 << 24 & (x))
 
+// This decor color indicates that the fg color should be used. Note that it's
+// not a 24-bit color because the 25-th bit is not set.
+#define DECOR_DEFAULT_COLOR	0x0ffffff
+
 enum glyph_attribute {
 	ATTR_NULL       = 0,
 	ATTR_BOLD       = 1 << 0,
@@ -35,6 +39,7 @@ enum glyph_attribute {
 	ATTR_WDUMMY     = 1 << 10,
 	ATTR_BOXDRAW    = 1 << 11,
 	ATTR_BOLD_FAINT = ATTR_BOLD | ATTR_FAINT,
+	ATTR_IMAGE      = 1 << 14,
 };
 
 enum selection_mode {
@@ -53,6 +58,14 @@ enum selection_snap {
 	SNAP_LINE = 2
 };
 
+enum underline_style {
+	UNDERLINE_STRAIGHT = 1,
+	UNDERLINE_DOUBLE = 2,
+	UNDERLINE_CURLY = 3,
+	UNDERLINE_DOTTED = 4,
+	UNDERLINE_DASHED = 5,
+};
+
 typedef unsigned char uchar;
 typedef unsigned int uint;
 typedef unsigned long ulong;
@@ -66,6 +79,7 @@ typedef struct {
 	ushort mode;      /* attribute flags */
 	uint32_t fg;      /* foreground  */
 	uint32_t bg;      /* background  */
+	uint32_t decor;   /* decoration (like underline) */
 } Glyph;
 
 typedef Glyph *Line;
@@ -109,6 +123,8 @@ void selextend(int, int, int, int);
 int selected(int, int);
 char *getsel(void);
 
+Glyph getglyphat(int, int);
+
 size_t utf8encode(Rune, char *);
 
 void *xmalloc(size_t);
@@ -137,3 +153,69 @@ extern unsigned int defaultfg;
 extern unsigned int defaultbg;
 extern unsigned int defaultcs;
 extern const int boxdraw, boxdraw_bold, boxdraw_braille;
+
+// Accessors to decoration properties stored in `decor`.
+// The 25-th bit is used to indicate if it's a 24-bit color.
+static inline uint32_t tgetdecorcolor(Glyph *g) { return g->decor & 0x1ffffff; }
+static inline uint32_t tgetdecorstyle(Glyph *g) { return (g->decor >> 25) & 0x7; }
+static inline void tsetdecorcolor(Glyph *g, uint32_t color) {
+	g->decor = (g->decor & ~0x1ffffff) | (color & 0x1ffffff);
+}
+static inline void tsetdecorstyle(Glyph *g, uint32_t style) {
+	g->decor = (g->decor & ~(0x7 << 25)) | ((style & 0x7) << 25);
+}
+
+
+// Some accessors to image placeholder properties stored in `u`:
+// - row (1-base) - 9 bits
+// - column (1-base) - 9 bits
+// - most significant byte of the image id plus 1 - 9 bits (0 means unspecified,
+//   don't forget to subtract 1).
+// - the original number of diacritics (0, 1, 2, or 3) - 2 bits
+// - whether this is a classic (1) or Unicode (0) placeholder - 1 bit
+static inline uint32_t tgetimgrow(Glyph *g) { return g->u & 0x1ff; }
+static inline uint32_t tgetimgcol(Glyph *g) { return (g->u >> 9) & 0x1ff; }
+static inline uint32_t tgetimgid4thbyteplus1(Glyph *g) { return (g->u >> 18) & 0x1ff; }
+static inline uint32_t tgetimgdiacriticcount(Glyph *g) { return (g->u >> 27) & 0x3; }
+static inline uint32_t tgetisclassicplaceholder(Glyph *g) { return (g->u >> 29) & 0x1; }
+static inline void tsetimgrow(Glyph *g, uint32_t row) {
+	g->u = (g->u & ~0x1ff) | (row & 0x1ff);
+}
+static inline void tsetimgcol(Glyph *g, uint32_t col) {
+	g->u = (g->u & ~(0x1ff << 9)) | ((col & 0x1ff) << 9);
+}
+static inline void tsetimg4thbyteplus1(Glyph *g, uint32_t byteplus1) {
+	g->u = (g->u & ~(0x1ff << 18)) | ((byteplus1 & 0x1ff) << 18);
+}
+static inline void tsetimgdiacriticcount(Glyph *g, uint32_t count) {
+	g->u = (g->u & ~(0x3 << 27)) | ((count & 0x3) << 27);
+}
+static inline void tsetisclassicplaceholder(Glyph *g, uint32_t isclassic) {
+	g->u = (g->u & ~(0x1 << 29)) | ((isclassic & 0x1) << 29);
+}
+
+/// Returns the full image id. This is a naive implementation, if the most
+/// significant byte is not specified, it's assumed to be 0 instead of inferring
+/// it from the cells to the left.
+static inline uint32_t tgetimgid(Glyph *g) {
+	uint32_t msb = tgetimgid4thbyteplus1(g);
+	if (msb != 0)
+		--msb;
+	return (msb << 24) | (g->fg & 0xFFFFFF);
+}
+
+/// Sets the full image id.
+static inline void tsetimgid(Glyph *g, uint32_t id) {
+	g->fg = (id & 0xFFFFFF) | (1 << 24);
+	tsetimg4thbyteplus1(g, ((id >> 24) & 0xFF) + 1);
+}
+
+static inline uint32_t tgetimgplacementid(Glyph *g) {
+	if (tgetdecorcolor(g) == DECOR_DEFAULT_COLOR)
+		return 0;
+	return g->decor & 0xFFFFFF;
+}
+
+static inline void tsetimgplacementid(Glyph *g, uint32_t id) {
+	g->decor = (id & 0xFFFFFF) | (1 << 24);
+}
diff --git a/st.info b/st.info
index efab2cf..ded76c1 100644
--- a/st.info
+++ b/st.info
@@ -195,6 +195,7 @@ st-mono| simpleterm monocolor,
 	Ms=\E]52;%p1%s;%p2%s\007,
 	Se=\E[2 q,
 	Ss=\E[%p1%d q,
+	Smulx=\E[4:%p1%dm,
 
 st| simpleterm,
 	use=st-mono,
@@ -215,6 +216,11 @@ st-256color| simpleterm with 256 colors,
 	initc=\E]4;%p1%d;rgb\:%p2%{255}%*%{1000}%/%2.2X/%p3%{255}%*%{1000}%/%2.2X/%p4%{255}%*%{1000}%/%2.2X\E\\,
 	setab=\E[%?%p1%{8}%<%t4%p1%d%e%p1%{16}%<%t10%p1%{8}%-%d%e48;5;%p1%d%;m,
 	setaf=\E[%?%p1%{8}%<%t3%p1%d%e%p1%{16}%<%t9%p1%{8}%-%d%e38;5;%p1%d%;m,
+# Underline colors
+	Su,
+	Setulc=\E[58:2:%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m,
+	Setulc1=\E[58:5:%p1%dm,
+	ol=\E[59m,
 
 st-meta| simpleterm with meta key,
 	use=st,
diff --git a/win.h b/win.h
index 6de960d..31b3fff 100644
--- a/win.h
+++ b/win.h
@@ -39,3 +39,6 @@ void xsetpointermotion(int);
 void xsetsel(char *);
 int xstartdraw(void);
 void xximspot(int, int);
+
+void xstartimagedraw(int *dirty, int rows);
+void xfinishimagedraw();
diff --git a/x.c b/x.c
index 6465d81..aa3b294 100644
--- a/x.c
+++ b/x.c
@@ -4,6 +4,8 @@
 #include <limits.h>
 #include <locale.h>
 #include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
 #include <sys/select.h>
 #include <time.h>
 #include <unistd.h>
@@ -19,6 +21,7 @@ char *argv0;
 #include "arg.h"
 #include "st.h"
 #include "win.h"
+#include "graphics.h"
 
 /* types used in config.h */
 typedef struct {
@@ -60,6 +63,12 @@ static void zoom(const Arg *);
 static void zoomabs(const Arg *);
 static void zoomreset(const Arg *);
 static void ttysend(const Arg *);
+static void previewimage(const Arg *);
+static void showimageinfo(const Arg *);
+static void togglegrdebug(const Arg *);
+static void dumpgrstate(const Arg *);
+static void unloadimages(const Arg *);
+static void toggleimages(const Arg *);
 
 /* config.h for applying patches and the configuration. */
 #include "config.h"
@@ -148,6 +157,8 @@ static inline ushort sixd_to_16bit(int);
 static int xmakeglyphfontspecs(XftGlyphFontSpec *, const Glyph *, int, int, int);
 static void xdrawglyphfontspecs(const XftGlyphFontSpec *, Glyph, int, int, int);
 static void xdrawglyph(Glyph, int, int);
+static void xdrawimages(Glyph, Line, int x1, int y1, int x2);
+static void xdrawoneimagecell(Glyph, int x, int y);
 static void xclear(int, int, int, int);
 static int xgeommasktogravity(int);
 static int ximopen(Display *);
@@ -224,6 +235,7 @@ static DC dc;
 static XWindow xw;
 static XSelection xsel;
 static TermWindow win;
+static unsigned int mouse_col = 0, mouse_row = 0;
 
 /* Font Ring Cache */
 enum {
@@ -333,6 +345,68 @@ ttysend(const Arg *arg)
 	ttywrite(arg->s, strlen(arg->s), 1);
 }
 
+void
+previewimage(const Arg *arg)
+{
+	Glyph g = getglyphat(mouse_col, mouse_row);
+	if (g.mode & ATTR_IMAGE) {
+		uint32_t image_id = tgetimgid(&g);
+		fprintf(stderr, "Clicked on placeholder %u/%u, x=%d, y=%d\n",
+			image_id, tgetimgplacementid(&g), tgetimgcol(&g),
+			tgetimgrow(&g));
+		gr_preview_image(image_id, arg->s);
+	}
+}
+
+void
+showimageinfo(const Arg *arg)
+{
+	Glyph g = getglyphat(mouse_col, mouse_row);
+	if (g.mode & ATTR_IMAGE) {
+		uint32_t image_id = tgetimgid(&g);
+		fprintf(stderr, "Clicked on placeholder %u/%u, x=%d, y=%d\n",
+			image_id, tgetimgplacementid(&g), tgetimgcol(&g),
+			tgetimgrow(&g));
+		char stcommand[256] = {0};
+		size_t len = snprintf(stcommand, sizeof(stcommand), "%s -e less", argv0);
+		if (len > sizeof(stcommand) - 1) {
+			fprintf(stderr, "Executable name too long: %s\n",
+				argv0);
+			return;
+		}
+		gr_show_image_info(image_id, tgetimgplacementid(&g),
+				   tgetimgcol(&g), tgetimgrow(&g),
+				   tgetisclassicplaceholder(&g),
+				   tgetimgdiacriticcount(&g), argv0);
+	}
+}
+
+void
+togglegrdebug(const Arg *arg)
+{
+	graphics_debug_mode = (graphics_debug_mode + 1) % 3;
+	redraw();
+}
+
+void
+dumpgrstate(const Arg *arg)
+{
+	gr_dump_state();
+}
+
+void
+unloadimages(const Arg *arg)
+{
+	gr_unload_images_to_reduce_ram();
+}
+
+void
+toggleimages(const Arg *arg)
+{
+	graphics_display_images = !graphics_display_images;
+	redraw();
+}
+
 int
 evcol(XEvent *e)
 {
@@ -457,6 +531,9 @@ mouseaction(XEvent *e, uint release)
 	/* ignore Button<N>mask for Button<N> - it's set on release */
 	uint state = e->xbutton.state & ~buttonmask(e->xbutton.button);
 
+	mouse_col = evcol(e);
+	mouse_row = evrow(e);
+
 	for (ms = mshortcuts; ms < mshortcuts + LEN(mshortcuts); ms++) {
 		if (ms->release == release &&
 		    ms->button == e->xbutton.button &&
@@ -750,6 +827,9 @@ cresize(int width, int height)
 	win.hborderpx = (win.w - col * win.cw) / 2;
 	win.vborderpx = (win.h - row * win.ch) / 2;
 
+	win.hborderpx = (win.w - col * win.cw) * anysize_halign / 100;
+	win.vborderpx = (win.h - row * win.ch) * anysize_valign / 100;
+
 	tresize(col, row);
 	xresize(col, row);
 	ttyresize(win.tw, win.th);
@@ -1035,7 +1115,8 @@ xloadfonts(const char *fontstr, double fontsize)
 			FcPatternAddDouble(pattern, FC_PIXEL_SIZE, 12);
 			usedfontsize = 12;
 		}
-		defaultfontsize = usedfontsize;
+		if (defaultfontsize <= 0)
+			defaultfontsize = usedfontsize;
 	}
 
 	if (xloadfont(&dc.font, pattern))
@@ -1045,7 +1126,7 @@ xloadfonts(const char *fontstr, double fontsize)
 		FcPatternGetDouble(dc.font.match->pattern,
 		                   FC_PIXEL_SIZE, 0, &fontval);
 		usedfontsize = fontval;
-		if (fontsize == 0)
+		if (defaultfontsize <= 0 && fontsize == 0)
 			defaultfontsize = fontval;
 	}
 
@@ -1275,6 +1356,9 @@ xinit(int cols, int rows)
 		xsel.xtarget = XA_STRING;
 
 	boxdraw_xinit(xw.dpy, xw.cmap, xw.draw, xw.vis);
+
+	// Initialize the graphics (image display) module.
+	gr_init(xw.dpy, xw.vis, xw.cmap);
 }
 
 int
@@ -1302,6 +1386,11 @@ xmakeglyphfontspecs(XftGlyphFontSpec *specs, const Glyph *glyphs, int len, int x
 		if (mode == ATTR_WDUMMY)
 			continue;
 
+		/* Draw spaces for image placeholders (images will be drawn
+		 * separately). */
+		if (mode & ATTR_IMAGE)
+			rune = ' ';
+
 		/* Determine font for glyph if different from previous glyph. */
 		if (prevmode != mode) {
 			prevmode = mode;
@@ -1414,6 +1503,56 @@ xmakeglyphfontspecs(XftGlyphFontSpec *specs, const Glyph *glyphs, int len, int x
 	return numspecs;
 }
 
+/* Draws a horizontal dashed line of length `w` starting at `(x, y)`. `wavelen`
+ * is the length of the dash plus the length of the gap. `fraction` is the
+ * fraction of the dash length compared to `wavelen`. */
+static void
+xdrawunderdashed(Draw draw, Color *color, int x, int y, int w,
+		 int wavelen, float fraction, int thick)
+{
+	int dashw = MAX(1, fraction * wavelen);
+	for (int i = x - x % wavelen; i < x + w; i += wavelen) {
+		int startx = MAX(i, x);
+		int endx = MIN(i + dashw, x + w);
+		if (startx < endx)
+			XftDrawRect(xw.draw, color, startx, y, endx - startx,
+				    thick);
+	}
+}
+
+/* Draws an undercurl. `h` is the total height, including line thickness. */
+static void
+xdrawundercurl(Draw draw, Color *color, int x, int y, int w, int h, int thick)
+{
+	XGCValues gcvals = {.foreground = color->pixel,
+			    .line_width = thick,
+			    .line_style = LineSolid,
+			    .cap_style = CapRound};
+	GC gc = XCreateGC(xw.dpy, XftDrawDrawable(xw.draw),
+			  GCForeground | GCLineWidth | GCLineStyle | GCCapStyle,
+			  &gcvals);
+
+	XRectangle clip = {.x = x, .y = y, .width = w, .height = h};
+	XSetClipRectangles(xw.dpy, gc, 0, 0, &clip, 1, Unsorted);
+
+	int yoffset = thick / 2;
+	int segh = MAX(1, h - thick);
+	/* Make sure every segment is at a 45 degree angle, otherwise it doesn't
+	 * look good without antialiasing. */
+	int segw = segh;
+	int wavelen = MAX(1, segw * 2);
+
+	for (int i = x - (x % wavelen); i < x + w; i += wavelen) {
+		XPoint points[3] = {{.x = i, .y = y + yoffset},
+				    {.x = i + segw, .y = y + yoffset + segh},
+				    {.x = i + wavelen, .y = y + yoffset}};
+		XDrawLines(xw.dpy, XftDrawDrawable(xw.draw), gc, points, 3,
+			   CoordModeOrigin);
+	}
+
+	XFreeGC(xw.dpy, gc);
+}
+
 void
 xdrawglyphfontspecs(const XftGlyphFontSpec *specs, Glyph base, int len, int x, int y)
 {
@@ -1527,6 +1666,61 @@ xdrawglyphfontspecs(const XftGlyphFontSpec *specs, Glyph base, int len, int x, i
 	r.width = width;
 	XftDrawSetClipRectangles(xw.draw, winx, winy, &r, 1);
 
+	/* Decoration color. */
+	Color decor;
+	uint32_t decorcolor = tgetdecorcolor(&base);
+	if (decorcolor == DECOR_DEFAULT_COLOR) {
+		decor = *fg;
+	} else if (IS_TRUECOL(decorcolor)) {
+		colfg.alpha = 0xffff;
+		colfg.red = TRUERED(decorcolor);
+		colfg.green = TRUEGREEN(decorcolor);
+		colfg.blue = TRUEBLUE(decorcolor);
+		XftColorAllocValue(xw.dpy, xw.vis, xw.cmap, &colfg, &decor);
+	} else {
+		decor = dc.col[decorcolor];
+	}
+	decor.color.alpha = 0xffff;
+	decor.pixel |= 0xff << 24;
+
+	/* Float thickness, used as a base to compute other values. */
+	float fthick = dc.font.height / 18.0;
+	/* Integer thickness in pixels. Must not be 0. */
+	int thick = MAX(1, roundf(fthick));
+	/* The default gap between the baseline and a single underline. */
+	int gap = roundf(fthick * 2);
+	/* The total thickness of a double underline. */
+	int doubleh = thick * 2 + ceilf(fthick * 0.5);
+	/* The total thickness of an undercurl. */
+	int curlh = thick * 2 + roundf(fthick * 0.75);
+
+	/* Render the underline before the glyphs. */
+	if (base.mode & ATTR_UNDERLINE) {
+		uint32_t style = tgetdecorstyle(&base);
+		int liney = winy + win.cyo + dc.font.ascent * chscale + gap;
+		/* Adjust liney to guarantee that a single underline fits. */
+		liney -= MAX(0, liney + thick - (winy + win.ch));
+		if (style == UNDERLINE_DOUBLE) {
+			liney -= MAX(0, liney + doubleh - (winy + win.ch));
+			XftDrawRect(xw.draw, &decor, winx, liney, width, thick);
+			XftDrawRect(xw.draw, &decor, winx,
+				    liney + doubleh - thick, width, thick);
+		} else if (style == UNDERLINE_DOTTED) {
+			xdrawunderdashed(xw.draw, &decor, winx, liney, width,
+					 thick * 2, 0.5, thick);
+		} else if (style == UNDERLINE_DASHED) {
+			int wavelen = MAX(2, win.cw * 0.9);
+			xdrawunderdashed(xw.draw, &decor, winx, liney, width,
+					 wavelen, 0.65, thick);
+		} else if (style == UNDERLINE_CURLY) {
+			liney -= MAX(0, liney + curlh - (winy + win.ch));
+			xdrawundercurl(xw.draw, &decor, winx, liney, width,
+				       curlh, thick);
+		} else {
+			XftDrawRect(xw.draw, &decor, winx, liney, width, thick);
+		}
+	}
+
 	if (base.mode & ATTR_BOXDRAW) {
 		drawboxes(winx, winy, width / len, win.ch, fg, bg, specs, len);
 	} else {
@@ -1534,15 +1728,10 @@ xdrawglyphfontspecs(const XftGlyphFontSpec *specs, Glyph base, int len, int x, i
 		XftDrawGlyphFontSpec(xw.draw, fg, specs, len);
 	}
 
-	/* Render underline and strikethrough. */
-	if (base.mode & ATTR_UNDERLINE) {
-		XftDrawRect(xw.draw, fg, winx, winy + win.cyo + dc.font.ascent * chscale + 1,
-				width, 1);
-	}
-
+	/* Render strikethrough. Alway use the fg color. */
 	if (base.mode & ATTR_STRUCK) {
 		XftDrawRect(xw.draw, fg, winx, winy + win.cyo + 2 * dc.font.ascent * chscale / 3,
-				width, 1);
+				width, thick);
 	}
 
 	/* Reset clip to none. */
@@ -1557,6 +1746,11 @@ xdrawglyph(Glyph g, int x, int y)
 
 	numspecs = xmakeglyphfontspecs(&spec, &g, 1, x, y);
 	xdrawglyphfontspecs(&spec, g, numspecs, x, y);
+	if (g.mode & ATTR_IMAGE) {
+		gr_start_drawing(xw.buf, win.cw, win.ch);
+		xdrawoneimagecell(g, x, y);
+		gr_finish_drawing(xw.buf);
+	}
 }
 
 void
@@ -1572,6 +1766,10 @@ xdrawcursor(int cx, int cy, Glyph g, int ox, int oy, Glyph og)
 	if (IS_SET(MODE_HIDE))
 		return;
 
+	// If it's an image, just draw a ballot box for simplicity.
+	if (g.mode & ATTR_IMAGE)
+		g.u = 0x2610;
+
 	/*
 	 * Select the right color for the right mode.
 	 */
@@ -1660,6 +1858,134 @@ xdrawcursor(int cx, int cy, Glyph g, int ox, int oy, Glyph og)
 	}
 }
 
+/* Draw (or queue for drawing) image cells between columns x1 and x2 assuming
+ * that they have the same attributes (and thus the same lower 24 bits of the
+ * image ID and the same placement ID). */
+void
+xdrawimages(Glyph base, Line line, int x1, int y1, int x2) {
+	int y_pix = win.vborderpx + y1 * win.ch;
+	uint32_t image_id_24bits = base.fg & 0xFFFFFF;
+	uint32_t placement_id = tgetimgplacementid(&base);
+	// Columns and rows are 1-based, 0 means unspecified.
+	int last_col = 0;
+	int last_row = 0;
+	int last_start_col = 0;
+	int last_start_x = x1;
+	// The most significant byte is also 1-base, subtract 1 before use.
+	uint32_t last_id_4thbyteplus1 = 0;
+	// We may need to inherit row/column/4th byte from the previous cell.
+	Glyph *prev = &line[x1 - 1];
+	if (x1 > 0 && (prev->mode & ATTR_IMAGE) &&
+	    (prev->fg & 0xFFFFFF) == image_id_24bits &&
+	    prev->decor == base.decor) {
+		last_row = tgetimgrow(prev);
+		last_col = tgetimgcol(prev);
+		last_id_4thbyteplus1 = tgetimgid4thbyteplus1(prev);
+		last_start_col = last_col + 1;
+	}
+	for (int x = x1; x < x2; ++x) {
+		Glyph *g = &line[x];
+		uint32_t cur_row = tgetimgrow(g);
+		uint32_t cur_col = tgetimgcol(g);
+		uint32_t cur_id_4thbyteplus1 = tgetimgid4thbyteplus1(g);
+		uint32_t num_diacritics = tgetimgdiacriticcount(g);
+		// If the row is not specified, assume it's the same as the row
+		// of the previous cell. Note that `cur_row` may contain a
+		// value imputed earlier, which will be preserved if `last_row`
+		// is zero (i.e. we don't know the row of the previous cell).
+		if (last_row && (num_diacritics == 0 || !cur_row))
+			cur_row = last_row;
+		// If the column is not specified and the row is the same as the
+		// row of the previous cell, then assume that the column is the
+		// next one.
+		if (last_col && (num_diacritics <= 1 || !cur_col) &&
+		    cur_row == last_row)
+			cur_col = last_col + 1;
+		// If the additional id byte is not specified and the
+		// coordinates are consecutive, assume the byte is also the
+		// same.
+		if (last_id_4thbyteplus1 &&
+		    (num_diacritics <= 2 || !cur_id_4thbyteplus1) &&
+		    cur_row == last_row && cur_col == last_col + 1)
+			cur_id_4thbyteplus1 = last_id_4thbyteplus1;
+		// If we couldn't infer row and column, start from the top left
+		// corner.
+		if (cur_row == 0)
+			cur_row = 1;
+		if (cur_col == 0)
+			cur_col = 1;
+		// If this cell breaks a contiguous stripe of image cells, draw
+		// that line and start a new one.
+		if (cur_col != last_col + 1 || cur_row != last_row ||
+		    cur_id_4thbyteplus1 != last_id_4thbyteplus1) {
+			uint32_t image_id = image_id_24bits;
+			if (last_id_4thbyteplus1)
+				image_id |= (last_id_4thbyteplus1 - 1) << 24;
+			if (last_row != 0) {
+				int x_pix =
+					win.hborderpx + last_start_x * win.cw;
+				gr_append_imagerect(
+					xw.buf, image_id, placement_id,
+					last_start_col - 1, last_col,
+					last_row - 1, last_row, last_start_x,
+					y1, x_pix, y_pix, win.cw, win.ch,
+					base.mode & ATTR_REVERSE);
+			}
+			last_start_col = cur_col;
+			last_start_x = x;
+		}
+		last_row = cur_row;
+		last_col = cur_col;
+		last_id_4thbyteplus1 = cur_id_4thbyteplus1;
+		// Populate the missing glyph data to enable inheritance between
+		// runs and support the naive implementation of tgetimgid.
+		if (!tgetimgrow(g))
+			tsetimgrow(g, cur_row);
+		// We cannot save this information if there are > 511 cols.
+		if (!tgetimgcol(g) && (cur_col & ~0x1ff) == 0)
+			tsetimgcol(g, cur_col);
+		if (!tgetimgid4thbyteplus1(g))
+			tsetimg4thbyteplus1(g, cur_id_4thbyteplus1);
+	}
+	uint32_t image_id = image_id_24bits;
+	if (last_id_4thbyteplus1)
+		image_id |= (last_id_4thbyteplus1 - 1) << 24;
+	// Draw the last contiguous stripe.
+	if (last_row != 0) {
+		int x_pix = win.hborderpx + last_start_x * win.cw;
+		gr_append_imagerect(xw.buf, image_id, placement_id,
+				    last_start_col - 1, last_col, last_row - 1,
+				    last_row, last_start_x, y1, x_pix, y_pix,
+				    win.cw, win.ch, base.mode & ATTR_REVERSE);
+	}
+}
+
+/* Draw just one image cell without inheriting attributes from the left. */
+void xdrawoneimagecell(Glyph g, int x, int y) {
+	if (!(g.mode & ATTR_IMAGE))
+		return;
+	int x_pix = win.hborderpx + x * win.cw;
+	int y_pix = win.vborderpx + y * win.ch;
+	uint32_t row = tgetimgrow(&g) - 1;
+	uint32_t col = tgetimgcol(&g) - 1;
+	uint32_t placement_id = tgetimgplacementid(&g);
+	uint32_t image_id = tgetimgid(&g);
+	gr_append_imagerect(xw.buf, image_id, placement_id, col, col + 1, row,
+			    row + 1, x, y, x_pix, y_pix, win.cw, win.ch,
+			    g.mode & ATTR_REVERSE);
+}
+
+/* Prepare for image drawing. */
+void xstartimagedraw(int *dirty, int rows) {
+	gr_start_drawing(xw.buf, win.cw, win.ch);
+	gr_mark_dirty_animations(dirty, rows);
+}
+
+/* Draw all queued image cells. */
+void xfinishimagedraw() {
+	gr_finish_drawing(xw.buf);
+}
+
 void
 xsetenv(void)
 {
@@ -1726,6 +2052,8 @@ xdrawline(Line line, int x1, int y1, int x2)
 			new.mode ^= ATTR_REVERSE;
 		if (i > 0 && ATTRCMP(base, new)) {
 			xdrawglyphfontspecs(specs, base, i, ox, y1);
+			if (base.mode & ATTR_IMAGE)
+				xdrawimages(base, line, ox, y1, x);
 			specs += i;
 			numspecs -= i;
 			i = 0;
@@ -1738,6 +2066,8 @@ xdrawline(Line line, int x1, int y1, int x2)
 	}
 	if (i > 0)
 		xdrawglyphfontspecs(specs, base, i, ox, y1);
+	if (i > 0 && base.mode & ATTR_IMAGE)
+		xdrawimages(base, line, ox, y1, x);
 }
 
 void
@@ -1965,6 +2295,7 @@ cmessage(XEvent *e)
 		}
 	} else if (e->xclient.data.l[0] == xw.wmdeletewin) {
 		ttyhangup();
+		gr_deinit();
 		exit(0);
 	}
 }
@@ -2015,6 +2346,13 @@ run(void)
 		if (XPending(xw.dpy))
 			timeout = 0;  /* existing events might not set xfd */
 
+		/* Decrease the timeout if there are active animations. */
+		if (graphics_next_redraw_delay != INT_MAX &&
+		    IS_SET(MODE_VISIBLE))
+			timeout = timeout < 0 ? graphics_next_redraw_delay
+					      : MIN(timeout,
+						    graphics_next_redraw_delay);
+
 		seltv.tv_sec = timeout / 1E3;
 		seltv.tv_nsec = 1E6 * (timeout - 1E3 * seltv.tv_sec);
 		tv = timeout >= 0 ? &seltv : NULL;
diff --git a/graphics.c b/graphics.c
new file mode 100644
index 0000000..801f5c9
--- /dev/null
+++ b/graphics.c
@@ -0,0 +1,4393 @@
+/* The MIT License
+
+   Copyright (c) 2021-2024 Sergei Grechanik <sergei.grechanik@gmail.com>
+
+   Permission is hereby granted, free of charge, to any person obtaining
+   a copy of this software and associated documentation files (the
+   "Software"), to deal in the Software without restriction, including
+   without limitation the rights to use, copy, modify, merge, publish,
+   distribute, sublicense, and/or sell copies of the Software, and to
+   permit persons to whom the Software is furnished to do so, subject to
+   the following conditions:
+
+   The above copyright notice and this permission notice shall be
+   included in all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+   EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+   MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+   BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+   ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+   CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+   SOFTWARE.
+*/
+
+////////////////////////////////////////////////////////////////////////////////
+//
+// This file implements a subset of the kitty graphics protocol.
+//
+////////////////////////////////////////////////////////////////////////////////
+
+// A workaround for mac os to enable mkdtemp.
+#ifdef __APPLE__
+#define _DARWIN_C_SOURCE
+#endif
+
+#define _POSIX_C_SOURCE 200809L
+
+#include <zlib.h>
+#include <Imlib2.h>
+#include <X11/Xlib.h>
+#include <X11/extensions/Xrender.h>
+
+#include <assert.h>
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <math.h>
+#include <spawn.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/mman.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "khash.h"
+#include "kvec.h"
+
+#include "st.h"
+#include "graphics.h"
+
+extern char **environ;
+
+#define MAX_FILENAME_SIZE 256
+#define MAX_INFO_LEN 256
+#define MAX_IMAGE_RECTS 20
+
+/// The type used in this file to represent time. Used both for time differences
+/// and absolute times (as milliseconds since an arbitrary point in time, see
+/// `initialization_time`).
+typedef int64_t Milliseconds;
+
+enum ScaleMode {
+	SCALE_MODE_UNSET = 0,
+	/// Stretch or shrink the image to fill the box, ignoring aspect ratio.
+	SCALE_MODE_FILL = 1,
+	/// Preserve aspect ratio and fit to width or to height so that the
+	/// whole image is visible.
+	SCALE_MODE_CONTAIN = 2,
+	/// Do not scale. The image may be cropped if the box is too small.
+	SCALE_MODE_NONE = 3,
+	/// Do not scale, unless the box is too small, in which case the image
+	/// will be shrunk like with `SCALE_MODE_CONTAIN`.
+	SCALE_MODE_NONE_OR_CONTAIN = 4,
+};
+
+enum AnimationState {
+	ANIMATION_STATE_UNSET = 0,
+	/// The animation is stopped. Display the current frame, but don't
+	/// advance to the next one.
+	ANIMATION_STATE_STOPPED = 1,
+	/// Run the animation to then end, then wait for the next frame.
+	ANIMATION_STATE_LOADING = 2,
+	/// Run the animation in a loop.
+	ANIMATION_STATE_LOOPING = 3,
+};
+
+/// The status of an image. Each image uploaded to the terminal is cached on
+/// disk, then it is loaded to ram when needed.
+enum ImageStatus {
+	STATUS_UNINITIALIZED = 0,
+	STATUS_UPLOADING = 1,
+	STATUS_UPLOADING_ERROR = 2,
+	STATUS_UPLOADING_SUCCESS = 3,
+	STATUS_RAM_LOADING_ERROR = 4,
+	STATUS_RAM_LOADING_SUCCESS = 6,
+};
+
+const char *image_status_strings[6] = {
+	"STATUS_UNINITIALIZED",
+	"STATUS_UPLOADING",
+	"STATUS_UPLOADING_ERROR",
+	"STATUS_UPLOADING_SUCCESS",
+	"STATUS_RAM_LOADING_ERROR",
+	"STATUS_RAM_LOADING_SUCCESS",
+};
+
+enum ImageUploadingFailure {
+	ERROR_OVER_SIZE_LIMIT = 1,
+	ERROR_CANNOT_OPEN_CACHED_FILE = 2,
+	ERROR_UNEXPECTED_SIZE = 3,
+	ERROR_CANNOT_COPY_FILE = 4,
+	ERROR_CANNOT_OPEN_SHM = 5,
+	ERROR_MTIME_MISMATCH = 3,
+};
+
+const char *image_uploading_failure_strings[7] = {
+	"NO_ERROR",
+	"ERROR_OVER_SIZE_LIMIT",
+	"ERROR_CANNOT_OPEN_CACHED_FILE",
+	"ERROR_UNEXPECTED_SIZE",
+	"ERROR_CANNOT_COPY_FILE",
+	"ERROR_CANNOT_OPEN_SHM",
+	"ERROR_MTIME_MISMATCH",
+};
+
+////////////////////////////////////////////////////////////////////////////////
+//
+// We use the following structures to represent images and placements:
+//
+//   - Image: this is the main structure representing an image, usually created
+//     by actions 'a=t', 'a=T`. Each image has an id (image id aka client id,
+//     specified by 'i='). An image may have multiple frames (ImageFrame) and
+//     placements (ImagePlacement).
+//
+//   - ImageFrame: represents a single frame of an image, usually created by
+//     the action 'a=f' (and the first frame is created with the image itself).
+//     Each frame has an index and also:
+//     - a file containing the frame data (considered to be "on disk", although
+//       it's probably in tmpfs),
+//     - an imlib object containing the fully composed frame (i.e. the frame
+//       data from the file composed onto the background frame or color). It is
+//       not ready for display yet, because it needs to be scaled and uploaded
+//       to the X server.
+//
+//   - ImagePlacement: represents a placement of an image, created by 'a=p' and
+//     'a=T'. Each placement has an id (placement id, specified by 'p='). Also
+//     each placement has an array of pixmaps: one for each frame of the image.
+//     Each pixmap is a scaled and uploaded image ready to be displayed.
+//
+// Images are store in the `images` hash table, mapping image ids to Image
+// objects (allocated on the heap).
+//
+// Placements are stored in the `placements` hash table of each Image object,
+// mapping placement ids to ImagePlacement objects (also allocated on the heap).
+//
+// ImageFrames are stored in the `first_frame` field and in the
+// `frames_beyond_the_first` array of each Image object. They are stored by
+// value, so ImageFrame pointer may be invalidated when frames are
+// added/deleted, be careful.
+//
+////////////////////////////////////////////////////////////////////////////////
+
+struct Image;
+struct ImageFrame;
+struct ImagePlacement;
+
+KHASH_MAP_INIT_INT(id2image, struct Image *)
+KHASH_MAP_INIT_INT(id2placement, struct ImagePlacement *)
+
+/// A transformation to apply to a pixmap before drawing it.
+typedef struct PixmapTransformation {
+	/// The width and height of the pixmap.
+	int pixmap_w, pixmap_h;
+	/// The width and height of the transformed pixmap.
+	int dst_w, dst_h;
+	/// The offset relative to the top-left corner of the box of cells where
+	/// the transformed pixmap is drawn.
+	int dst_x, dst_y;
+} PixmapTransformation;
+
+typedef struct ImageFrame {
+	/// The image this frame belongs to.
+	struct Image *image;
+	/// The 1-based index of the frame. Zero if the frame isn't initialized.
+	int index;
+	/// The last time when the frame was displayed or otherwise touched.
+	Milliseconds atime;
+	/// The background color of the frame in the 0xRRGGBBAA format.
+	uint32_t background_color;
+	/// The index of the background frame. Zero to use the color instead.
+	int background_frame_index;
+	/// The duration of the frame in milliseconds.
+	int gap;
+	/// The expected size of the frame image file (specified with 'S='),
+	/// used to check if uploading succeeded.
+	unsigned expected_size;
+	/// Format specification (see the `f=` key).
+	int format;
+	/// Pixel width and height of the non-composed (on-disk) frame data. May
+	/// differ from the image (i.e. first frame) dimensions.
+	int data_pix_width, data_pix_height;
+	/// The offset of the frame relative to the first frame.
+	int x, y;
+	/// Compression mode (see the `o=` key).
+	char compression;
+	/// The status (see `ImageStatus`).
+	char status;
+	/// Whether loading into ram is in progress. This is used to avoid
+	/// cyclic dependencies between frames.
+	char ram_loading_in_progress;
+	/// The reason of uploading failure (see `ImageUploadingFailure`).
+	char uploading_failure;
+	/// Whether failures and successes should be reported ('q=').
+	char quiet;
+	/// Whether to blend the frame with the background or replace it.
+	char blend;
+	/// The original file name used with file transmission. Malloced.
+	char *original_filename;
+	/// The modification time of the original file used with file
+	/// transmission.
+	time_t original_file_mtime;
+	/// The file corresponding to the on-disk cache, used when uploading.
+	FILE *open_file;
+	/// The size of the corresponding file cached on disk.
+	unsigned disk_size;
+	/// The imlib object containing the fully composed frame. It's not
+	/// scaled for screen display yet.
+	Imlib_Image imlib_object;
+} ImageFrame;
+
+typedef struct Image {
+	/// The client id (the one specified with 'i='). Must be nonzero.
+	uint32_t image_id;
+	/// The client id specified in the query command (`a=q`). This one must
+	/// be used to create the response if it's non-zero.
+	uint32_t query_id;
+	/// The number specified in the transmission command (`I=`). If
+	/// non-zero, it may be used to identify the image instead of the
+	/// image_id, and it also should be mentioned in responses.
+	uint32_t image_number;
+	/// The last time when the image was displayed or otherwise touched.
+	Milliseconds atime;
+	/// The total duration of the animation in milliseconds.
+	int total_duration;
+	/// The total size of cached image files for all frames.
+	int total_disk_size;
+	/// The global index of the creation command. Used to decide which image
+	/// is newer if they have the same image number.
+	uint64_t global_command_index;
+	/// The 1-based index of the currently displayed frame.
+	int current_frame;
+	/// The state of the animation, see `AnimationState`.
+	char animation_state;
+	/// The absolute time that is assumed to be the start of the current
+	/// frame (in ms since initialization).
+	Milliseconds current_frame_time;
+	/// The absolute time of the last redraw (in ms since initialization).
+	/// Used to check whether it's the first time we draw the image in the
+	/// current redraw cycle.
+	Milliseconds last_redraw;
+	/// The absolute time of the next redraw (in ms since initialization).
+	/// 0 means no redraw is scheduled.
+	Milliseconds next_redraw;
+	/// The unscaled pixel width and height of the image. Usually inherited
+	/// from the first frame.
+	int pix_width, pix_height;
+	/// The first frame.
+	ImageFrame first_frame;
+	/// The array of frames beyond the first one.
+	kvec_t(ImageFrame) frames_beyond_the_first;
+	/// Image placements.
+	khash_t(id2placement) *placements;
+	/// The default placement.
+	uint32_t default_placement;
+	/// The initial placement id, specified with the transmission command,
+	/// used to report success or failure.
+	uint32_t initial_placement_id;
+} Image;
+
+typedef struct ImagePlacement {
+	/// The image this placement belongs to.
+	Image *image;
+	/// The id of the placement. Must be nonzero.
+	uint32_t placement_id;
+	/// The last time when the placement was displayed or otherwise touched.
+	Milliseconds atime;
+	/// The 1-based index of the protected pixmap. We protect a pixmap in
+	/// gr_load_pixmap to avoid unloading it right after it was loaded.
+	int protected_frame;
+	/// Whether the placement is used only for Unicode placeholders.
+	char virtual;
+	/// The scaling mode (see `ScaleMode`).
+	char scale_mode;
+	/// Height and width in cells.
+	uint16_t rows, cols;
+	/// Top-left corner of the source rectangle ('x=' and 'y=').
+	int src_pix_x, src_pix_y;
+	/// Height and width of the source rectangle (zero if full image).
+	int src_pix_width, src_pix_height;
+	/// The image appropriately scaled and uploaded to the X server. This
+	/// pixmap is premultiplied by alpha.
+	Pixmap first_pixmap;
+	/// The array of pixmaps beyond the first one.
+	kvec_t(Pixmap) pixmaps_beyond_the_first;
+	/// The dimensions of the cell used to scale the image. If cell
+	/// dimensions are changed (font change), the image will be rescaled.
+	uint16_t scaled_cw, scaled_ch;
+	/// The transformation to apply to the pixmap before drawing it.
+	PixmapTransformation pixmap_transformation;
+	/// If true, do not move the cursor when displaying this placement
+	/// (non-virtual placements only).
+	char do_not_move_cursor;
+	/// The text underneath this placement, valid only for classic
+	/// placements. On deletion, the text is restored. This is a malloced
+	/// array of rows*cols Glyphs.
+	Glyph *text_underneath;
+} ImagePlacement;
+
+/// A rectangular piece of an image to be drawn.
+typedef struct {
+	uint32_t image_id;
+	uint32_t placement_id;
+	/// The position of the rectangle in pixels.
+	int screen_x_pix, screen_y_pix;
+	/// The starting row on the screen.
+	int screen_y_row;
+	/// The part of the whole image to be drawn, in cells. Starts are
+	/// zero-based, ends are exclusive.
+	int img_start_col, img_end_col, img_start_row, img_end_row;
+	/// The current cell width and height in pixels.
+	int cw, ch;
+	/// Whether colors should be inverted.
+	int reverse;
+} ImageRect;
+
+/// Executes `code` for each frame of an image. Example:
+///
+///     foreach_frame(image, frame, {
+///         printf("Frame %d\n", frame->index);
+///     });
+///
+#define foreach_frame(image, framevar, code) { size_t __i; \
+	for (__i = 0; __i <= kv_size((image).frames_beyond_the_first); ++__i) { \
+		ImageFrame *framevar = \
+			__i == 0 ? &(image).first_frame \
+			: &kv_A((image).frames_beyond_the_first, __i - 1); \
+		code; \
+	} }
+
+/// Executes `code` for each pixmap of a placement. Example:
+///
+///     foreach_pixmap(placement, pixmap, {
+///         ...
+///     });
+///
+#define foreach_pixmap(placement, pixmapvar, code) { size_t __i; \
+	for (__i = 0; __i <= kv_size((placement).pixmaps_beyond_the_first); ++__i) { \
+		Pixmap pixmapvar = \
+			__i == 0 ? (placement).first_pixmap \
+			: kv_A((placement).pixmaps_beyond_the_first, __i - 1); \
+		code; \
+	} }
+
+
+static Image *gr_find_image(uint32_t image_id);
+static void gr_get_frame_filename(ImageFrame *frame, char *out, size_t max_len);
+static void gr_delete_image(Image *img);
+static void gr_erase_placement(ImagePlacement *placement);
+static void gr_check_limits();
+static void gr_try_restore_imagefile(ImageFrame *frame);
+static char *gr_base64dec(const char *src, size_t *size);
+static void sanitize_str(char *str, size_t max_len);
+static const char *sanitized_filename(const char *str);
+
+/// The array of image rectangles to draw. It is reset each frame.
+static ImageRect image_rects[MAX_IMAGE_RECTS] = {{0}};
+/// The known images (including the ones being uploaded).
+static khash_t(id2image) *images = NULL;
+/// The total number of placements in all images.
+static unsigned total_placement_count = 0;
+/// The total size of all image files stored in the on-disk cache.
+static int64_t images_disk_size = 0;
+/// The total size of all images and placements loaded into ram.
+static int64_t images_ram_size = 0;
+/// The id of the last loaded image.
+static uint32_t last_image_id = 0;
+/// Current cell width and heigh in pixels.
+static int current_cw = 0, current_ch = 0;
+/// The id of the currently uploaded image (when using direct uploading).
+static uint32_t current_upload_image_id = 0;
+/// The index of the frame currently being uploaded.
+static int current_upload_frame_index = 0;
+/// The time when the graphics module was initialized.
+static struct timespec initialization_time = {0};
+/// The time when the current frame drawing started, used for debugging fps and
+/// to calculate the current frame for animations.
+static Milliseconds drawing_start_time;
+/// The global index of the current command.
+static uint64_t global_command_counter = 0;
+/// The next redraw times for each row of the terminal. Used for animations.
+/// 0 means no redraw is scheduled.
+static kvec_t(Milliseconds) next_redraw_times = {0, 0, NULL};
+/// The number of files loaded in the current redraw cycle or command execution.
+static int debug_loaded_files_counter = 0;
+/// The number of pixmaps loaded in the current redraw cycle or command execution.
+static int debug_loaded_pixmaps_counter = 0;
+
+/// The directory where the cache files are stored.
+static char cache_dir[MAX_FILENAME_SIZE - 16];
+
+/// The table used for color inversion.
+static unsigned char reverse_table[256];
+
+// Declared in the header.
+GraphicsDebugMode graphics_debug_mode = GRAPHICS_DEBUG_NONE;
+char graphics_display_images = 1;
+GraphicsCommandResult graphics_command_result = {0};
+int graphics_next_redraw_delay = INT_MAX;
+
+// Defined in config.h
+extern const char graphics_cache_dir_template[];
+extern unsigned graphics_max_single_image_file_size;
+extern unsigned graphics_total_file_cache_size;
+extern unsigned graphics_max_single_image_ram_size;
+extern unsigned graphics_max_total_ram_size;
+extern unsigned graphics_max_total_placements;
+extern double graphics_excess_tolerance_ratio;
+extern unsigned graphics_animation_min_delay;
+
+// Constants that are not important enough to expose in the config.
+
+/// The time after which an interrupted (with another command) direct
+/// transmission cannot be resumed.
+static Milliseconds graphics_direct_transmission_timeout_ms = 2000;
+
+////////////////////////////////////////////////////////////////////////////////
+// Basic helpers.
+////////////////////////////////////////////////////////////////////////////////
+
+#define MIN(a, b)		((a) < (b) ? (a) : (b))
+#define MAX(a, b)		((a) < (b) ? (b) : (a))
+
+/// Returns the difference between `end` and `start` in milliseconds.
+static int64_t gr_timediff_ms(const struct timespec *end,
+			      const struct timespec *start) {
+	return (end->tv_sec - start->tv_sec) * 1000 +
+	       (end->tv_nsec - start->tv_nsec) / 1000000;
+}
+
+/// Returns the current time in milliseconds since the initialization.
+static Milliseconds gr_now_ms() {
+	struct timespec now;
+	clock_gettime(CLOCK_MONOTONIC, &now);
+	return gr_timediff_ms(&now, &initialization_time);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Logging.
+////////////////////////////////////////////////////////////////////////////////
+
+#define GR_LOG(...) \
+	do { if(graphics_debug_mode) fprintf(stderr, __VA_ARGS__); } while(0)
+
+////////////////////////////////////////////////////////////////////////////////
+// Basic image management functions (create, delete, find, etc).
+////////////////////////////////////////////////////////////////////////////////
+
+/// Returns the 1-based index of the last frame. Note that you may want to use
+/// `gr_last_uploaded_frame_index` instead since the last frame may be not
+/// fully uploaded yet.
+static inline int gr_last_frame_index(Image *img) {
+	return kv_size(img->frames_beyond_the_first) + 1;
+}
+
+/// Returns the frame with the given index. Returns NULL if the index is out of
+/// bounds. The index is 1-based.
+static ImageFrame *gr_get_frame(Image *img, int index) {
+	if (!img)
+		return NULL;
+	if (index == 1)
+		return &img->first_frame;
+	if (2 <= index && index <= gr_last_frame_index(img))
+		return &kv_A(img->frames_beyond_the_first, index - 2);
+	return NULL;
+}
+
+/// Returns the last frame of the image. Returns NULL if `img` is NULL.
+static ImageFrame *gr_get_last_frame(Image *img) {
+	if (!img)
+		return NULL;
+	return gr_get_frame(img, gr_last_frame_index(img));
+}
+
+/// Returns the 1-based index of the last frame or the second-to-last frame if
+/// the last frame is not fully uploaded yet.
+static inline int gr_last_uploaded_frame_index(Image *img) {
+	int last_index = gr_last_frame_index(img);
+	if (last_index > 1 &&
+	    gr_get_frame(img, last_index)->status < STATUS_UPLOADING_SUCCESS)
+		return last_index - 1;
+	return last_index;
+}
+
+/// Returns the pixmap for the frame with the given index. Returns 0 if the
+/// index is out of bounds. The index is 1-based.
+static Pixmap gr_get_frame_pixmap(ImagePlacement *placement, int index) {
+	if (index == 1)
+		return placement->first_pixmap;
+	if (2 <= index &&
+	    index <= kv_size(placement->pixmaps_beyond_the_first) + 1)
+		return kv_A(placement->pixmaps_beyond_the_first, index - 2);
+	return 0;
+}
+
+/// Sets the pixmap for the frame with the given index. The index is 1-based.
+/// The array of pixmaps is resized if needed.
+static void gr_set_frame_pixmap(ImagePlacement *placement, int index,
+				Pixmap pixmap) {
+	if (index == 1) {
+		placement->first_pixmap = pixmap;
+		return;
+	}
+	// Resize the array if needed.
+	size_t old_size = kv_size(placement->pixmaps_beyond_the_first);
+	if (old_size < index - 1) {
+		kv_a(Pixmap, placement->pixmaps_beyond_the_first, index - 2);
+		for (size_t i = old_size; i < index - 1; i++)
+			kv_A(placement->pixmaps_beyond_the_first, i) = 0;
+	}
+	kv_A(placement->pixmaps_beyond_the_first, index - 2) = pixmap;
+}
+
+/// Finds the image corresponding to the client id. Returns NULL if cannot find.
+static Image *gr_find_image(uint32_t image_id) {
+	khiter_t k = kh_get(id2image, images, image_id);
+	if (k == kh_end(images))
+		return NULL;
+	Image *res = kh_value(images, k);
+	return res;
+}
+
+/// Finds the newest image corresponding to the image number. Returns NULL if
+/// cannot find.
+static Image *gr_find_image_by_number(uint32_t image_number) {
+	if (image_number == 0)
+		return NULL;
+	Image *newest_img = NULL;
+	Image *img = NULL;
+	kh_foreach_value(images, img, {
+		if (img->image_number == image_number &&
+		    (!newest_img || newest_img->global_command_index <
+					    img->global_command_index))
+			newest_img = img;
+	});
+	if (!newest_img)
+		GR_LOG("Image number %u not found\n", image_number);
+	else
+		GR_LOG("Found image number %u, its id is %u\n", image_number,
+		       img->image_id);
+	return newest_img;
+}
+
+/// Finds the placement corresponding to the id. If the placement id is 0,
+/// returns some default placement.
+static ImagePlacement *gr_find_placement(Image *img, uint32_t placement_id) {
+	if (!img)
+		return NULL;
+	if (placement_id == 0) {
+		// Try to get the default placement.
+		ImagePlacement *dflt = NULL;
+		if (img->default_placement != 0)
+			dflt = gr_find_placement(img, img->default_placement);
+		if (dflt)
+			return dflt;
+		// If there is no default placement, return the first one and
+		// set it as the default.
+		kh_foreach_value(img->placements, dflt, {
+			img->default_placement = dflt->placement_id;
+			return dflt;
+		});
+		// If there are no placements, return NULL.
+		return NULL;
+	}
+	khiter_t k = kh_get(id2placement, img->placements, placement_id);
+	if (k == kh_end(img->placements))
+		return NULL;
+	ImagePlacement *res = kh_value(img->placements, k);
+	return res;
+}
+
+/// Finds the placement by image id and placement id.
+static ImagePlacement *gr_find_image_and_placement(uint32_t image_id,
+						   uint32_t placement_id) {
+	return gr_find_placement(gr_find_image(image_id), placement_id);
+}
+
+/// Returns a pointer to the glyph under the classic placement with `image_id`
+/// and `placement_id` at `col` and `row` (1-based). May return NULL if the
+/// underneath text is unknown.
+Glyph *gr_get_glyph_underneath_image(uint32_t image_id, uint32_t placement_id,
+				     int col, int row) {
+	ImagePlacement *placement =
+		gr_find_image_and_placement(image_id, placement_id);
+	if (!placement || !placement->text_underneath)
+		return NULL;
+	col--;
+	row--;
+	if (col < 0 || col >= placement->cols || row < 0 ||
+	    row >= placement->rows)
+		return NULL;
+	return &placement->text_underneath[row * placement->cols + col];
+}
+
+/// Writes the name of the on-disk cache file to `out`. `max_len` should be the
+/// size of `out`. The name will be something like
+/// "/tmp/st-images-xxx/img-ID-FRAME".
+static void gr_get_frame_filename(ImageFrame *frame, char *out,
+				  size_t max_len) {
+	snprintf(out, max_len, "%s/img-%.3u-%.3u", cache_dir,
+		 frame->image->image_id, frame->index);
+}
+
+/// Returns the (estimation) of the RAM size used by the frame right now.
+static unsigned gr_frame_current_ram_size(ImageFrame *frame) {
+	if (!frame->imlib_object)
+		return 0;
+	return (unsigned)frame->image->pix_width * frame->image->pix_height * 4;
+}
+
+/// Returns the (estimation) of the RAM size used by a single frame pixmap.
+static unsigned gr_placement_single_frame_ram_size(ImagePlacement *placement) {
+	return (unsigned)placement->pixmap_transformation.pixmap_w *
+	       placement->pixmap_transformation.pixmap_h * 4;
+}
+
+/// Returns the (estimation) of the RAM size used by the placemenet right now.
+static unsigned gr_placement_current_ram_size(ImagePlacement *placement) {
+	unsigned single_frame_size =
+		gr_placement_single_frame_ram_size(placement);
+	unsigned result = 0;
+	foreach_pixmap(*placement, pixmap, {
+		if (pixmap)
+			result += single_frame_size;
+	});
+	return result;
+}
+
+/// Unload the frame from RAM (i.e. delete the corresponding imlib object).
+/// If the on-disk file of the frame is preserved, it can be reloaded later.
+static void gr_unload_frame(ImageFrame *frame) {
+	if (!frame->imlib_object)
+		return;
+
+	unsigned frame_ram_size = gr_frame_current_ram_size(frame);
+	images_ram_size -= frame_ram_size;
+
+	imlib_context_set_image(frame->imlib_object);
+	imlib_free_image_and_decache();
+	frame->imlib_object = NULL;
+
+	GR_LOG("After unloading image %u frame %u (atime %ld ms ago) "
+	       "ram: %ld KiB  (- %u KiB)\n",
+	       frame->image->image_id, frame->index,
+	       drawing_start_time - frame->atime, images_ram_size / 1024,
+	       frame_ram_size / 1024);
+}
+
+/// Unload all frames of the image.
+static void gr_unload_all_frames(Image *img) {
+	foreach_frame(*img, frame, {
+		gr_unload_frame(frame);
+	});
+}
+
+/// Unload the placement from RAM (i.e. free all of the corresponding pixmaps).
+/// If the on-disk files or imlib objects of the corresponding image are
+/// preserved, the placement can be reloaded later.
+static void gr_unload_placement(ImagePlacement *placement) {
+	unsigned placement_ram_size = gr_placement_current_ram_size(placement);
+	images_ram_size -= placement_ram_size;
+
+	Display *disp = imlib_context_get_display();
+	foreach_pixmap(*placement, pixmap, {
+		if (pixmap)
+			XFreePixmap(disp, pixmap);
+	});
+
+	placement->first_pixmap = 0;
+	placement->pixmaps_beyond_the_first.n = 0;
+	placement->scaled_ch = placement->scaled_cw = 0;
+
+	GR_LOG("After unloading placement %u/%u (atime %ld ms ago) "
+	       "ram: %ld KiB  (- %u KiB)\n",
+	       placement->image->image_id, placement->placement_id,
+	       drawing_start_time - placement->atime, images_ram_size / 1024,
+	       placement_ram_size / 1024);
+}
+
+/// Unload a single pixmap of the placement from RAM.
+static void gr_unload_pixmap(ImagePlacement *placement, int frameidx) {
+	Pixmap pixmap = gr_get_frame_pixmap(placement, frameidx);
+	if (!pixmap)
+		return;
+
+	Display *disp = imlib_context_get_display();
+	XFreePixmap(disp, pixmap);
+	gr_set_frame_pixmap(placement, frameidx, 0);
+	images_ram_size -= gr_placement_single_frame_ram_size(placement);
+
+	GR_LOG("After unloading pixmap %ld of "
+	       "placement %u/%u (atime %ld ms ago) "
+	       "frame %u (atime %ld ms ago) "
+	       "ram: %ld KiB  (- %u KiB)\n",
+	       pixmap, placement->image->image_id, placement->placement_id,
+	       drawing_start_time - placement->atime, frameidx,
+	       drawing_start_time -
+		       gr_get_frame(placement->image, frameidx)->atime,
+	       images_ram_size / 1024,
+	       gr_placement_single_frame_ram_size(placement) / 1024);
+}
+
+/// Closes the on-disk cache file of the frame `frame`.
+static void gr_close_disk_cache_file(ImageFrame *frame) {
+	if (frame && frame->open_file) {
+		fclose(frame->open_file);
+		frame->open_file = NULL;
+	}
+}
+
+/// Deletes the on-disk cache file corresponding to the frame. The in-ram image
+/// object (if it exists) is not deleted, placements are not unloaded either.
+static void gr_delete_imagefile(ImageFrame *frame) {
+	// It may still be being loaded. Close the file in this case.
+	gr_close_disk_cache_file(frame);
+
+	if (frame->disk_size == 0)
+		return;
+
+	char filename[MAX_FILENAME_SIZE];
+	gr_get_frame_filename(frame, filename, MAX_FILENAME_SIZE);
+	remove(filename);
+
+	unsigned disk_size = frame->disk_size;
+	images_disk_size -= disk_size;
+	frame->image->total_disk_size -= disk_size;
+	frame->disk_size = 0;
+
+	GR_LOG("After deleting image file %u frame %u (atime %ld ms ago) "
+	       "disk: %ld KiB  (- %u KiB)\n",
+	       frame->image->image_id, frame->index,
+	       drawing_start_time - frame->atime, images_disk_size / 1024,
+	       disk_size / 1024);
+}
+
+/// Deletes all on-disk cache files of the image (for each frame).
+static void gr_delete_imagefiles(Image *img) {
+	foreach_frame(*img, frame, {
+		gr_delete_imagefile(frame);
+	});
+}
+
+/// Deletes the given placement: unloads, frees the object, erases it from the
+/// screen in the classic case, but doesn't change the `placements` hash table.
+static void gr_delete_placement_keep_id(ImagePlacement *placement) {
+	if (!placement)
+		return;
+	GR_LOG("Deleting placement %u/%u\n", placement->image->image_id,
+	       placement->placement_id);
+	// Erase the placement from the screen if it's classic and there is some
+	// saved text underneath.
+	if (placement->text_underneath && !placement->virtual)
+		gr_erase_placement(placement);
+	gr_unload_placement(placement);
+	kv_destroy(placement->pixmaps_beyond_the_first);
+	free(placement->text_underneath);
+	free(placement);
+	total_placement_count--;
+}
+
+/// Deletes all placements of `img`.
+static void gr_delete_all_placements(Image *img) {
+	ImagePlacement *placement = NULL;
+	kh_foreach_value(img->placements, placement, {
+		gr_delete_placement_keep_id(placement);
+	});
+	kh_clear(id2placement, img->placements);
+}
+
+/// Deletes the given image: unloads, deletes the file, frees the Image object,
+/// but doesn't change the `images` hash table.
+static void gr_delete_image_keep_id(Image *img) {
+	if (!img)
+		return;
+	GR_LOG("Deleting image %u\n", img->image_id);
+	foreach_frame(*img, frame, {
+		gr_delete_imagefile(frame);
+		gr_unload_frame(frame);
+		if (frame->original_filename)
+			free(frame->original_filename);
+	});
+	kv_destroy(img->frames_beyond_the_first);
+	gr_delete_all_placements(img);
+	kh_destroy(id2placement, img->placements);
+	free(img);
+}
+
+/// Deletes the given image: unloads, deletes the file, frees the Image object,
+/// and also removes it from `images`.
+static void gr_delete_image(Image *img) {
+	if (!img)
+		return;
+	uint32_t id = img->image_id;
+	gr_delete_image_keep_id(img);
+	khiter_t k = kh_get(id2image, images, id);
+	kh_del(id2image, images, k);
+}
+
+/// Deletes the given placement: unloads, frees the object, erases from the
+/// screen (in the classic case), and also removes it from `placements`.
+static void gr_delete_placement(ImagePlacement *placement) {
+	if (!placement)
+		return;
+	uint32_t id = placement->placement_id;
+	Image *img = placement->image;
+	gr_delete_placement_keep_id(placement);
+	khiter_t k = kh_get(id2placement, img->placements, id);
+	kh_del(id2placement, img->placements, k);
+}
+
+/// Deletes all images and clears `images`.
+static void gr_delete_all_images() {
+	Image *img = NULL;
+	kh_foreach_value(images, img, {
+		gr_delete_image_keep_id(img);
+	});
+	kh_clear(id2image, images);
+}
+
+/// Update the atime of the image.
+static void gr_touch_image(Image *img) {
+	img->atime = gr_now_ms();
+}
+
+/// Update the atime of the frame.
+static void gr_touch_frame(ImageFrame *frame) {
+	frame->image->atime = frame->atime = gr_now_ms();
+}
+
+/// Update the atime of the placement. Touches the images too.
+static void gr_touch_placement(ImagePlacement *placement) {
+	placement->image->atime = placement->atime = gr_now_ms();
+}
+
+/// Creates a new image with the given id. If an image with that id already
+/// exists, it is deleted first. If the provided id is 0, generates a
+/// random id.
+static Image *gr_new_image(uint32_t id) {
+	if (id == 0) {
+		do {
+			id = rand();
+			// Avoid IDs that don't need full 32 bits.
+		} while ((id & 0xFF000000) == 0 || (id & 0x00FFFF00) == 0 ||
+			 gr_find_image(id));
+		GR_LOG("Generated random image id %u\n", id);
+	}
+	Image *img = gr_find_image(id);
+	gr_delete_image_keep_id(img);
+	GR_LOG("Creating image %u\n", id);
+	img = malloc(sizeof(Image));
+	memset(img, 0, sizeof(Image));
+	img->placements = kh_init(id2placement);
+	int ret;
+	khiter_t k = kh_put(id2image, images, id, &ret);
+	kh_value(images, k) = img;
+	img->image_id = id;
+	gr_touch_image(img);
+	img->global_command_index = global_command_counter;
+	return img;
+}
+
+/// Creates a new frame at the end of the frame array. It may be the first frame
+/// if there are no frames yet.
+static ImageFrame *gr_append_new_frame(Image *img) {
+	ImageFrame *frame = NULL;
+	if (img->first_frame.index == 0 &&
+	    kv_size(img->frames_beyond_the_first) == 0) {
+		frame = &img->first_frame;
+		frame->index = 1;
+	} else {
+		frame = kv_pushp(ImageFrame, img->frames_beyond_the_first);
+		memset(frame, 0, sizeof(ImageFrame));
+		frame->index = kv_size(img->frames_beyond_the_first) + 1;
+	}
+	frame->image = img;
+	gr_touch_frame(frame);
+	GR_LOG("Appending frame %d to image %u\n", frame->index, img->image_id);
+	return frame;
+}
+
+/// Creates a new placement with the given id. If a placement with that id
+/// already exists, it is deleted first. If the provided id is 0, generates a
+/// random id.
+static ImagePlacement *gr_new_placement(Image *img, uint32_t id) {
+	if (id == 0) {
+		do {
+			// Currently we support only 24-bit IDs.
+			id = rand() & 0xFFFFFF;
+			// Avoid IDs that need only one byte.
+		} while ((id & 0x00FFFF00) == 0 || gr_find_placement(img, id));
+	}
+	ImagePlacement *placement = gr_find_placement(img, id);
+	gr_delete_placement_keep_id(placement);
+	GR_LOG("Creating placement %u/%u\n", img->image_id, id);
+	placement = malloc(sizeof(ImagePlacement));
+	memset(placement, 0, sizeof(ImagePlacement));
+	total_placement_count++;
+	int ret;
+	khiter_t k = kh_put(id2placement, img->placements, id, &ret);
+	kh_value(img->placements, k) = placement;
+	placement->image = img;
+	placement->placement_id = id;
+	gr_touch_placement(placement);
+	if (img->default_placement == 0)
+		img->default_placement = id;
+	return placement;
+}
+
+static int64_t ceil_div(int64_t a, int64_t b) {
+	return (a + b - 1) / b;
+}
+
+/// Computes the best number of rows and columns for a placement if it's not
+/// specified, and also adjusts the source rectangle size.
+static void gr_infer_placement_size_maybe(ImagePlacement *placement) {
+	// The size of the image.
+	int image_pix_width = placement->image->pix_width;
+	int image_pix_height = placement->image->pix_height;
+	// Negative values are not allowed. Quietly set them to 0.
+	if (placement->src_pix_x < 0)
+		placement->src_pix_x = 0;
+	if (placement->src_pix_y < 0)
+		placement->src_pix_y = 0;
+	if (placement->src_pix_width < 0)
+		placement->src_pix_width = 0;
+	if (placement->src_pix_height < 0)
+		placement->src_pix_height = 0;
+	// If the source rectangle is outside the image, truncate it.
+	if (placement->src_pix_x > image_pix_width)
+		placement->src_pix_x = image_pix_width;
+	if (placement->src_pix_y > image_pix_height)
+		placement->src_pix_y = image_pix_height;
+	// If the source rectangle is not specified, use the whole image. If
+	// it's partially outside the image, truncate it.
+	if (placement->src_pix_width == 0 ||
+	    placement->src_pix_x + placement->src_pix_width > image_pix_width)
+		placement->src_pix_width =
+			image_pix_width - placement->src_pix_x;
+	if (placement->src_pix_height == 0 ||
+	    placement->src_pix_y + placement->src_pix_height > image_pix_height)
+		placement->src_pix_height =
+			image_pix_height - placement->src_pix_y;
+
+	if (placement->cols != 0 && placement->rows != 0)
+		return;
+	if (placement->src_pix_width == 0 || placement->src_pix_height == 0)
+		return;
+	if (current_cw == 0 || current_ch == 0)
+		return;
+
+	// If no size is specified, use the image size.
+	if (placement->cols == 0 && placement->rows == 0) {
+		placement->cols =
+			ceil_div(placement->src_pix_width, current_cw);
+		placement->rows =
+			ceil_div(placement->src_pix_height, current_ch);
+		return;
+	}
+
+	// Some applications specify only one of the dimensions.
+	if (placement->scale_mode == SCALE_MODE_CONTAIN) {
+		// If we preserve aspect ratio and fit to width/height, the most
+		// logical thing is to find the minimum size of the
+		// non-specified dimension that allows the image to fit the
+		// specified dimension.
+		if (placement->cols == 0) {
+			placement->cols = ceil_div(
+				placement->src_pix_width * placement->rows *
+					current_ch,
+				placement->src_pix_height * current_cw);
+			return;
+		}
+		if (placement->rows == 0) {
+			placement->rows =
+				ceil_div(placement->src_pix_height *
+						 placement->cols * current_cw,
+					 placement->src_pix_width * current_ch);
+			return;
+		}
+	} else {
+		// Otherwise we stretch the image or preserve the original size.
+		// In both cases we compute the best number of columns from the
+		// pixel size and cell size.
+		// TODO: In the case of stretching it's not the most logical
+		//       thing to do, may need to revisit in the future.
+		//       Currently we switch to SCALE_MODE_CONTAIN when only one
+		//       of the dimensions is specified, so this case shouldn't
+		//       happen in practice.
+		if (!placement->cols)
+			placement->cols =
+				ceil_div(placement->src_pix_width, current_cw);
+		if (!placement->rows)
+			placement->rows =
+				ceil_div(placement->src_pix_height, current_ch);
+	}
+}
+
+/// Adjusts the current frame index if enough time has passed since the display
+/// of the current frame. Also computes the time of the next redraw of this
+/// image (`img->next_redraw`). The current time is passed as an argument so
+/// that all animations are in sync.
+static void gr_update_frame_index(Image *img, Milliseconds now) {
+	if (img->current_frame == 0) {
+		img->current_frame_time = now;
+		img->current_frame = 1;
+		img->next_redraw = now + MAX(1, img->first_frame.gap);
+		return;
+	}
+	// If the animation is stopped, show the current frame.
+	if (!img->animation_state ||
+	    img->animation_state == ANIMATION_STATE_STOPPED ||
+	    img->animation_state == ANIMATION_STATE_UNSET) {
+		// The next redraw is never (unless the state is changed).
+		img->next_redraw = 0;
+		return;
+	}
+	int last_uploaded_frame_index = gr_last_uploaded_frame_index(img);
+	// If we are loading and we reached the last frame, show the last frame.
+	if (img->animation_state == ANIMATION_STATE_LOADING &&
+	    img->current_frame == last_uploaded_frame_index) {
+		// The next redraw is never (unless the state is changed or
+		// frames are added).
+		img->next_redraw = 0;
+		return;
+	}
+
+	// Check how many milliseconds passed since the current frame was shown.
+	int passed_ms = now - img->current_frame_time;
+	// If the animation is looping and too much time has passes, we can
+	// make a shortcut.
+	if (img->animation_state == ANIMATION_STATE_LOOPING &&
+	    img->total_duration > 0 && passed_ms >= img->total_duration) {
+		passed_ms %= img->total_duration;
+		img->current_frame_time = now - passed_ms;
+	}
+	// Find the next frame.
+	int original_frame_index = img->current_frame;
+	while (1) {
+		ImageFrame *frame = gr_get_frame(img, img->current_frame);
+		if (!frame) {
+			// The frame doesn't exist, go to the first frame.
+			img->current_frame = 1;
+			img->current_frame_time = now;
+			img->next_redraw = now + MAX(1, img->first_frame.gap);
+			return;
+		}
+		if (frame->gap >= 0 && passed_ms < frame->gap) {
+			// Not enough time has passed, we are still in the same
+			// frame, and it's not a gapless frame.
+			img->next_redraw =
+				img->current_frame_time + MAX(1, frame->gap);
+			return;
+		}
+		// Otherwise go to the next frame.
+		passed_ms -= MAX(0, frame->gap);
+		if (img->current_frame >= last_uploaded_frame_index) {
+			// It's the last frame, if the animation is loading,
+			// remain on it.
+			if (img->animation_state == ANIMATION_STATE_LOADING) {
+				img->next_redraw = 0;
+				return;
+			}
+			// Otherwise the animation is looping.
+			img->current_frame = 1;
+			// TODO: Support finite number of loops.
+		} else {
+			img->current_frame++;
+		}
+		// Make sure we don't get stuck in an infinite loop.
+		if (img->current_frame == original_frame_index) {
+			// We looped through all frames, but haven't reached the
+			// next frame yet. This may happen if too much time has
+			// passed since the last redraw or all the frames are
+			// gapless. Just move on to the next frame.
+			img->current_frame++;
+			if (img->current_frame >
+			    last_uploaded_frame_index)
+				img->current_frame = 1;
+			img->current_frame_time = now;
+			img->next_redraw = now + MAX(
+				1, gr_get_frame(img, img->current_frame)->gap);
+			return;
+		}
+		// Adjust the start time of the frame. The next redraw time will
+		// be set in the next iteration.
+		img->current_frame_time += MAX(0, frame->gap);
+	}
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Unloading and deleting images to save resources.
+////////////////////////////////////////////////////////////////////////////////
+
+/// Returns whether the original file of the frame is still available (exists,
+/// is a regular file, has the same size and mtime).
+static int gr_is_original_file_still_available(ImageFrame *frame) {
+	if (!frame->original_filename)
+		return 0;
+	struct stat st;
+	if (stat(frame->original_filename, &st) != 0)
+		return 0;
+	if (!S_ISREG(st.st_mode))
+		return 0;
+	if (st.st_size == 0 || st.st_size > graphics_max_single_image_file_size)
+		return 0;
+	if (frame->expected_size && st.st_size != frame->expected_size)
+		return 0;
+	if (st.st_mtime != frame->original_file_mtime)
+		return 0;
+	return 1;
+}
+
+/// A helper to compare frames by atime for qsort.
+static int gr_cmp_frames_by_atime(const void *a, const void *b) {
+	ImageFrame *frame_a = *(ImageFrame *const *)a;
+	ImageFrame *frame_b = *(ImageFrame *const *)b;
+	if (frame_a->atime == frame_b->atime)
+		return frame_a->image->global_command_index -
+		       frame_b->image->global_command_index;
+	return frame_a->atime - frame_b->atime;
+}
+
+/// A helper to compare images by atime for qsort.
+static int gr_cmp_images_by_atime(const void *a, const void *b) {
+	Image *img_a = *(Image *const *)a;
+	Image *img_b = *(Image *const *)b;
+	if (img_a->atime == img_b->atime)
+		return img_a->global_command_index -
+		       img_b->global_command_index;
+	return img_a->atime - img_b->atime;
+}
+
+/// A helper to compare placements by atime for qsort.
+static int gr_cmp_placements_by_atime(const void *a, const void *b) {
+	ImagePlacement *p_a = *(ImagePlacement **)a;
+	ImagePlacement *p_b = *(ImagePlacement **)b;
+	if (p_a->atime == p_b->atime)
+		return p_a->image->global_command_index -
+		       p_b->image->global_command_index;
+	return p_a->atime - p_b->atime;
+}
+
+typedef kvec_t(Image *) ImageVec;
+typedef kvec_t(ImagePlacement *) ImagePlacementVec;
+typedef kvec_t(ImageFrame *) ImageFrameVec;
+
+/// Returns an array of pointers to all images sorted by atime.
+static ImageVec gr_get_images_sorted_by_atime() {
+	ImageVec vec;
+	kv_init(vec);
+	if (kh_size(images) == 0)
+		return vec;
+	kv_resize(Image *, vec, kh_size(images));
+	Image *img = NULL;
+	kh_foreach_value(images, img, { kv_push(Image *, vec, img); });
+	qsort(vec.a, kv_size(vec), sizeof(Image *), gr_cmp_images_by_atime);
+	return vec;
+}
+
+/// Returns an array of pointers to all placements sorted by atime.
+static ImagePlacementVec gr_get_placements_sorted_by_atime() {
+	ImagePlacementVec vec;
+	kv_init(vec);
+	if (total_placement_count == 0)
+		return vec;
+	kv_resize(ImagePlacement *, vec, total_placement_count);
+	Image *img = NULL;
+	ImagePlacement *placement = NULL;
+	kh_foreach_value(images, img, {
+		kh_foreach_value(img->placements, placement, {
+			kv_push(ImagePlacement *, vec, placement);
+		});
+	});
+	qsort(vec.a, kv_size(vec), sizeof(ImagePlacement *),
+	      gr_cmp_placements_by_atime);
+	return vec;
+}
+
+/// Returns an array of pointers to all frames sorted by atime.
+static ImageFrameVec gr_get_frames_sorted_by_atime() {
+	ImageFrameVec frames;
+	kv_init(frames);
+	Image *img = NULL;
+	kh_foreach_value(images, img, {
+		foreach_frame(*img, frame, {
+			kv_push(ImageFrame *, frames, frame);
+		});
+	});
+	qsort(frames.a, kv_size(frames), sizeof(ImageFrame *),
+	      gr_cmp_frames_by_atime);
+	return frames;
+}
+
+/// An object that can be unloaded from RAM.
+typedef struct {
+	/// Some score, probably based on access time. The lower the score, the
+	/// more likely that the object should be unloaded.
+	int64_t score;
+	union {
+		ImagePlacement *placement;
+		ImageFrame *frame;
+	};
+	/// If zero, the object is the imlib object of `frame`, if non-zero,
+	/// the object is a pixmap of `frameidx`-th frame of `placement`.
+	int frameidx;
+} UnloadableObject;
+
+typedef kvec_t(UnloadableObject) UnloadableObjectVec;
+
+/// A helper to compare unloadable objects by score for qsort.
+static int gr_cmp_unloadable_objects(const void *a, const void *b) {
+	UnloadableObject *obj_a = (UnloadableObject *)a;
+	UnloadableObject *obj_b = (UnloadableObject *)b;
+	return obj_a->score - obj_b->score;
+}
+
+/// Unloads an unloadable object from RAM.
+static void gr_unload_object(UnloadableObject *obj) {
+	if (obj->frameidx) {
+		if (obj->placement->protected_frame == obj->frameidx)
+			return;
+		gr_unload_pixmap(obj->placement, obj->frameidx);
+	} else {
+		gr_unload_frame(obj->frame);
+	}
+}
+
+/// Returns the recency threshold for an image. Frames that were accessed within
+/// this threshold from now are considered recent and may be handled
+/// differently because we may need them again very soon.
+static Milliseconds gr_recency_threshold(Image *img) {
+	return img->total_duration * 2 + 1000;
+}
+
+/// Creates an unloadable object for the imlib object of a frame.
+static UnloadableObject gr_unloadable_object_for_frame(Milliseconds now,
+						       ImageFrame *frame) {
+	UnloadableObject obj = {0};
+	obj.frameidx = 0;
+	obj.frame = frame;
+	Milliseconds atime = frame->atime;
+	obj.score = atime;
+	if (atime >= now - gr_recency_threshold(frame->image)) {
+		// This is a recent frame, probably from an active animation.
+		// Score it above `now` to prefer unloading non-active frames.
+		// Randomize the score because it's not very clear in which
+		// order we want to unload them: reloading a frame may require
+		// reloading other frames.
+		obj.score = now + 1000 + rand() % 1000;
+	}
+	return obj;
+}
+
+/// Creates an unloadable object for a pixmap.
+static UnloadableObject
+gr_unloadable_object_for_pixmap(Milliseconds now, ImageFrame *frame,
+				ImagePlacement *placement) {
+	UnloadableObject obj = {0};
+	obj.frameidx = frame->index;
+	obj.placement = placement;
+	obj.score = placement->atime;
+	// Since we don't store pixmap atimes, use the
+	// oldest atime of the frame and the placement.
+	Milliseconds atime = MIN(placement->atime, frame->atime);
+	obj.score = atime;
+	if (atime >= now - gr_recency_threshold(frame->image)) {
+		// This is a recent pixmap, probably from an active animation.
+		// Score it above `now` to prefer unloading non-active frames.
+		// Also assign higher scores to frames that are closer to the
+		// current frame (more likely to be used soon).
+		int num_frames = gr_last_frame_index(frame->image);
+		int dist = frame->index - frame->image->current_frame;
+		if (dist < 0)
+			dist += num_frames;
+		obj.score =
+			now + 1000 + (num_frames - dist) * 1000 / num_frames;
+		// If the pixmap is much larger than the imlib image, prefer to
+		// unload the pixmap by adding up to -1000 to the score. If the
+		// imlib image is larger, add up to +1000.
+		float imlib_size = gr_frame_current_ram_size(frame);
+		float pixmap_size =
+			gr_placement_single_frame_ram_size(placement);
+		obj.score +=
+			2000 * (imlib_size / (imlib_size + pixmap_size) - 0.5);
+	}
+	return obj;
+}
+
+/// Returns an array of unloadable objects sorted by score.
+static UnloadableObjectVec
+gr_get_unloadable_objects_sorted_by_score(Milliseconds now) {
+	UnloadableObjectVec objects;
+	kv_init(objects);
+	Image *img = NULL;
+	ImagePlacement *placement = NULL;
+	kh_foreach_value(images, img, {
+		foreach_frame(*img, frame, {
+			if (frame->imlib_object) {
+				kv_push(UnloadableObject, objects,
+					gr_unloadable_object_for_frame(now,
+								       frame));
+			}
+			int frameidx = frame->index;
+			kh_foreach_value(img->placements, placement, {
+				if (!gr_get_frame_pixmap(placement, frameidx))
+					continue;
+				kv_push(UnloadableObject, objects,
+					gr_unloadable_object_for_pixmap(
+						now, frame, placement));
+			});
+		});
+	});
+	qsort(objects.a, kv_size(objects), sizeof(UnloadableObject),
+	      gr_cmp_unloadable_objects);
+	return objects;
+}
+
+/// Returns the limit adjusted by the excess tolerance ratio.
+static inline unsigned apply_tolerance(unsigned limit) {
+	return limit + (unsigned)(limit * graphics_excess_tolerance_ratio);
+}
+
+/// Checks RAM and disk cache limits and deletes/unloads some images.
+static void gr_check_limits() {
+	Milliseconds now = gr_now_ms();
+	ImageVec images_sorted = {0};
+	ImagePlacementVec placements_sorted = {0};
+	ImageFrameVec frames_sorted = {0};
+	UnloadableObjectVec objects_sorted = {0};
+	int images_begin = 0;
+	int placements_begin = 0;
+	char changed = 0;
+	// First reduce the number of images if there are too many.
+	if (kh_size(images) > apply_tolerance(graphics_max_total_placements)) {
+		GR_LOG("Too many images: %d\n", kh_size(images));
+		changed = 1;
+		images_sorted = gr_get_images_sorted_by_atime();
+		int to_delete = kv_size(images_sorted) -
+				graphics_max_total_placements;
+		for (; images_begin < to_delete; images_begin++)
+			gr_delete_image(images_sorted.a[images_begin]);
+	}
+	// Then reduce the number of placements if there are too many.
+	if (total_placement_count >
+	    apply_tolerance(graphics_max_total_placements)) {
+		GR_LOG("Too many placements: %d\n", total_placement_count);
+		changed = 1;
+		placements_sorted = gr_get_placements_sorted_by_atime();
+		int to_delete = kv_size(placements_sorted) -
+				graphics_max_total_placements;
+		for (; placements_begin < to_delete; placements_begin++) {
+			ImagePlacement *placement =
+				placements_sorted.a[placements_begin];
+			if (placement->protected_frame)
+				break;
+			gr_delete_placement(placement);
+		}
+	}
+	// Then reduce the size of the image file cache. The files correspond to
+	// image frames.
+	if (images_disk_size >
+	    apply_tolerance(graphics_total_file_cache_size)) {
+		GR_LOG("Too big disk cache: %ld KiB\n",
+		       images_disk_size / 1024);
+		changed = 1;
+		frames_sorted = gr_get_frames_sorted_by_atime();
+		for (int i = 0; i < kv_size(frames_sorted); i++) {
+			if (images_disk_size <= graphics_total_file_cache_size)
+				break;
+			gr_delete_imagefile(kv_A(frames_sorted, i));
+		}
+	}
+	// Then unload images from RAM.
+	if (images_ram_size > apply_tolerance(graphics_max_total_ram_size)) {
+		changed = 1;
+		int frames_begin = 0;
+		GR_LOG("Too much ram: %ld KiB\n", images_ram_size / 1024);
+		objects_sorted = gr_get_unloadable_objects_sorted_by_score(now);
+		for (int i = 0; i < kv_size(objects_sorted); i++) {
+			if (images_ram_size <= graphics_max_total_ram_size)
+				break;
+			gr_unload_object(&kv_A(objects_sorted, i));
+		}
+	}
+	if (changed) {
+		Milliseconds end = gr_now_ms();
+		GR_LOG("After cleaning:  ram: %ld KiB  disk: %ld KiB  "
+		       "img count: %d  placement count: %d  Took %ld ms\n",
+		       images_ram_size / 1024, images_disk_size / 1024,
+		       kh_size(images), total_placement_count, end - now);
+	}
+	kv_destroy(images_sorted);
+	kv_destroy(placements_sorted);
+	kv_destroy(frames_sorted);
+	kv_destroy(objects_sorted);
+}
+
+/// Unloads all images by user request.
+void gr_unload_images_to_reduce_ram() {
+	Image *img = NULL;
+	ImagePlacement *placement = NULL;
+	kh_foreach_value(images, img, {
+		kh_foreach_value(img->placements, placement, {
+			if (placement->protected_frame)
+				continue;
+			gr_unload_placement(placement);
+		});
+		gr_unload_all_frames(img);
+	});
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Image loading.
+////////////////////////////////////////////////////////////////////////////////
+
+/// Copies `num_pixels` pixels (not bytes!) from a buffer `from` to an imlib2
+/// image data `to`. The format may be 24 (RGB) or 32 (RGBA), and it's converted
+/// to imlib2's representation, which is 0xAARRGGBB (having BGRA memory layout
+/// on little-endian architectures).
+static inline void gr_copy_pixels(DATA32 *to, unsigned char *from, int format,
+				  size_t num_pixels) {
+	size_t pixel_size = format == 24 ? 3 : 4;
+	if (format == 32) {
+		for (unsigned i = 0; i < num_pixels; ++i) {
+			unsigned byte_i = i * pixel_size;
+			to[i] = ((DATA32)from[byte_i + 2]) |
+				((DATA32)from[byte_i + 1]) << 8 |
+				((DATA32)from[byte_i]) << 16 |
+				((DATA32)from[byte_i + 3]) << 24;
+		}
+	} else {
+		for (unsigned i = 0; i < num_pixels; ++i) {
+			unsigned byte_i = i * pixel_size;
+			to[i] = ((DATA32)from[byte_i + 2]) |
+				((DATA32)from[byte_i + 1]) << 8 |
+				((DATA32)from[byte_i]) << 16 | 0xFF000000;
+		}
+	}
+}
+
+/// Loads uncompressed RGB or RGBA image data from a file.
+static void gr_load_raw_pixel_data_uncompressed(DATA32 *data, FILE *file,
+						int format,
+						size_t total_pixels) {
+	unsigned char chunk[BUFSIZ];
+	size_t pixel_size = format == 24 ? 3 : 4;
+	size_t chunk_size_pix = BUFSIZ / 4;
+	size_t chunk_size_bytes = chunk_size_pix * pixel_size;
+	size_t bytes = total_pixels * pixel_size;
+	for (size_t chunk_start_pix = 0; chunk_start_pix < total_pixels;
+	     chunk_start_pix += chunk_size_pix) {
+		size_t read_size = fread(chunk, 1, chunk_size_bytes, file);
+		size_t read_pixels = read_size / pixel_size;
+		if (chunk_start_pix + read_pixels > total_pixels)
+			read_pixels = total_pixels - chunk_start_pix;
+		gr_copy_pixels(data + chunk_start_pix, chunk, format,
+			       read_pixels);
+	}
+}
+
+#define COMPRESSED_CHUNK_SIZE BUFSIZ
+#define DECOMPRESSED_CHUNK_SIZE (BUFSIZ * 4)
+
+/// Loads compressed RGB or RGBA image data from a file.
+static int gr_load_raw_pixel_data_compressed(DATA32 *data, FILE *file,
+					     int format, size_t total_pixels) {
+	size_t pixel_size = format == 24 ? 3 : 4;
+	unsigned char compressed_chunk[COMPRESSED_CHUNK_SIZE];
+	unsigned char decompressed_chunk[DECOMPRESSED_CHUNK_SIZE];
+
+	z_stream strm;
+	strm.zalloc = Z_NULL;
+	strm.zfree = Z_NULL;
+	strm.opaque = Z_NULL;
+	strm.next_out = decompressed_chunk;
+	strm.avail_out = DECOMPRESSED_CHUNK_SIZE;
+	strm.avail_in = 0;
+	strm.next_in = Z_NULL;
+	int ret = inflateInit(&strm);
+	if (ret != Z_OK)
+	    return 1;
+
+	int error = 0;
+	int progress = 0;
+	size_t total_copied_pixels = 0;
+	while (1) {
+		// If we don't have enough data in the input buffer, try to read
+		// from the file.
+		if (strm.avail_in <= COMPRESSED_CHUNK_SIZE / 4) {
+			// Move the existing data to the beginning.
+			memmove(compressed_chunk, strm.next_in, strm.avail_in);
+			strm.next_in = compressed_chunk;
+			// Read more data.
+			size_t bytes_read = fread(
+				compressed_chunk + strm.avail_in, 1,
+				COMPRESSED_CHUNK_SIZE - strm.avail_in, file);
+			strm.avail_in += bytes_read;
+			if (bytes_read != 0)
+				progress = 1;
+		}
+
+		// Try to inflate the data.
+		int ret = inflate(&strm, Z_SYNC_FLUSH);
+		if (ret == Z_MEM_ERROR || ret == Z_DATA_ERROR) {
+			error = 1;
+			fprintf(stderr,
+				"error: could not decompress the image, error "
+				"%s\n",
+				ret == Z_MEM_ERROR ? "Z_MEM_ERROR"
+						   : "Z_DATA_ERROR");
+			break;
+		}
+
+		// Copy the data from the output buffer to the image.
+		size_t full_pixels =
+			(DECOMPRESSED_CHUNK_SIZE - strm.avail_out) / pixel_size;
+		// Make sure we don't overflow the image.
+		if (full_pixels > total_pixels - total_copied_pixels)
+			full_pixels = total_pixels - total_copied_pixels;
+		if (full_pixels > 0) {
+			// Copy pixels.
+			gr_copy_pixels(data, decompressed_chunk, format,
+				       full_pixels);
+			data += full_pixels;
+			total_copied_pixels += full_pixels;
+			if (total_copied_pixels >= total_pixels) {
+				// We filled the whole image, there may be some
+				// data left, but we just truncate it.
+				break;
+			}
+			// Move the remaining data to the beginning.
+			size_t copied_bytes = full_pixels * pixel_size;
+			size_t leftover =
+				(DECOMPRESSED_CHUNK_SIZE - strm.avail_out) -
+				copied_bytes;
+			memmove(decompressed_chunk,
+				decompressed_chunk + copied_bytes, leftover);
+			strm.next_out -= copied_bytes;
+			strm.avail_out += copied_bytes;
+			progress = 1;
+		}
+
+		// If we haven't made any progress, then we have reached the end
+		// of both the file and the inflated data.
+		if (!progress)
+			break;
+		progress = 0;
+	}
+
+	inflateEnd(&strm);
+	return error;
+}
+
+#undef COMPRESSED_CHUNK_SIZE
+#undef DECOMPRESSED_CHUNK_SIZE
+
+/// Load the image from a file containing raw pixel data (RGB or RGBA), the data
+/// may be compressed.
+static Imlib_Image gr_load_raw_pixel_data(ImageFrame *frame,
+					  const char *filename) {
+	size_t total_pixels = frame->data_pix_width * frame->data_pix_height;
+	if (total_pixels * 4 > graphics_max_single_image_ram_size) {
+		fprintf(stderr,
+			"error: image %u frame %u is too big too load: %zu > %u\n",
+			frame->image->image_id, frame->index, total_pixels * 4,
+			graphics_max_single_image_ram_size);
+		return NULL;
+	}
+
+	FILE* file = fopen(filename, "rb");
+	if (!file) {
+		fprintf(stderr,
+			"error: could not open image file: %s\n",
+			sanitized_filename(filename));
+		return NULL;
+	}
+
+	Imlib_Image image = imlib_create_image(frame->data_pix_width,
+					       frame->data_pix_height);
+	if (!image) {
+		fprintf(stderr,
+			"error: could not create an image of size %d x %d\n",
+			frame->data_pix_width, frame->data_pix_height);
+		fclose(file);
+		return NULL;
+	}
+
+	imlib_context_set_image(image);
+	imlib_image_set_has_alpha(1);
+	DATA32* data = imlib_image_get_data();
+
+	if (frame->compression == 0) {
+		gr_load_raw_pixel_data_uncompressed(data, file, frame->format,
+						    total_pixels);
+	} else {
+		int ret = gr_load_raw_pixel_data_compressed(
+			data, file, frame->format, total_pixels);
+		if (ret != 0) {
+			imlib_image_put_back_data(data);
+			imlib_free_image();
+			fclose(file);
+			return NULL;
+		}
+	}
+
+	fclose(file);
+	imlib_image_put_back_data(data);
+	return image;
+}
+
+/// Loads the unscaled frame into RAM as an imlib object. The frame imlib object
+/// is fully composed on top of the background frame. If the frame is already
+/// loaded, does nothing. Loading may fail, in which case the status of the
+/// frame will be set to STATUS_RAM_LOADING_ERROR.
+static void gr_load_imlib_object(ImageFrame *frame) {
+	if (frame->imlib_object)
+		return;
+
+	// If the image is uninitialized or uploading has failed, or the file
+	// has been deleted, we cannot load the image.
+	if (frame->status < STATUS_UPLOADING_SUCCESS)
+		return;
+	if (frame->disk_size == 0) {
+		// In some cases the original image file may still be available,
+		// try to restore it.
+		gr_try_restore_imagefile(frame);
+	}
+	if (frame->disk_size == 0) {
+		if (frame->status != STATUS_RAM_LOADING_ERROR &&
+		    frame->status >= STATUS_UPLOADING_SUCCESS) {
+			fprintf(stderr,
+				"error: cached image was deleted: %u frame %u\n",
+				frame->image->image_id, frame->index);
+		}
+		frame->status = STATUS_RAM_LOADING_ERROR;
+		return;
+	}
+
+	// Prevent recursive dependences between frames.
+	if (frame->ram_loading_in_progress) {
+		if (frame->status != STATUS_RAM_LOADING_ERROR) {
+			fprintf(stderr,
+				"error: recursive loading of image %u frame "
+				"%u\n",
+				frame->image->image_id, frame->index);
+		}
+		frame->status = STATUS_RAM_LOADING_ERROR;
+		return;
+	}
+	frame->ram_loading_in_progress = 1;
+
+	// Load the background frame if needed. Hopefully it's not recursive.
+	ImageFrame *bg_frame = NULL;
+	if (frame->background_frame_index) {
+		bg_frame = gr_get_frame(frame->image,
+					frame->background_frame_index);
+		if (!bg_frame) {
+			if (frame->status != STATUS_RAM_LOADING_ERROR) {
+				fprintf(stderr,
+					"error: could not find background "
+					"frame %d for image %u frame %d\n",
+					frame->background_frame_index,
+					frame->image->image_id, frame->index);
+				frame->status = STATUS_RAM_LOADING_ERROR;
+			}
+			return;
+		}
+		gr_load_imlib_object(bg_frame);
+		if (!bg_frame->imlib_object) {
+			if (frame->status != STATUS_RAM_LOADING_ERROR) {
+				fprintf(stderr,
+					"error: could not load background "
+					"frame %d for image %u frame %d\n",
+					frame->background_frame_index,
+					frame->image->image_id, frame->index);
+			}
+			frame->status = STATUS_RAM_LOADING_ERROR;
+			return;
+		}
+	}
+
+	frame->ram_loading_in_progress = 0;
+
+	// We exclude background frames from the time to load the frame.
+	Milliseconds loading_start = gr_now_ms();
+
+	// Load the frame data image.
+	Imlib_Image frame_data_image = NULL;
+	char filename[MAX_FILENAME_SIZE];
+	gr_get_frame_filename(frame, filename, MAX_FILENAME_SIZE);
+	GR_LOG("Loading image: %s\n", sanitized_filename(filename));
+	if (frame->format == 100)
+		frame_data_image = imlib_load_image(filename);
+	if (frame->format == 32 || frame->format == 24)
+		frame_data_image = gr_load_raw_pixel_data(frame, filename);
+	debug_loaded_files_counter++;
+
+	if (!frame_data_image) {
+		if (frame->status != STATUS_RAM_LOADING_ERROR) {
+			fprintf(stderr, "error: could not load image: %s\n",
+				sanitized_filename(filename));
+		}
+		frame->status = STATUS_RAM_LOADING_ERROR;
+		return;
+	}
+
+	imlib_context_set_image(frame_data_image);
+	int frame_data_width = imlib_image_get_width();
+	int frame_data_height = imlib_image_get_height();
+
+	// Check that the size of the image we are loading does not exceed the
+	// limit.
+	if (frame_data_width * frame_data_height * 4 >
+	    graphics_max_single_image_ram_size) {
+		if (frame->status != STATUS_RAM_LOADING_ERROR) {
+			fprintf(stderr,
+				"error: image %u frame %u is too big too load: "
+				"%d x %d * 4 = %d > %u\n",
+				frame->image->image_id, frame->index,
+				frame_data_width, frame_data_height,
+				frame_data_width * frame_data_height * 4,
+				graphics_max_single_image_ram_size);
+		}
+		imlib_free_image();
+		frame->status = STATUS_RAM_LOADING_ERROR;
+		return;
+	}
+
+	GR_LOG("Successfully loaded, size %d x %d\n", frame_data_width,
+	       frame_data_height);
+	// If imlib loading succeeded, and it is the first frame, set the
+	// information about the original image size, unless it's already set.
+	if (frame->index == 1 && frame->image->pix_width == 0 &&
+	    frame->image->pix_height == 0) {
+		frame->image->pix_width = frame_data_width;
+		frame->image->pix_height = frame_data_height;
+	}
+
+	int image_width = frame->image->pix_width;
+	int image_height = frame->image->pix_height;
+
+	// Compose the image with the background color or frame.
+	if (frame->background_color != 0 || bg_frame ||
+	    image_width != frame_data_width ||
+	    image_height != frame_data_height) {
+		GR_LOG("Composing the frame bg = 0x%08X, bgframe = %d\n",
+		       frame->background_color, frame->background_frame_index);
+		Imlib_Image composed_image = imlib_create_image(
+			image_width, image_height);
+		imlib_context_set_image(composed_image);
+		imlib_image_set_has_alpha(1);
+		imlib_context_set_anti_alias(0);
+
+		// Start with the background frame or color.
+		imlib_context_set_blend(0);
+		if (bg_frame && bg_frame->imlib_object) {
+			imlib_blend_image_onto_image(
+				bg_frame->imlib_object, 1, 0, 0,
+				image_width, image_height, 0, 0,
+				image_width, image_height);
+		} else {
+			int r = (frame->background_color >> 24) & 0xFF;
+			int g = (frame->background_color >> 16) & 0xFF;
+			int b = (frame->background_color >> 8) & 0xFF;
+			int a = frame->background_color & 0xFF;
+			imlib_context_set_color(r, g, b, a);
+			imlib_image_fill_rectangle(0, 0, image_width,
+						   image_height);
+		}
+
+		// Blend the frame data image onto the background.
+		imlib_context_set_blend(1);
+		imlib_blend_image_onto_image(
+			frame_data_image, 1, 0, 0, frame->data_pix_width,
+			frame->data_pix_height, frame->x, frame->y,
+			frame->data_pix_width, frame->data_pix_height);
+
+		// Free the frame data image.
+		imlib_context_set_image(frame_data_image);
+		imlib_free_image();
+
+		frame_data_image = composed_image;
+	}
+
+	frame->imlib_object = frame_data_image;
+
+	images_ram_size += gr_frame_current_ram_size(frame);
+	frame->status = STATUS_RAM_LOADING_SUCCESS;
+
+	Milliseconds loading_end = gr_now_ms();
+	GR_LOG("After loading image %u frame %d ram: %ld KiB  (+ %u KiB)  Took "
+	       "%ld ms\n",
+	       frame->image->image_id, frame->index, images_ram_size / 1024,
+	       gr_frame_current_ram_size(frame) / 1024,
+	       loading_end - loading_start);
+}
+
+/// Premultiplies the alpha channel of the image data. The data is an array of
+/// pixels such that each pixel is a 32-bit integer in the format 0xAARRGGBB.
+static void gr_premultiply_alpha(DATA32 *data, size_t num_pixels) {
+	for (size_t i = 0; i < num_pixels; ++i) {
+		DATA32 pixel = data[i];
+		unsigned char a = pixel >> 24;
+		if (a == 0) {
+			data[i] = 0;
+		} else if (a != 255) {
+			unsigned char b = (pixel & 0xFF) * a / 255;
+			unsigned char g = ((pixel >> 8) & 0xFF) * a / 255;
+			unsigned char r = ((pixel >> 16) & 0xFF) * a / 255;
+			data[i] = (a << 24) | (r << 16) | (g << 8) | b;
+		}
+	}
+}
+
+/// Computes the pixmap transformation, which is essentially the destination
+/// rectangle for drawing an image placement in a box of size `cols*cw` x
+/// `rows*ch`. The destination rectangle depends on the parameters of the
+/// placement, like scaling mode. The computed transformation is stored in the
+/// placement.
+void gr_compute_pixmap_transformation(ImagePlacement *placement) {
+	// Infer the placement size if needed.
+	gr_infer_placement_size_maybe(placement);
+
+	// The size of the box in which the image is placed.
+	int box_w = (int)placement->cols * placement->scaled_cw;
+	int box_h = (int)placement->rows * placement->scaled_ch;
+
+	int src_w = placement->src_pix_width;
+	int src_h = placement->src_pix_height;
+
+	// Whether the box is too small to use the true size of the image.
+	char box_too_small = box_w < src_w || box_h < src_h;
+	char mode = placement->scale_mode;
+
+	PixmapTransformation *tr = &placement->pixmap_transformation;
+
+	if (src_w <= 0 || src_h <= 0) {
+		tr->dst_x = tr->dst_y = tr->dst_w = tr->dst_h = 0;
+	} else if (mode == SCALE_MODE_FILL) {
+		tr->dst_x = tr->dst_y = 0;
+		tr->dst_w = box_w;
+		tr->dst_h = box_h;
+	} else if (mode == SCALE_MODE_NONE ||
+		   (mode == SCALE_MODE_NONE_OR_CONTAIN && !box_too_small)) {
+		tr->dst_x = tr->dst_y = 0;
+		tr->dst_w = src_w;
+		tr->dst_h = src_h;
+	} else {
+		if (mode != SCALE_MODE_CONTAIN &&
+		    mode != SCALE_MODE_NONE_OR_CONTAIN) {
+			fprintf(stderr,
+				"warning: unknown scale mode %u, using "
+				"'contain' instead\n",
+				mode);
+		}
+		if (box_w * src_h > src_w * box_h) {
+			// If the box is wider than the original image, fit to
+			// height.
+			tr->dst_h = box_h;
+			tr->dst_y = 0;
+			tr->dst_w = src_w * box_h / src_h;
+			tr->dst_x = (box_w - tr->dst_w) / 2;
+		} else {
+			// Otherwise, fit to width.
+			tr->dst_w = box_w;
+			tr->dst_x = 0;
+			tr->dst_h = src_h * box_w / src_w;
+			tr->dst_y = (box_h - tr->dst_h) / 2;
+		}
+	}
+
+	// Make sure that the size of the destination image is non-zero.
+	tr->dst_w = MAX(1, tr->dst_w);
+	tr->dst_h = MAX(1, tr->dst_h);
+
+	// Normally we want the pixmap to be exactly the size of the destination
+	// rectangle.
+	tr->pixmap_w = tr->dst_w;
+	tr->pixmap_h = tr->dst_h;
+	// However, if the pixmap would be larger than the source image, use the
+	// source image size. The upscaling will be done by XRender then.
+	if (tr->pixmap_w * tr->pixmap_h > src_w * src_h) {
+		tr->pixmap_w = MAX(1, src_w);
+		tr->pixmap_h = MAX(1, src_h);
+	}
+
+	// If the pixmap would be over the limit, scale it down.
+	if (tr->pixmap_w * tr->pixmap_h * 4 >
+	    graphics_max_single_image_ram_size) {
+		double scale = sqrt((double)graphics_max_single_image_ram_size /
+				    (tr->pixmap_w * tr->pixmap_h * 4));
+		tr->pixmap_w = MAX(1, (int)(tr->pixmap_w * scale));
+		tr->pixmap_h = MAX(1, (int)(tr->pixmap_h * scale));
+	}
+}
+
+/// Creates and returns a scaled imlib image for the image placement. The
+/// original imlib object must be loaded and the placement size must be inferred
+/// by the caller (by calling `gr_compute_pixmap_transformation`). The caller is
+/// responsible for freeing the returned image.
+Imlib_Image gr_create_scaled_image_object(ImagePlacement *placement,
+					  ImageFrame *frame) {
+	// The source rectangle (inside the original image).
+	int src_x = placement->src_pix_x;
+	int src_y = placement->src_pix_y;
+	int src_w = placement->src_pix_width;
+	int src_h = placement->src_pix_height;
+	// The pixmap dimensions.
+	int pixmap_w = placement->pixmap_transformation.pixmap_w;
+	int pixmap_h = placement->pixmap_transformation.pixmap_h;
+
+	if (pixmap_w * pixmap_h * 4 > graphics_max_single_image_ram_size) {
+		fprintf(stderr,
+			"error: placement %u/%u would be too big to load: %d x "
+			"%d x 4 > %u\n",
+			placement->image->image_id, placement->placement_id,
+			pixmap_w, pixmap_h, graphics_max_single_image_ram_size);
+		return 0;
+	}
+
+	if (pixmap_w == 0 || pixmap_h == 0)
+		fprintf(stderr, "warning: image of zero size\n");
+
+	imlib_context_set_image(frame->imlib_object);
+	imlib_context_set_anti_alias(1);
+	imlib_context_set_blend(1);
+
+	return imlib_create_cropped_scaled_image(src_x, src_y, src_w, src_h,
+						 pixmap_w, pixmap_h);
+}
+
+/// Creates a pixmap for the frame of an image placement. The pixmap contains
+/// the image data correctly scaled and fit to the box defined by the number of
+/// rows/columns of the image placement and the provided cell dimensions in
+/// pixels. If the placement is already loaded, it will be reloaded only if the
+/// cell dimensions have changed.
+Pixmap gr_load_pixmap(ImagePlacement *placement, int frameidx, int cw, int ch) {
+	Milliseconds loading_start = gr_now_ms();
+	Image *img = placement->image;
+	ImageFrame *frame = gr_get_frame(img, frameidx);
+
+	// Update the atime unconditionally.
+	gr_touch_placement(placement);
+	if (frame)
+		gr_touch_frame(frame);
+
+	// If cw or ch are different, unload all the pixmaps and recompute the
+	// pixmap geometry and transformation (shared for all pixmaps).
+	if (placement->scaled_cw != cw || placement->scaled_ch != ch) {
+		gr_unload_placement(placement);
+		placement->scaled_cw = cw;
+		placement->scaled_ch = ch;
+		gr_compute_pixmap_transformation(placement);
+	}
+
+	// If it's already loaded, do nothing.
+	Pixmap pixmap = gr_get_frame_pixmap(placement, frameidx);
+	if (pixmap)
+		return pixmap;
+
+	GR_LOG("Loading placement: %u/%u frame %u\n", img->image_id,
+	       placement->placement_id, frameidx);
+
+	if (placement->pixmap_transformation.pixmap_w == 0 ||
+	    placement->pixmap_transformation.pixmap_h == 0) {
+		GR_LOG("Not loading because the pixmap size is zero\n");
+		return 0;
+	}
+
+	// Load the imlib object for the frame.
+	if (!frame) {
+		fprintf(stderr,
+			"error: could not find frame %u for image %u\n",
+			frameidx, img->image_id);
+		return 0;
+	}
+	gr_load_imlib_object(frame);
+	if (!frame->imlib_object)
+		return 0;
+
+	// Create the scaled image. This is temporary, we will upload to the X
+	// server, and then delete immediately.
+	Imlib_Image scaled_image =
+		gr_create_scaled_image_object(placement, frame);
+	if (!scaled_image)
+		return 0;
+	imlib_context_set_image(scaled_image);
+	int pixmap_w = imlib_image_get_width();
+	int pixmap_h = imlib_image_get_height();
+
+	// XRender needs the alpha channel premultiplied.
+	DATA32 *data = imlib_image_get_data();
+	gr_premultiply_alpha(data, pixmap_w * pixmap_h);
+
+	// Upload the image to the X server.
+	Display *disp = imlib_context_get_display();
+	Visual *vis = imlib_context_get_visual();
+	Colormap cmap = imlib_context_get_colormap();
+	Drawable drawable = imlib_context_get_drawable();
+	if (!drawable)
+		drawable = DefaultRootWindow(disp);
+	pixmap = XCreatePixmap(disp, drawable, pixmap_w, pixmap_h, 32);
+	XVisualInfo visinfo = {0};
+	Status visual_found = XMatchVisualInfo(disp, DefaultScreen(disp), 32,
+					       TrueColor, &visinfo) ||
+			      XMatchVisualInfo(disp, DefaultScreen(disp), 24,
+					       TrueColor, &visinfo);
+	if (!visual_found) {
+		fprintf(stderr,
+			"error: could not find 32-bit TrueColor visual\n");
+		// Proceed anyway.
+		visinfo.visual = NULL;
+	}
+	XImage *ximage = XCreateImage(disp, visinfo.visual, 32, ZPixmap, 0,
+				      (char *)data, pixmap_w, pixmap_h, 32, 0);
+	if (!ximage) {
+		fprintf(stderr, "error: could not create XImage\n");
+		imlib_image_put_back_data(data);
+		imlib_free_image();
+		return 0;
+	}
+	GC gc = XCreateGC(disp, pixmap, 0, NULL);
+	XPutImage(disp, pixmap, gc, ximage, 0, 0, 0, 0, pixmap_w, pixmap_h);
+	XFreeGC(disp, gc);
+	// XDestroyImage will free the data as well, but it is managed by imlib,
+	// so set it to NULL.
+	ximage->data = NULL;
+	XDestroyImage(ximage);
+	imlib_image_put_back_data(data);
+	imlib_free_image();
+
+	// Assign the pixmap to the frame and increase the ram size.
+	gr_set_frame_pixmap(placement, frameidx, pixmap);
+	images_ram_size += gr_placement_single_frame_ram_size(placement);
+	debug_loaded_pixmaps_counter++;
+
+	Milliseconds loading_end = gr_now_ms();
+	GR_LOG("After loading placement %u/%u frame %d ram: %ld KiB  (+ %u "
+	       "KiB)  Took %ld ms\n",
+	       frame->image->image_id, placement->placement_id, frame->index,
+	       images_ram_size / 1024,
+	       gr_placement_single_frame_ram_size(placement) / 1024,
+	       loading_end - loading_start);
+
+	// Free up ram if needed, but keep the pixmap we've loaded no matter
+	// what.
+	placement->protected_frame = frameidx;
+	gr_check_limits();
+	placement->protected_frame = 0;
+
+	return pixmap;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Initialization and deinitialization.
+////////////////////////////////////////////////////////////////////////////////
+
+/// Creates a temporary directory.
+static int gr_create_cache_dir() {
+	strncpy(cache_dir, graphics_cache_dir_template, sizeof(cache_dir));
+	if (!mkdtemp(cache_dir)) {
+		fprintf(stderr,
+			"error: could not create temporary dir from template "
+			"%s\n",
+			sanitized_filename(cache_dir));
+		return 0;
+	}
+	fprintf(stderr, "Graphics cache directory: %s\n", cache_dir);
+	return 1;
+}
+
+/// Checks whether `tmp_dir` exists and recreates it if it doesn't.
+static void gr_make_sure_tmpdir_exists() {
+	struct stat st;
+	if (stat(cache_dir, &st) == 0 && S_ISDIR(st.st_mode))
+		return;
+	fprintf(stderr,
+		"error: %s is not a directory, will need to create a new "
+		"graphics cache directory\n",
+		sanitized_filename(cache_dir));
+	gr_create_cache_dir();
+}
+
+/// Initialize the graphics module.
+void gr_init(Display *disp, Visual *vis, Colormap cm) {
+	// Set the initialization time.
+	clock_gettime(CLOCK_MONOTONIC, &initialization_time);
+
+	// Create the temporary dir.
+	if (!gr_create_cache_dir())
+		abort();
+
+	// Initialize imlib.
+	imlib_context_set_display(disp);
+	imlib_context_set_visual(vis);
+	imlib_context_set_colormap(cm);
+	imlib_context_set_anti_alias(1);
+	imlib_context_set_blend(1);
+	// Imlib2 checks only the file name when caching, which is not enough
+	// for us since we reuse file names. Disable caching.
+	imlib_set_cache_size(0);
+
+	// Prepare for color inversion.
+	for (size_t i = 0; i < 256; ++i)
+		reverse_table[i] = 255 - i;
+
+	// Create data structures.
+	images = kh_init(id2image);
+	kv_init(next_redraw_times);
+
+	atexit(gr_deinit);
+}
+
+/// Deinitialize the graphics module.
+void gr_deinit() {
+	// Remove the cache dir.
+	remove(cache_dir);
+	kv_destroy(next_redraw_times);
+	if (images) {
+		// Delete all images.
+		gr_delete_all_images();
+		// Destroy the data structures.
+		kh_destroy(id2image, images);
+		images = NULL;
+	}
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Dumping, debugging, and image preview.
+////////////////////////////////////////////////////////////////////////////////
+
+/// Returns a string containing a time difference in a human-readable format.
+/// Uses a static buffer, so be careful.
+static const char *gr_ago(Milliseconds diff) {
+	static char result[32];
+	double seconds = (double)diff / 1000.0;
+	if (seconds < 1)
+		snprintf(result, sizeof(result), "%.2f sec ago", seconds);
+	else if (seconds < 60)
+		snprintf(result, sizeof(result), "%d sec ago", (int)seconds);
+	else if (seconds < 3600)
+		snprintf(result, sizeof(result), "%d min %d sec ago",
+			 (int)(seconds / 60), (int)(seconds) % 60);
+	else {
+		snprintf(result, sizeof(result), "%d hr %d min %d sec ago",
+			 (int)(seconds / 3600), (int)(seconds) % 3600 / 60,
+			 (int)(seconds) % 60);
+	}
+	return result;
+}
+
+/// Prints to `file` with an indentation of `ind` spaces.
+static void fprintf_ind(FILE *file, int ind, const char *format, ...) {
+	fprintf(file, "%*s", ind, "");
+	va_list args;
+	va_start(args, format);
+	vfprintf(file, format, args);
+	va_end(args);
+}
+
+/// Dumps the image info to `file` with an indentation of `ind` spaces.
+static void gr_dump_image_info(FILE *file, Image *img, int ind) {
+	if (!img) {
+		fprintf_ind(file, ind, "Image is NULL\n");
+		return;
+	}
+	Milliseconds now = gr_now_ms();
+	fprintf_ind(file, ind, "Image %u\n", img->image_id);
+	ind += 4;
+	fprintf_ind(file, ind, "number: %u\n", img->image_number);
+	fprintf_ind(file, ind, "global command index: %lu\n",
+		img->global_command_index);
+	fprintf_ind(file, ind, "accessed: %ld  %s\n", img->atime,
+		    gr_ago(now - img->atime));
+	fprintf_ind(file, ind, "pix size: %ux%u\n", img->pix_width,
+		    img->pix_height);
+	fprintf_ind(file, ind, "cur frame start time: %ld  %s\n",
+		    img->current_frame_time,
+		    gr_ago(now - img->current_frame_time));
+	if (img->next_redraw)
+		fprintf_ind(file, ind, "next redraw: %ld  in %ld ms\n",
+			    img->next_redraw, img->next_redraw - now);
+	fprintf_ind(file, ind, "total disk size: %u KiB\n",
+		img->total_disk_size / 1024);
+	fprintf_ind(file, ind, "total duration: %d\n", img->total_duration);
+	fprintf_ind(file, ind, "frames: %d\n", gr_last_frame_index(img));
+	fprintf_ind(file, ind, "cur frame: %d\n", img->current_frame);
+	fprintf_ind(file, ind, "animation state: %d\n", img->animation_state);
+	fprintf_ind(file, ind, "default_placement: %u\n",
+		    img->default_placement);
+}
+
+/// Dumps the frame info to `file` with an indentation of `ind` spaces.
+static void gr_dump_frame_info(FILE *file, ImageFrame *frame, int ind) {
+	if (!frame) {
+		fprintf_ind(file, ind, "Frame is NULL\n");
+		return;
+	}
+	Milliseconds now = gr_now_ms();
+	fprintf_ind(file, ind, "Frame %d\n", frame->index);
+	ind += 4;
+	if (frame->index == 0) {
+		fprintf_ind(file, ind, "NOT INITIALIZED\n");
+		return;
+	}
+	if (frame->original_filename) {
+		fprintf_ind(file, ind, "original filename (sanitized): %s\n",
+			    sanitized_filename(frame->original_filename));
+		fprintf_ind(file, ind, "original file %s\n",
+			    gr_is_original_file_still_available(frame)
+				    ? "is still available"
+				    : "is NOT available anymore");
+	}
+	if (frame->uploading_failure)
+		fprintf_ind(file, ind, "uploading failure: %s\n",
+			    image_uploading_failure_strings
+				    [frame->uploading_failure]);
+	fprintf_ind(file, ind, "gap: %d\n", frame->gap);
+	fprintf_ind(file, ind, "accessed: %ld  %s\n", frame->atime,
+		    gr_ago(now - frame->atime));
+	fprintf_ind(file, ind, "data pix size: %ux%u\n", frame->data_pix_width,
+		    frame->data_pix_height);
+	char filename[MAX_FILENAME_SIZE];
+	gr_get_frame_filename(frame, filename, MAX_FILENAME_SIZE);
+	if (access(filename, F_OK) != -1)
+		fprintf_ind(file, ind, "file: %s\n",
+			    sanitized_filename(filename));
+	else
+		fprintf_ind(file, ind, "not on disk\n");
+	fprintf_ind(file, ind, "disk size: %u KiB\n", frame->disk_size / 1024);
+	if (frame->imlib_object) {
+		unsigned ram_size = gr_frame_current_ram_size(frame);
+		fprintf_ind(file, ind,
+			    "loaded into ram, size: %d "
+			    "KiB\n",
+			    ram_size / 1024);
+	} else {
+		fprintf_ind(file, ind, "not loaded into ram\n");
+	}
+}
+
+/// Dumps the placement info to `file` with an indentation of `ind` spaces.
+static void gr_dump_placement_info(FILE *file, ImagePlacement *placement,
+				   int ind) {
+	if (!placement) {
+		fprintf_ind(file, ind, "Placement is NULL\n");
+		return;
+	}
+	Milliseconds now = gr_now_ms();
+	fprintf_ind(file, ind, "Placement %u\n", placement->placement_id);
+	ind += 4;
+	fprintf_ind(file, ind, "accessed: %ld  %s\n", placement->atime,
+		    gr_ago(now - placement->atime));
+	fprintf_ind(file, ind, "scale_mode: %u\n", placement->scale_mode);
+	fprintf_ind(file, ind, "size: %u cols x %u rows\n", placement->cols,
+		    placement->rows);
+	fprintf_ind(file, ind, "cell size: %ux%u\n", placement->scaled_cw,
+		    placement->scaled_ch);
+	PixmapTransformation *tr = &placement->pixmap_transformation;
+	fprintf_ind(file, ind, "pixmap size: %ux%u\n", tr->pixmap_w,
+		    tr->pixmap_h);
+	fprintf_ind(file, ind, "dst size: %ux%u  offset: (%d, %d)\n", tr->dst_w,
+		    tr->dst_h, tr->dst_x, tr->dst_y);
+	fprintf_ind(file, ind, "ram per frame: %u KiB\n",
+		    gr_placement_single_frame_ram_size(placement) / 1024);
+	unsigned ram_size = gr_placement_current_ram_size(placement);
+	fprintf_ind(file, ind, "ram size: %d KiB\n", ram_size / 1024);
+}
+
+/// Dumps placement pixmaps to `file` with an indentation of `ind` spaces.
+static void gr_dump_placement_pixmaps(FILE *file, ImagePlacement *placement,
+				      int ind) {
+	if (!placement)
+		return;
+	int frameidx = 1;
+	foreach_pixmap(*placement, pixmap, {
+		fprintf_ind(file, ind, "Frame %d pixmap %lu\n", frameidx,
+			    pixmap);
+		++frameidx;
+	});
+}
+
+/// Dumps the internal state (images and placements) to stderr.
+void gr_dump_state() {
+	FILE *file = stderr;
+	int ind = 0;
+	fprintf_ind(file, ind, "======= Graphics module state dump =======\n");
+	fprintf_ind(file, ind,
+		"sizeof(Image) = %lu  sizeof(ImageFrame) = %lu  "
+		"sizeof(ImagePlacement) = %lu\n",
+		sizeof(Image), sizeof(ImageFrame), sizeof(ImagePlacement));
+	fprintf_ind(file, ind, "Image count: %u\n", kh_size(images));
+	fprintf_ind(file, ind, "Placement count: %u\n", total_placement_count);
+	fprintf_ind(file, ind, "Estimated RAM usage: %ld KiB\n",
+		images_ram_size / 1024);
+	fprintf_ind(file, ind, "Estimated Disk usage: %ld KiB\n",
+		images_disk_size / 1024);
+
+	Milliseconds now = gr_now_ms();
+
+	int64_t images_ram_size_computed = 0;
+	int64_t images_disk_size_computed = 0;
+
+	Image *img = NULL;
+	ImagePlacement *placement = NULL;
+	kh_foreach_value(images, img, {
+		fprintf_ind(file, ind, "----------------\n");
+		gr_dump_image_info(file, img, 0);
+		int64_t total_disk_size_computed = 0;
+		int total_duration_computed = 0;
+		foreach_frame(*img, frame, {
+			gr_dump_frame_info(file, frame, 4);
+			if (frame->image != img)
+				fprintf_ind(file, 8,
+					    "ERROR: WRONG IMAGE POINTER\n");
+			total_duration_computed += frame->gap;
+			images_disk_size_computed += frame->disk_size;
+			total_disk_size_computed += frame->disk_size;
+			if (frame->imlib_object)
+				images_ram_size_computed +=
+					gr_frame_current_ram_size(frame);
+		});
+		if (img->total_disk_size != total_disk_size_computed) {
+			fprintf_ind(file, ind,
+				"    ERROR: total_disk_size is %u, but "
+				"computed value is %ld\n",
+				img->total_disk_size, total_disk_size_computed);
+		}
+		if (img->total_duration != total_duration_computed) {
+			fprintf_ind(file, ind,
+				"    ERROR: total_duration is %d, but computed "
+				"value is %d\n",
+				img->total_duration, total_duration_computed);
+		}
+		kh_foreach_value(img->placements, placement, {
+			gr_dump_placement_info(file, placement, 4);
+			if (placement->image != img)
+				fprintf_ind(file, 8,
+					    "ERROR: WRONG IMAGE POINTER\n");
+			fprintf_ind(file, 8,
+				    "Pixmaps:\n");
+			gr_dump_placement_pixmaps(file, placement, 12);
+			unsigned ram_size =
+				gr_placement_current_ram_size(placement);
+			images_ram_size_computed += ram_size;
+		});
+	});
+	if (images_ram_size != images_ram_size_computed) {
+		fprintf_ind(file, ind,
+			"ERROR: images_ram_size is %ld, but computed value "
+			"is %ld\n",
+			images_ram_size, images_ram_size_computed);
+	}
+	if (images_disk_size != images_disk_size_computed) {
+		fprintf_ind(file, ind,
+			"ERROR: images_disk_size is %ld, but computed value "
+			"is %ld\n",
+			images_disk_size, images_disk_size_computed);
+	}
+	fprintf_ind(file, ind, "===========================================\n");
+}
+
+/// Executes `command` with the name of the file corresponding to `image_id` as
+/// the argument. Executes xmessage with an error message on failure.
+// TODO: Currently we do this for the first frame only. Not sure what to do with
+//       animations.
+void gr_preview_image(uint32_t image_id, const char *exec) {
+	char command[256];
+	size_t len;
+	Image *img = gr_find_image(image_id);
+	if (img) {
+		ImageFrame *frame = &img->first_frame;
+		char filename[MAX_FILENAME_SIZE];
+		gr_get_frame_filename(frame, filename, MAX_FILENAME_SIZE);
+		if (frame->disk_size == 0) {
+			len = snprintf(command, 255,
+				       "xmessage 'Image with id=%u is not "
+				       "fully copied to %s'",
+				       image_id, sanitized_filename(filename));
+		} else {
+			len = snprintf(command, 255, "%s %s &", exec,
+				       sanitized_filename(filename));
+		}
+	} else {
+		len = snprintf(command, 255,
+			       "xmessage 'Cannot find image with id=%u'",
+			       image_id);
+	}
+	if (len > 255) {
+		fprintf(stderr, "error: command too long: %s\n", command);
+		snprintf(command, 255, "xmessage 'error: command too long'");
+	}
+	if (system(command) != 0) {
+		fprintf(stderr, "error: could not execute command %s\n",
+			command);
+	}
+}
+
+/// Executes `<st> -e less <file>` where <file> is the name of a temporary file
+/// containing the information about an image and placement, and <st> is
+/// specified with `st_executable`.
+void gr_show_image_info(uint32_t image_id, uint32_t placement_id,
+			uint32_t imgcol, uint32_t imgrow,
+			char is_classic_placeholder, int32_t diacritic_count,
+			char *st_executable) {
+	char filename[MAX_FILENAME_SIZE];
+	snprintf(filename, sizeof(filename), "%s/info-%u", cache_dir, image_id);
+	FILE *file = fopen(filename, "w");
+	if (!file) {
+		perror("fopen");
+		return;
+	}
+	// Basic information about the cell.
+	fprintf(file, "image_id = %u = 0x%08X\n", image_id, image_id);
+	fprintf(file, "placement_id = %u = 0x%08X\n", placement_id, placement_id);
+	fprintf(file, "column = %d, row = %d\n", imgcol, imgrow);
+	fprintf(file, "classic/unicode placeholder = %s\n",
+		is_classic_placeholder ? "classic" : "unicode");
+	fprintf(file, "original diacritic count = %d\n", diacritic_count);
+	// Information about the image and the placement.
+	Image *img = gr_find_image(image_id);
+	ImagePlacement *placement = gr_find_placement(img, placement_id);
+	gr_dump_image_info(file, img, 0);
+	gr_dump_placement_info(file, placement, 0);
+	// The text underneath this particular cell.
+	if (placement && placement->text_underneath && imgcol >= 1 &&
+	    imgrow >= 1 && imgcol <= placement->cols &&
+	    imgrow <= placement->rows) {
+		fprintf(file, "Glyph underneath:\n");
+		Glyph *glyph =
+			&placement->text_underneath[(imgrow - 1) *
+							    placement->cols +
+						    imgcol - 1];
+		fprintf(file, "    rune = 0x%08X\n", glyph->u);
+		fprintf(file, "    bg = 0x%08X\n", glyph->bg);
+		fprintf(file, "    fg = 0x%08X\n", glyph->fg);
+		fprintf(file, "    decor = 0x%08X\n", glyph->decor);
+		fprintf(file, "    mode = 0x%08X\n", glyph->mode);
+	}
+	if (img) {
+		fprintf(file, "Frames:\n");
+		foreach_frame(*img, frame, {
+			gr_dump_frame_info(file, frame, 4);
+		});
+	}
+	if (placement) {
+		fprintf(file, "Placement pixmaps:\n");
+		gr_dump_placement_pixmaps(file, placement, 4);
+	}
+	fclose(file);
+	char *argv[] = {st_executable, "-e", "less", filename, NULL};
+	if (posix_spawnp(NULL, st_executable, NULL, NULL, argv, environ) != 0) {
+		perror("posix_spawnp");
+		return;
+	}
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Appending and displaying image rectangles.
+////////////////////////////////////////////////////////////////////////////////
+
+/// Displays debug information in the rectangle using colors col1 and col2.
+static void gr_displayinfo(Drawable buf, ImageRect *rect, int col1, int col2,
+			   const char *message) {
+	int w_pix = (rect->img_end_col - rect->img_start_col) * rect->cw;
+	int h_pix = (rect->img_end_row - rect->img_start_row) * rect->ch;
+	Display *disp = imlib_context_get_display();
+	GC gc = XCreateGC(disp, buf, 0, NULL);
+	char info[MAX_INFO_LEN];
+	if (rect->placement_id)
+		snprintf(info, MAX_INFO_LEN, "%s%u/%u [%d:%d)x[%d:%d)", message,
+			 rect->image_id, rect->placement_id,
+			 rect->img_start_col, rect->img_end_col,
+			 rect->img_start_row, rect->img_end_row);
+	else
+		snprintf(info, MAX_INFO_LEN, "%s%u [%d:%d)x[%d:%d)", message,
+			 rect->image_id, rect->img_start_col, rect->img_end_col,
+			 rect->img_start_row, rect->img_end_row);
+	XSetForeground(disp, gc, col1);
+	XDrawString(disp, buf, gc, rect->screen_x_pix + 4,
+		    rect->screen_y_pix + h_pix - 3, info, strlen(info));
+	XSetForeground(disp, gc, col2);
+	XDrawString(disp, buf, gc, rect->screen_x_pix + 2,
+		    rect->screen_y_pix + h_pix - 5, info, strlen(info));
+	XFreeGC(disp, gc);
+}
+
+/// Draws a rectangle (bounding box) for debugging.
+static void gr_showrect(Drawable buf, ImageRect *rect) {
+	int w_pix = (rect->img_end_col - rect->img_start_col) * rect->cw;
+	int h_pix = (rect->img_end_row - rect->img_start_row) * rect->ch;
+	Display *disp = imlib_context_get_display();
+	GC gc = XCreateGC(disp, buf, 0, NULL);
+	XSetForeground(disp, gc, 0xFF00FF00);
+	XDrawRectangle(disp, buf, gc, rect->screen_x_pix, rect->screen_y_pix,
+		       w_pix - 1, h_pix - 1);
+	XSetForeground(disp, gc, 0xFFFF0000);
+	XDrawRectangle(disp, buf, gc, rect->screen_x_pix + 1,
+		       rect->screen_y_pix + 1, w_pix - 3, h_pix - 3);
+	XFreeGC(disp, gc);
+}
+
+/// Updates the next redraw time for the given row. Resizes the
+/// next_redraw_times array if needed.
+static void gr_update_next_redraw_time(int row, Milliseconds next_redraw) {
+	if (next_redraw == 0)
+		return;
+	if (row >= kv_size(next_redraw_times)) {
+		size_t old_size = kv_size(next_redraw_times);
+		kv_a(Milliseconds, next_redraw_times, row);
+		for (size_t i = old_size; i <= row; ++i)
+			kv_A(next_redraw_times, i) = 0;
+	}
+	Milliseconds old_value = kv_A(next_redraw_times, row);
+	if (old_value == 0 || old_value > next_redraw)
+		kv_A(next_redraw_times, row) = next_redraw;
+}
+
+/// Draws the given part of an image.
+static void gr_drawimagerect(Drawable buf, ImageRect *rect) {
+	ImagePlacement *placement =
+		gr_find_image_and_placement(rect->image_id, rect->placement_id);
+	// If the image does not exist or image display is switched off, draw
+	// the bounding box.
+	if (!placement || !graphics_display_images) {
+		gr_showrect(buf, rect);
+		if (graphics_debug_mode == GRAPHICS_DEBUG_LOG_AND_BOXES)
+			gr_displayinfo(buf, rect, 0xFF000000, 0xFFFFFFFF, "");
+		return;
+	}
+
+	Image *img = placement->image;
+
+	if (img->last_redraw < drawing_start_time) {
+		// This is the first time we draw this image in this redraw
+		// cycle. Update the frame index we are going to display. Note
+		// that currently all image placements are synchronized.
+		int old_frame = img->current_frame;
+		gr_update_frame_index(img, drawing_start_time);
+		img->last_redraw = drawing_start_time;
+	}
+
+	// Adjust next redraw times for the rows of this image rect.
+	if (img->next_redraw) {
+		for (int row = rect->screen_y_row;
+		     row <= rect->screen_y_row + rect->img_end_row -
+					  rect->img_start_row - 1; ++row) {
+			gr_update_next_redraw_time(
+				row, img->next_redraw);
+		}
+	}
+
+	// Load the frame.
+	Pixmap pixmap = gr_load_pixmap(placement, img->current_frame, rect->cw,
+				       rect->ch);
+
+	// If the image couldn't be loaded, display the bounding box.
+	if (!pixmap) {
+		gr_showrect(buf, rect);
+		if (graphics_debug_mode == GRAPHICS_DEBUG_LOG_AND_BOXES)
+			gr_displayinfo(buf, rect, 0xFF000000, 0xFFFFFFFF, "");
+		return;
+	}
+
+	// The coordinates and size (in pixels) of the src rectangle inside the
+	// box of cells (not the pixmap).
+	int src_x = rect->img_start_col * rect->cw;
+	int src_y = rect->img_start_row * rect->ch;
+	int src_w = (rect->img_end_col - rect->img_start_col) * rect->cw;
+	int src_h = (rect->img_end_row - rect->img_start_row) * rect->ch;
+	// The coordinates of the dst rectangle inside the window. The size is
+	// the same as the src size in the box (src_w, src_h).
+	int window_x = rect->screen_x_pix;
+	int window_y = rect->screen_y_pix;
+
+	Display *disp = imlib_context_get_display();
+	Visual *vis = imlib_context_get_visual();
+
+	// Create an xrender picture for the window.
+	XRenderPictFormat *win_format =
+		XRenderFindVisualFormat(disp, vis);
+	Picture window_pic =
+		XRenderCreatePicture(disp, buf, win_format, 0, NULL);
+
+	// If needed, invert the image pixmap. Note that this naive approach of
+	// inverting the pixmap is not entirely correct, because the pixmap is
+	// premultiplied. But the result is good enough to visually indicate
+	// selection.
+	if (rect->reverse) {
+		unsigned pixmap_w = placement->pixmap_transformation.pixmap_w;
+		unsigned pixmap_h = placement->pixmap_transformation.pixmap_h;
+		Pixmap invpixmap =
+			XCreatePixmap(disp, buf, pixmap_w, pixmap_h, 32);
+		XGCValues gcv = {.function = GXcopyInverted};
+		GC gc = XCreateGC(disp, invpixmap, GCFunction, &gcv);
+		XCopyArea(disp, pixmap, invpixmap, gc, 0, 0, pixmap_w,
+			  pixmap_h, 0, 0);
+		XFreeGC(disp, gc);
+		pixmap = invpixmap;
+	}
+
+	// Create a picture for the image pixmap.
+	XRenderPictFormat *pic_format =
+		XRenderFindStandardFormat(disp, PictStandardARGB32);
+	// We use RepeatPad to avoid bilinear filtering halo.
+	XRenderPictureAttributes attrs = {0};
+	attrs.repeat = RepeatPad;
+	Picture pixmap_pic = XRenderCreatePicture(disp, pixmap, pic_format,
+						  CPRepeat, &attrs);
+
+	// Since the pixmap may be of different size than the destination box of
+	// cells, we must apply a transformation to it.
+	// The XTransform structure describes a matrix to transform the
+	// destination picture coordinates (i.e. in the box) to the source
+	// coordinates (i.e. in the pixmap). We apply only scaling (translation
+	// will be applied to the src coordinates directly):
+	//
+	//   pixmap_x = picture_x * pixmap_w / dst_w
+	//   pixmap_y = picture_y * pixmap_h / dst_h
+	//
+	// Where dst_w, dst_h, pixmap_w, pixmap_h are from the placement's
+	// pixmap_transformation structure.
+	PixmapTransformation *tr = &placement->pixmap_transformation;
+	double xs = (double)tr->pixmap_w / MAX(tr->dst_w, 1);
+	double ys = (double)tr->pixmap_h / MAX(tr->dst_h, 1);
+	// clang-format off
+	XTransform xform = {{
+	    { XDoubleToFixed(xs), XDoubleToFixed( 0), XDoubleToFixed( 0) },
+	    { XDoubleToFixed( 0), XDoubleToFixed(ys), XDoubleToFixed( 0) },
+	    { XDoubleToFixed( 0), XDoubleToFixed( 0), XDoubleToFixed( 1) }
+	}};
+	// clang-format on
+	XRenderSetPictureTransform(disp, pixmap_pic, &xform);
+	XRenderSetPictureFilter(disp, pixmap_pic, FilterBilinear, NULL, 0);
+
+	// Do the translation: modify the src coordinates to make them
+	// coordinates into the picture rather than into the box.
+	src_x -= tr->dst_x;
+	src_y -= tr->dst_y;
+	// At this point src_x, src_y, src_w, src_h are coordinates of the
+	// source rectangle inside the picture (scaled pixmap).
+
+	// Now do clipping. If src coordinates are negative, adjust the dst
+	// coordinates (into the window) and the width/height. We do clipping
+	// instead of using the values as is to avoid rendering the pad area
+	// outside the pixmap.
+	if (src_x < 0) {
+		window_x += -src_x;
+		src_w -= -src_x;
+		src_x = 0;
+	}
+	if (src_y < 0) {
+		window_y += -src_y;
+		src_h -= -src_y;
+		src_y = 0;
+	}
+
+	// Adjust width and height if the src rectangle exceeds the picture.
+	src_w = MIN(src_w, tr->dst_w - src_x);
+	src_h = MIN(src_h, tr->dst_h - src_y);
+
+	// Composite the image onto the window. In the reverse mode we ignore
+	// the alpha channel of the image because the naive inversion above
+	// seems to invert the alpha channel as well.
+	int pictop = rect->reverse ? PictOpSrc : PictOpOver;
+	if (src_w > 0 && src_h > 0)
+		XRenderComposite(disp, pictop, pixmap_pic, 0, window_pic, src_x,
+				 src_y, src_x, src_y, window_x, window_y, src_w,
+				 src_h);
+
+	// Free resources
+	XRenderFreePicture(disp, pixmap_pic);
+	XRenderFreePicture(disp, window_pic);
+	if (rect->reverse)
+		XFreePixmap(disp, pixmap);
+
+	// In debug mode always draw bounding boxes and print info.
+	if (graphics_debug_mode == GRAPHICS_DEBUG_LOG_AND_BOXES) {
+		gr_showrect(buf, rect);
+		gr_displayinfo(buf, rect, 0xFF000000, 0xFFFFFFFF, "");
+	}
+}
+
+/// Removes the given image rectangle.
+static void gr_freerect(ImageRect *rect) { memset(rect, 0, sizeof(ImageRect)); }
+
+/// Returns the bottom coordinate of the rect.
+static int gr_getrectbottom(ImageRect *rect) {
+	return rect->screen_y_pix +
+	       (rect->img_end_row - rect->img_start_row) * rect->ch;
+}
+
+/// Prepare for image drawing. `cw` and `ch` are dimensions of the cell.
+void gr_start_drawing(Drawable buf, int cw, int ch) {
+	current_cw = cw;
+	current_ch = ch;
+	debug_loaded_files_counter = 0;
+	debug_loaded_pixmaps_counter = 0;
+	drawing_start_time = gr_now_ms();
+	imlib_context_set_drawable(buf);
+}
+
+/// Finish image drawing. This functions will draw all the rectangles left to
+/// draw.
+void gr_finish_drawing(Drawable buf) {
+	// Draw and then delete all known image rectangles.
+	for (size_t i = 0; i < MAX_IMAGE_RECTS; ++i) {
+		ImageRect *rect = &image_rects[i];
+		if (!rect->image_id)
+			continue;
+		gr_drawimagerect(buf, rect);
+		gr_freerect(rect);
+	}
+
+	// Compute the delay until the next redraw as the minimum of the next
+	// redraw delays for all rows.
+	Milliseconds drawing_end_time = gr_now_ms();
+	graphics_next_redraw_delay = INT_MAX;
+	for (int row = 0; row < kv_size(next_redraw_times); ++row) {
+		Milliseconds row_next_redraw = kv_A(next_redraw_times, row);
+		if (row_next_redraw > 0) {
+			int delay = MAX(graphics_animation_min_delay,
+					row_next_redraw - drawing_end_time);
+			graphics_next_redraw_delay =
+				MIN(graphics_next_redraw_delay, delay);
+		}
+	}
+
+	// In debug mode display additional info.
+	if (graphics_debug_mode) {
+		int milliseconds = drawing_end_time - drawing_start_time;
+
+		Display *disp = imlib_context_get_display();
+		GC gc = XCreateGC(disp, buf, 0, NULL);
+		const char *debug_mode_str =
+			graphics_debug_mode == GRAPHICS_DEBUG_LOG_AND_BOXES
+				? "(boxes shown) "
+				: "";
+		int redraw_delay = graphics_next_redraw_delay == INT_MAX
+					   ? -1
+					   : graphics_next_redraw_delay;
+		char info[MAX_INFO_LEN];
+		snprintf(info, MAX_INFO_LEN,
+			 "%sRender time: %d ms  ram %ld K  disk %ld K  count "
+			 "%d  cell %dx%d  delay %d",
+			 debug_mode_str, milliseconds, images_ram_size / 1024,
+			 images_disk_size / 1024, kh_size(images), current_cw,
+			 current_ch, redraw_delay);
+		XSetForeground(disp, gc, 0xFF000000);
+		XFillRectangle(disp, buf, gc, 0, 0, 600, 16);
+		XSetForeground(disp, gc, 0xFFFFFFFF);
+		XDrawString(disp, buf, gc, 0, 14, info, strlen(info));
+		XFreeGC(disp, gc);
+
+		if (milliseconds > 0) {
+			fprintf(stderr, "%s  (loaded %d files, %d pixmaps)\n",
+				info, debug_loaded_files_counter,
+				debug_loaded_pixmaps_counter);
+		}
+	}
+
+	// Check the limits in case we have used too much ram for placements.
+	gr_check_limits();
+}
+
+// Add an image rectangle to the list of rectangles to draw.
+void gr_append_imagerect(Drawable buf, uint32_t image_id, uint32_t placement_id,
+			 int img_start_col, int img_end_col, int img_start_row,
+			 int img_end_row, int x_col, int y_row, int x_pix,
+			 int y_pix, int cw, int ch, int reverse) {
+	current_cw = cw;
+	current_ch = ch;
+
+	ImageRect new_rect;
+	new_rect.image_id = image_id;
+	new_rect.placement_id = placement_id;
+	new_rect.img_start_col = img_start_col;
+	new_rect.img_end_col = img_end_col;
+	new_rect.img_start_row = img_start_row;
+	new_rect.img_end_row = img_end_row;
+	new_rect.screen_y_row = y_row;
+	new_rect.screen_x_pix = x_pix;
+	new_rect.screen_y_pix = y_pix;
+	new_rect.ch = ch;
+	new_rect.cw = cw;
+	new_rect.reverse = reverse;
+
+	// Display some red text in debug mode.
+	if (graphics_debug_mode == GRAPHICS_DEBUG_LOG_AND_BOXES)
+		gr_displayinfo(buf, &new_rect, 0xFF000000, 0xFFFF0000, "? ");
+
+	// If it's the empty image (image_id=0) or an empty rectangle, do
+	// nothing.
+	if (image_id == 0 || img_end_col - img_start_col <= 0 ||
+	    img_end_row - img_start_row <= 0)
+		return;
+	// Try to find a rect to merge with.
+	ImageRect *free_rect = NULL;
+	for (size_t i = 0; i < MAX_IMAGE_RECTS; ++i) {
+		ImageRect *rect = &image_rects[i];
+		if (rect->image_id == 0) {
+			if (!free_rect)
+				free_rect = rect;
+			continue;
+		}
+		if (rect->image_id != image_id ||
+		    rect->placement_id != placement_id || rect->cw != cw ||
+		    rect->ch != ch || rect->reverse != reverse)
+			continue;
+		// We only support the case when the new stripe is added to the
+		// bottom of an existing rectangle and they are perfectly
+		// aligned.
+		if (rect->img_end_row == img_start_row &&
+		    gr_getrectbottom(rect) == y_pix) {
+			if (rect->img_start_col == img_start_col &&
+			    rect->img_end_col == img_end_col &&
+			    rect->screen_x_pix == x_pix) {
+				rect->img_end_row = img_end_row;
+				return;
+			}
+		}
+	}
+	// If we haven't merged the new rect with any existing rect, and there
+	// is no free rect, we have to render one of the existing rects.
+	if (!free_rect) {
+		for (size_t i = 0; i < MAX_IMAGE_RECTS; ++i) {
+			ImageRect *rect = &image_rects[i];
+			if (!free_rect || gr_getrectbottom(free_rect) >
+						  gr_getrectbottom(rect))
+				free_rect = rect;
+		}
+		gr_drawimagerect(buf, free_rect);
+		gr_freerect(free_rect);
+	}
+	// Start a new rectangle in `free_rect`.
+	*free_rect = new_rect;
+}
+
+/// Mark rows containing animations as dirty if it's time to redraw them. Must
+/// be called right after `gr_start_drawing`.
+void gr_mark_dirty_animations(int *dirty, int rows) {
+	if (rows < kv_size(next_redraw_times))
+		kv_size(next_redraw_times) = rows;
+	if (rows * 2 < kv_max(next_redraw_times))
+		kv_resize(Milliseconds, next_redraw_times, rows);
+	for (int i = 0; i < MIN(rows, kv_size(next_redraw_times)); ++i) {
+		if (dirty[i]) {
+			kv_A(next_redraw_times, i) = 0;
+			continue;
+		}
+		Milliseconds next_update = kv_A(next_redraw_times, i);
+		if (next_update > 0 && next_update <= drawing_start_time) {
+			dirty[i] = 1;
+			kv_A(next_redraw_times, i) = 0;
+		}
+	}
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Command parsing and handling.
+////////////////////////////////////////////////////////////////////////////////
+
+/// A parsed kitty graphics protocol command.
+typedef struct {
+	/// The command itself, without the 'G'.
+	char *command;
+	/// The payload (after ';').
+	char *payload;
+	/// 'a=', may be 't', 'q', 'f', 'T', 'p', 'd', 'a'.
+	char action;
+	/// 'q=', 1 to suppress OK response, 2 to suppress errors too.
+	int quiet;
+	/// 'f=', use 24 or 32 for raw pixel data, 100 to autodetect with
+	/// imlib2. If 'f=0', will try to load with imlib2, then fallback to
+	/// 32-bit pixel data.
+	int format;
+	/// 'o=', may be 'z' for RFC 1950 ZLIB.
+	int compression;
+	/// 't=', may be 'f', 't' or 'd'.
+	char transmission_medium;
+	/// 'd='
+	char delete_specifier;
+	/// 's=', 'v=', if 'a=t' or 'a=T', used only when 'f=24' or 'f=32'.
+	/// When 'a=f', this is the size of the frame rectangle when composed on
+	/// top of another frame.
+	int frame_pix_width, frame_pix_height;
+	/// 'x=', 'y=' - top-left corner of the source rectangle.
+	int src_pix_x, src_pix_y;
+	/// 'w=', 'h=' - width and height of the source rectangle.
+	int src_pix_width, src_pix_height;
+	/// 'r=', 'c='
+	int rows, columns;
+	/// 'i='
+	uint32_t image_id;
+	/// 'I='
+	uint32_t image_number;
+	/// 'p='
+	uint32_t placement_id;
+	/// 'm=', may be 0 or 1.
+	int more;
+	/// True if turns out that this command is a continuation of a data
+	/// transmission and not the first one for this image. Populated by
+	/// `gr_handle_transmit_command`.
+	char is_direct_transmission_continuation;
+	/// 'S=', used to check the size of uploaded data.
+	int size;
+	/// The offset of the frame image data in the shared memory ('O=').
+	unsigned offset;
+	/// 'U=', whether it's a virtual placement for Unicode placeholders.
+	int virtual;
+	/// 'C=', if true, do not move the cursor when displaying this placement
+	/// (non-virtual placements only).
+	char do_not_move_cursor;
+	// ---------------------------------------------------------------------
+	// Animation-related fields. Their keys often overlap with keys of other
+	// commands, so these make sense only if the action is 'a=f' (frame
+	// transmission) or 'a=a' (animation control).
+	//
+	// 'x=' and 'y=', the relative position of the frame image when it's
+	// composed on top of another frame.
+	int frame_dst_pix_x, frame_dst_pix_y;
+	/// 'X=', 'X=1' to replace colors instead of alpha blending on top of
+	/// the background color or frame.
+	char replace_instead_of_blending;
+	/// 'Y=', the background color in the 0xRRGGBBAA format (still
+	/// transmitted as a decimal number).
+	uint32_t background_color;
+	/// (Only for 'a=f'). 'c=', the 1-based index of the background frame.
+	int background_frame;
+	/// (Only for 'a=a'). 'c=', sets the index of the current frame.
+	int current_frame;
+	/// 'r=', the 1-based index of the frame to edit.
+	int edit_frame;
+	/// 'z=', the duration of the frame. Zero if not specified, negative if
+	/// the frame is gapless (i.e. skipped).
+	int gap;
+	/// (Only for 'a=a'). 's=', if non-zero, sets the state of the
+	/// animation, 1 to stop, 2 to run in loading mode, 3 to loop.
+	int animation_state;
+	/// (Only for 'a=a'). 'v=', if non-zero, sets the number of times the
+	/// animation will loop. 1 to loop infinitely, N to loop N-1 times.
+	int loops;
+} GraphicsCommand;
+
+/// Replaces all non-printed characters in `str` with '?' and truncates the
+/// string to `max_size`, maybe inserting ellipsis at the end.
+static void sanitize_str(char *str, size_t max_size) {
+	assert(max_size >= 4);
+	for (size_t i = 0; i < max_size; ++i) {
+		unsigned c = str[i];
+		if (c == '\0')
+			return;
+		if (c >= 128 || !isprint(c))
+			str[i] = '?';
+	}
+	str[max_size - 1] = '\0';
+	str[max_size - 2] = '.';
+	str[max_size - 3] = '.';
+	str[max_size - 4] = '.';
+}
+
+/// A non-destructive version of `sanitize_str`. Uses a static buffer, so be
+/// careful.
+static const char *sanitized_filename(const char *str) {
+	static char buf[MAX_FILENAME_SIZE];
+	strncpy(buf, str, sizeof(buf));
+	sanitize_str(buf, sizeof(buf));
+	return buf;
+}
+
+/// Creates a response to the current command in `graphics_command_result`.
+static void gr_createresponse(uint32_t image_id, uint32_t image_number,
+			      uint32_t placement_id, const char *msg) {
+	if (!image_id && !image_number && !placement_id) {
+		// Nobody expects the response in this case, so just print it to
+		// stderr.
+		fprintf(stderr,
+			"error: No image id or image number or placement_id, "
+			"but still there is a response: %s\n",
+			msg);
+		return;
+	}
+	char *buf = graphics_command_result.response;
+	size_t maxlen = MAX_GRAPHICS_RESPONSE_LEN;
+	size_t written;
+	written = snprintf(buf, maxlen, "\033_G");
+	buf += written;
+	maxlen -= written;
+	if (image_id) {
+		written = snprintf(buf, maxlen, "i=%u,", image_id);
+		buf += written;
+		maxlen -= written;
+	}
+	if (image_number) {
+		written = snprintf(buf, maxlen, "I=%u,", image_number);
+		buf += written;
+		maxlen -= written;
+	}
+	if (placement_id) {
+		written = snprintf(buf, maxlen, "p=%u,", placement_id);
+		buf += written;
+		maxlen -= written;
+	}
+	buf[-1] = ';';
+	written = snprintf(buf, maxlen, "%s\033\\", msg);
+	buf += written;
+	maxlen -= written;
+	buf[-2] = '\033';
+	buf[-1] = '\\';
+}
+
+/// Creates the 'OK' response to the current command, unless suppressed or a
+/// non-final data transmission.
+static void gr_reportsuccess_cmd(GraphicsCommand *cmd) {
+	if (cmd->quiet < 1 && !cmd->more)
+		gr_createresponse(cmd->image_id, cmd->image_number,
+				  cmd->placement_id, "OK");
+}
+
+/// Creates the 'OK' response to the current command (unless suppressed).
+static void gr_reportsuccess_frame(ImageFrame *frame) {
+	uint32_t id = frame->image->query_id ? frame->image->query_id
+					     : frame->image->image_id;
+	if (frame->quiet < 1)
+		gr_createresponse(id, frame->image->image_number,
+				  frame->image->initial_placement_id, "OK");
+}
+
+/// Creates an error response to the current command (unless suppressed).
+static void gr_reporterror_cmd(GraphicsCommand *cmd, const char *format, ...) {
+	char errmsg[MAX_GRAPHICS_RESPONSE_LEN];
+	graphics_command_result.error = 1;
+	va_list args;
+	va_start(args, format);
+	vsnprintf(errmsg, MAX_GRAPHICS_RESPONSE_LEN, format, args);
+	va_end(args);
+
+	fprintf(stderr, "%s  in command: %s\n", errmsg, cmd->command);
+	if (cmd->quiet < 2)
+		gr_createresponse(cmd->image_id, cmd->image_number,
+				  cmd->placement_id, errmsg);
+}
+
+/// Creates an error response to the current command (unless suppressed).
+static void gr_reporterror_frame(ImageFrame *frame, const char *format, ...) {
+	char errmsg[MAX_GRAPHICS_RESPONSE_LEN];
+	graphics_command_result.error = 1;
+	va_list args;
+	va_start(args, format);
+	vsnprintf(errmsg, MAX_GRAPHICS_RESPONSE_LEN, format, args);
+	va_end(args);
+
+	if (!frame) {
+		fprintf(stderr, "%s\n", errmsg);
+		gr_createresponse(0, 0, 0, errmsg);
+	} else {
+		uint32_t id = frame->image->query_id ? frame->image->query_id
+						     : frame->image->image_id;
+		fprintf(stderr, "%s  id=%u\n", errmsg, id);
+		if (frame->quiet < 2)
+			gr_createresponse(id, frame->image->image_number,
+					  frame->image->initial_placement_id,
+					  errmsg);
+	}
+}
+
+/// Loads an image and creates a success/failure response. Returns `frame`, or
+/// NULL if it's a query action and the image was deleted.
+static ImageFrame *gr_loadimage_and_report(ImageFrame *frame) {
+	gr_load_imlib_object(frame);
+	if (!frame->imlib_object) {
+		gr_reporterror_frame(frame, "EBADF: could not load image");
+	} else {
+		gr_reportsuccess_frame(frame);
+	}
+	// If it was a query action, discard the image.
+	if (frame->image->query_id) {
+		gr_delete_image(frame->image);
+		return NULL;
+	}
+	return frame;
+}
+
+/// Creates an appropriate uploading failure response to the current command.
+static void gr_reportuploaderror(ImageFrame *frame) {
+	switch (frame->uploading_failure) {
+	case 0:
+		return;
+	case ERROR_CANNOT_OPEN_CACHED_FILE:
+		gr_reporterror_frame(frame,
+				   "EIO: could not create a file for image");
+		break;
+	case ERROR_OVER_SIZE_LIMIT:
+		gr_reporterror_frame(
+			frame,
+			"EFBIG: the size of the uploaded image exceeded "
+			"the image size limit %u",
+			graphics_max_single_image_file_size);
+		break;
+	case ERROR_UNEXPECTED_SIZE:
+		gr_reporterror_frame(frame,
+				   "EINVAL: the size of the uploaded image %u "
+				   "doesn't match the expected size %u",
+				   frame->disk_size, frame->expected_size);
+		break;
+	};
+}
+
+/// Displays a non-virtual placement. This functions records the information in
+/// `graphics_command_result`, the placeholder itself is created by the terminal
+/// after handling the current command in the graphics module.
+static void gr_display_nonvirtual_placement(ImagePlacement *placement) {
+	if (placement->virtual)
+		return;
+	if (placement->image->first_frame.status < STATUS_RAM_LOADING_SUCCESS)
+		return;
+	// Infer the placement size if needed.
+	gr_infer_placement_size_maybe(placement);
+	// Populate the information about the placeholder which will be created
+	// by the terminal.
+	graphics_command_result.create_placeholder = 1;
+	graphics_command_result.placeholder.image_id = placement->image->image_id;
+	graphics_command_result.placeholder.placement_id = placement->placement_id;
+	graphics_command_result.placeholder.columns = placement->cols;
+	graphics_command_result.placeholder.rows = placement->rows;
+	graphics_command_result.placeholder.do_not_move_cursor =
+		placement->do_not_move_cursor;
+	placement->text_underneath =
+		calloc(placement->rows * placement->cols, sizeof(Glyph));
+	graphics_command_result.placeholder.text_underneath =
+		placement->text_underneath;
+	GR_LOG("Creating a placeholder for %u/%u  %d x %d\n",
+	       placement->image->image_id, placement->placement_id,
+	       placement->cols, placement->rows);
+}
+
+/// Marks the rows that are occupied by the image as dirty.
+static void gr_schedule_image_redraw(Image *img) {
+	if (!img)
+		return;
+	gr_schedule_image_redraw_by_id(img->image_id);
+}
+
+/// Closes the file currently being uploaded. This doesn't necessarily finish
+/// the upload since the file may be reopened.
+static void gr_close_current_upload_file() {
+	Image *img = gr_find_image(current_upload_image_id);
+	ImageFrame *frame = gr_get_frame(img, current_upload_frame_index);
+	gr_close_disk_cache_file(frame);
+}
+
+/// Sets the current image and frame being uploaded. Closes the previous upload
+/// file if it's changed. If `frame` is NULL, clears the current upload
+/// image/frame.
+static void gr_set_current_upload_frame(ImageFrame *frame) {
+	if (frame) {
+		if (current_upload_image_id != frame->image->image_id ||
+		    current_upload_frame_index != frame->index) {
+			gr_close_current_upload_file();
+		}
+		current_upload_image_id = frame->image->image_id;
+		current_upload_frame_index = frame->index;
+		GR_LOG("Set current_upload_image_id = %u, "
+		       "current_upload_frame_index = %u\n",
+		       current_upload_image_id, current_upload_frame_index);
+	} else {
+		gr_close_current_upload_file();
+		current_upload_image_id = 0;
+		current_upload_frame_index = 0;
+		GR_LOG("Set current_upload_image_id = 0\n");
+	}
+}
+
+/// Returns whether direct transmission continuation is allowed for the given
+/// command and frame.
+static int gr_transmission_continuation_is_allowed(GraphicsCommand *cmd,
+						   ImageFrame *frame) {
+	if (!frame || frame->status != STATUS_UPLOADING)
+		return 0;
+
+	// If it's the same image and frame as the current upload, allow it.
+	if (current_upload_image_id == frame->image->image_id &&
+	    current_upload_frame_index == frame->index)
+		return 1;
+
+	// Otherwise it's a continuation of an interrupted upload. The kitty
+	// graphics protocol doesn't allow interleaving of direct transmission
+	// with other commands, so interrupted uploads must be aborted. However,
+	// we still allow it as an extension, because it's useful for
+	// protocol-unaware multiplexer. We check that there are no
+	// contradictions, and the time since the last upload activity is small.
+
+	if (cmd->size && cmd->size != frame->expected_size) {
+		fprintf(stderr, "warning: Not resuming interrupted upload "
+				"because of expected size mismatch\n");
+		return 0;
+	}
+	if (cmd->format && cmd->format != frame->format) {
+		fprintf(stderr, "warning: Not resuming interrupted upload "
+				"because of format mismatch\n");
+		return 0;
+	}
+	if (cmd->compression && cmd->compression != frame->compression) {
+		fprintf(stderr, "warning: Not resuming interrupted upload "
+				"because of compression mismatch\n");
+		return 0;
+	}
+	if ((cmd->frame_pix_width &&
+	     cmd->frame_pix_width != frame->data_pix_width) ||
+	    (cmd->frame_pix_height &&
+	     cmd->frame_pix_height != frame->data_pix_height) ||
+	    (cmd->background_color &&
+	     cmd->background_color != frame->background_color) ||
+	    (cmd->background_frame &&
+	     cmd->background_frame != frame->background_frame_index) ||
+	    (cmd->gap && cmd->gap != frame->gap) ||
+	    (cmd->replace_instead_of_blending &&
+	     cmd->replace_instead_of_blending != !frame->blend)) {
+		fprintf(stderr, "warning: Not resuming interrupted upload "
+				"because of frame parameters mismatch\n");
+		return 0;
+	}
+
+	Milliseconds now = gr_now_ms();
+	if (now - frame->atime > graphics_direct_transmission_timeout_ms) {
+		fprintf(stderr, "warning: Not resuming interrupted upload "
+				"because of time out\n");
+		return 0;
+	}
+
+	return 1;
+}
+
+/// Appends `data` to the on-disk cache file of the frame `frame`. Creates the
+/// file if it doesn't exist. Updates `frame->disk_size` and the total disk
+/// size. Returns 1 on success and 0 on failure.
+static int gr_append_raw_data_to_file(ImageFrame *frame, const char *data,
+				      size_t data_size) {
+	// If there is no open file corresponding to the image, create it.
+	if (!frame->open_file) {
+		gr_make_sure_tmpdir_exists();
+		char filename[MAX_FILENAME_SIZE];
+		gr_get_frame_filename(frame, filename, MAX_FILENAME_SIZE);
+		FILE *file = fopen(filename, frame->disk_size ? "a" : "w");
+		if (!file)
+			return 0;
+		frame->open_file = file;
+	}
+
+	// Write data to the file and update disk size variables.
+	fwrite(data, 1, data_size, frame->open_file);
+	frame->disk_size += data_size;
+	frame->image->total_disk_size += data_size;
+	images_disk_size += data_size;
+	gr_touch_frame(frame);
+	return 1;
+}
+
+/// Appends data from `payload` to the frame `frame` when using direct
+/// transmission. Note that we report errors only for the final command
+/// (`!more`) to avoid spamming the client. If the frame is not specified, use
+/// the image id and frame index we are currently uploading.
+static void gr_append_data(ImageFrame *frame, const char *payload, int more) {
+	gr_set_current_upload_frame(frame);
+
+	if (frame->status != STATUS_UPLOADING) {
+		if (!more)
+			gr_reportuploaderror(frame);
+		gr_set_current_upload_frame(NULL);
+		return;
+	}
+
+	// Decode the data.
+	size_t data_size = 0;
+	char *data = gr_base64dec(payload, &data_size);
+
+	GR_LOG("appending %u + %zu = %zu bytes\n", frame->disk_size, data_size,
+	       frame->disk_size + data_size);
+
+	// Do not append this data if the image exceeds the size limit.
+	if (frame->disk_size + data_size >
+		    graphics_max_single_image_file_size ||
+	    frame->expected_size > graphics_max_single_image_file_size) {
+		free(data);
+		gr_delete_imagefile(frame);
+		frame->uploading_failure = ERROR_OVER_SIZE_LIMIT;
+		if (!more)
+			gr_reportuploaderror(frame);
+		gr_set_current_upload_frame(NULL);
+		return;
+	}
+
+	// Append the data to the file.
+	if (!gr_append_raw_data_to_file(frame, data, data_size)) {
+		frame->status = STATUS_UPLOADING_ERROR;
+		frame->uploading_failure = ERROR_CANNOT_OPEN_CACHED_FILE;
+		if (!more)
+			gr_reportuploaderror(frame);
+		gr_set_current_upload_frame(NULL);
+		return;
+	}
+	free(data);
+
+	if (!more) {
+		gr_set_current_upload_frame(NULL);
+		frame->status = STATUS_UPLOADING_SUCCESS;
+		uint32_t placement_id = frame->image->default_placement;
+		if (frame->expected_size &&
+		    frame->expected_size != frame->disk_size) {
+			// Report failure if the uploaded image size doesn't
+			// match the expected size.
+			frame->status = STATUS_UPLOADING_ERROR;
+			frame->uploading_failure = ERROR_UNEXPECTED_SIZE;
+			gr_reportuploaderror(frame);
+		} else {
+			// Make sure to redraw all existing image instances.
+			gr_schedule_image_redraw(frame->image);
+			// Try to load the image into ram and report the result.
+			frame = gr_loadimage_and_report(frame);
+			// If there is a non-virtual image placement, we may
+			// need to display it.
+			if (frame && frame->index == 1) {
+				Image *img = frame->image;
+				ImagePlacement *placement = NULL;
+				kh_foreach_value(img->placements, placement, {
+					gr_display_nonvirtual_placement(placement);
+				});
+			}
+		}
+	}
+
+	// Check whether we need to delete old images.
+	gr_check_limits();
+}
+
+/// Finds the image either by id or by number specified in the command.
+static Image *gr_find_image_for_command(GraphicsCommand *cmd) {
+	if (cmd->image_id)
+		return gr_find_image(cmd->image_id);
+	Image *img = NULL;
+	// If the image number is not specified, we can't find the image, unless
+	// it's a put command, in which case we will try the last image.
+	if (cmd->image_number == 0 && cmd->action == 'p')
+		img = gr_find_image(last_image_id);
+	else
+		img = gr_find_image_by_number(cmd->image_number);
+	return img;
+}
+
+/// Creates a new image or a new frame in an existing image (depending on the
+/// command's action) and initializes its parameters from the command.
+static ImageFrame *gr_new_image_or_frame_from_command(GraphicsCommand *cmd) {
+	if (cmd->format != 0 && cmd->format != 32 && cmd->format != 24 &&
+	    cmd->compression != 0) {
+		gr_reporterror_cmd(cmd, "EINVAL: compression is supported only "
+					"for raw pixel data (f=32 or f=24)");
+		// Even though we report an error, we still create an image.
+	}
+
+	Image *img = NULL;
+	if (cmd->action == 'f') {
+		// If it's a frame transmission action, there must be an
+		// existing image.
+		img = gr_find_image_for_command(cmd);
+		if (img) {
+			cmd->image_id = img->image_id;
+		} else {
+			gr_reporterror_cmd(cmd, "ENOENT: image not found");
+			return NULL;
+		}
+	} else {
+		// Otherwise create a new image object. If the action is `q`,
+		// we'll use random id instead of the one specified in the
+		// command.
+		uint32_t image_id = cmd->action == 'q' ? 0 : cmd->image_id;
+		img = gr_new_image(image_id);
+		if (!img)
+			return NULL;
+		if (cmd->action == 'q')
+			img->query_id = cmd->image_id;
+		else if (!cmd->image_id)
+			cmd->image_id = img->image_id;
+		// Set the image number.
+		img->image_number = cmd->image_number;
+	}
+
+	ImageFrame *frame = gr_append_new_frame(img);
+	// Initialize the frame.
+	frame->expected_size = cmd->size;
+	// The default format is 32.
+	frame->format = cmd->format ? cmd->format : 32;
+	frame->compression = cmd->compression;
+	frame->background_color = cmd->background_color;
+	frame->background_frame_index = cmd->background_frame;
+	frame->gap = cmd->gap;
+	img->total_duration += frame->gap;
+	frame->blend = !cmd->replace_instead_of_blending;
+	frame->data_pix_width = cmd->frame_pix_width;
+	frame->data_pix_height = cmd->frame_pix_height;
+	if (cmd->action == 'f') {
+		frame->x = cmd->frame_dst_pix_x;
+		frame->y = cmd->frame_dst_pix_y;
+	}
+	// If the expected size is not specified, we can infer it from the pixel
+	// width and height if the format is 24 or 32 and there is no
+	// compression. This is required for the shared memory transmission.
+	if (!frame->expected_size && !frame->compression &&
+	    (frame->format == 24 || frame->format == 32)) {
+		frame->expected_size = frame->data_pix_width *
+				       frame->data_pix_height *
+				       (frame->format / 8);
+	}
+	// We save the quietness information in the frame because for direct
+	// transmission subsequent transmission command won't contain this info.
+	frame->quiet = cmd->quiet;
+	return frame;
+}
+
+/// Removes a file if it actually looks like a temporary file.
+static void gr_delete_tmp_file(const char *filename) {
+	if (strstr(filename, "tty-graphics-protocol") == NULL)
+		return;
+	if (strstr(filename, "/tmp/") != filename) {
+		const char *tmpdir = getenv("TMPDIR");
+		if (!tmpdir || !tmpdir[0] ||
+		    strstr(filename, tmpdir) != filename)
+			return;
+	}
+	unlink(filename);
+}
+
+/// Copy the image file `frame->original_filename` to the cache directory. This
+/// is done when the image is transmitted via file transfer, or when we have
+/// evicted the image from the disk cache and need to restore it.
+/// If `cmd` is not NULL, it's used to report errors, otherwise errors are only
+/// printed to stderr.
+static void gr_copy_imagefile(ImageFrame *frame, GraphicsCommand *cmd) {
+	GR_LOG("Copying image %s\n",
+	       sanitized_filename(frame->original_filename));
+	// Stat the file and check that it's a regular file and not too big.
+	struct stat st;
+	int stat_res = stat(frame->original_filename, &st);
+
+	const char *stat_error = NULL;
+	if (stat_res)
+		stat_error = strerror(errno);
+	else if (!S_ISREG(st.st_mode))
+		stat_error = "Not a regular file";
+	else if (st.st_size == 0)
+		stat_error = "The size of the file is zero";
+	else if (st.st_size > graphics_max_single_image_file_size)
+		stat_error = "The file is too large";
+	if (stat_error) {
+		fprintf(stderr, "Could not load the file %s: %s\n",
+			sanitized_filename(frame->original_filename),
+			stat_error);
+		if (cmd)
+			gr_reporterror_cmd(cmd, "EBADF: %s", stat_error);
+		frame->status = STATUS_UPLOADING_ERROR;
+		frame->uploading_failure = ERROR_CANNOT_COPY_FILE;
+		return;
+	}
+
+	// Check the expected size if specified.
+	if (frame->expected_size && frame->expected_size != st.st_size) {
+		fprintf(stderr,
+			"Could not load, the size doesn't match: %s expected "
+			"%u vs actual %ld\n",
+			sanitized_filename(frame->original_filename),
+			frame->expected_size, st.st_size);
+		// The file has unexpected size.
+		frame->status = STATUS_UPLOADING_ERROR;
+		frame->uploading_failure = ERROR_UNEXPECTED_SIZE;
+		if (cmd)
+			gr_reportuploaderror(frame);
+		return;
+	}
+
+	// If we know the original modification time, we are trying to restore
+	// the evicted image file. Check that the modification time matches.
+	if (frame->original_file_mtime &&
+	    frame->original_file_mtime != st.st_mtime) {
+		fprintf(stderr, "Could not load, the mtime doesn't match: %s\n",
+			sanitized_filename(frame->original_filename));
+		frame->status = STATUS_UPLOADING_ERROR;
+		frame->uploading_failure = ERROR_MTIME_MISMATCH;
+		if (cmd)
+			gr_reportuploaderror(frame);
+		return;
+	}
+
+	frame->original_file_mtime = st.st_mtime;
+
+	gr_make_sure_tmpdir_exists();
+	// Build the filename for the cached copy of the file.
+	char cache_filename[MAX_FILENAME_SIZE];
+	gr_get_frame_filename(frame, cache_filename, MAX_FILENAME_SIZE);
+	// We will create a symlink to the original file, and
+	// then copy the file to the temporary cache dir. We do
+	// this symlink trick mostly to be able to use cp for
+	// copying, and avoid escaping file name characters when
+	// calling system at the same time.
+	char tmp_filename_symlink[MAX_FILENAME_SIZE + 4] = {0};
+	strcat(tmp_filename_symlink, cache_filename);
+	strcat(tmp_filename_symlink, ".sym");
+	char command[MAX_FILENAME_SIZE + 256];
+	size_t len = snprintf(command, MAX_FILENAME_SIZE + 255, "cp '%s' '%s'",
+			      tmp_filename_symlink, cache_filename);
+
+	if (len > MAX_FILENAME_SIZE + 255 ||
+	    symlink(frame->original_filename, tmp_filename_symlink) ||
+	    system(command) != 0) {
+		fprintf(stderr,
+			"Could not copy the image "
+			"%s (symlink %s) to %s",
+			sanitized_filename(frame->original_filename),
+			tmp_filename_symlink, cache_filename);
+		if (cmd)
+			gr_reporterror_cmd(cmd, "EBADF: could not copy the "
+						"image to the cache dir");
+		frame->status = STATUS_UPLOADING_ERROR;
+		frame->uploading_failure = ERROR_CANNOT_COPY_FILE;
+		// Delete the symlink.
+		unlink(tmp_filename_symlink);
+		return;
+	}
+
+	// Delete the symlink.
+	unlink(tmp_filename_symlink);
+	// Set the status and update disk size variables.
+	frame->status = STATUS_UPLOADING_SUCCESS;
+	frame->disk_size = st.st_size;
+	frame->image->total_disk_size += st.st_size;
+	images_disk_size += frame->disk_size;
+}
+
+/// Tries to restore the image file for `frame` if the original file is still
+/// available.
+static void gr_try_restore_imagefile(ImageFrame *frame) {
+	if (frame->disk_size != 0)
+		return;
+	if (gr_is_original_file_still_available(frame))
+		gr_copy_imagefile(frame, NULL);
+}
+
+/// Handles a data transmission command.
+static ImageFrame *gr_handle_transmit_command(GraphicsCommand *cmd) {
+	// The default is direct transmission.
+	if (!cmd->transmission_medium)
+		cmd->transmission_medium = 'd';
+
+	// If neither id, nor image number is specified, and the transmission
+	// medium is 'd' (or unspecified), and there is an active direct upload,
+	// this is a continuation of the upload.
+	if (current_upload_image_id != 0 && cmd->image_id == 0 &&
+	    cmd->image_number == 0 && cmd->transmission_medium == 'd') {
+		cmd->image_id = current_upload_image_id;
+		GR_LOG("No images id is specified, continuing uploading %u\n",
+		       cmd->image_id);
+	}
+
+	ImageFrame *frame = NULL;
+	if (cmd->transmission_medium == 'f' ||
+	    cmd->transmission_medium == 't') {
+		// File transmission.
+		// Create a new image or a new frame of an existing image.
+		frame = gr_new_image_or_frame_from_command(cmd);
+		if (!frame)
+			return NULL;
+		last_image_id = frame->image->image_id;
+		// Decode the filename.
+		frame->original_filename = gr_base64dec(cmd->payload, NULL);
+		// Copy the file to the cache directory.
+		gr_copy_imagefile(frame, cmd);
+		if (frame->status == STATUS_UPLOADING_SUCCESS) {
+			// Everything seems fine, try to load and redraw
+			// existing instances.
+			gr_schedule_image_redraw(frame->image);
+			frame = gr_loadimage_and_report(frame);
+		}
+		// Delete the original file if it's temporary.
+		if (cmd->transmission_medium == 't')
+			gr_delete_tmp_file(frame->original_filename);
+		gr_check_limits();
+	} else if (cmd->transmission_medium == 'd') {
+		// Direct transmission (default if 't' is not specified).
+		frame = gr_get_last_frame(gr_find_image_for_command(cmd));
+		if (gr_transmission_continuation_is_allowed(cmd, frame)) {
+			// This is a continuation of the previous transmission.
+			cmd->is_direct_transmission_continuation = 1;
+			cmd->image_id = frame->image->image_id;
+			gr_append_data(frame, cmd->payload, cmd->more);
+			return frame;
+		}
+		// Otherwise create a new image or frame structure.
+		frame = gr_new_image_or_frame_from_command(cmd);
+		if (!frame)
+			return NULL;
+		last_image_id = frame->image->image_id;
+		frame->status = STATUS_UPLOADING;
+		// Start appending data.
+		gr_append_data(frame, cmd->payload, cmd->more);
+	} else if (cmd->transmission_medium == 's') {
+		// Shared memory transmission.
+		// Create a new image or a new frame of an existing image.
+		frame = gr_new_image_or_frame_from_command(cmd);
+		if (!frame)
+			return NULL;
+		last_image_id = frame->image->image_id;
+		// Check that we know the size.
+		if (!frame->expected_size) {
+			frame->status = STATUS_UPLOADING_ERROR;
+			frame->uploading_failure = ERROR_UNEXPECTED_SIZE;
+			gr_reporterror_cmd(
+				cmd, "EINVAL: the size of the image is not "
+				     "specified and cannot be inferred");
+			return frame;
+		}
+		// Check the data size limit.
+		if (frame->expected_size > graphics_max_single_image_file_size) {
+			frame->uploading_failure = ERROR_OVER_SIZE_LIMIT;
+			gr_reportuploaderror(frame);
+			return frame;
+		}
+		// Decode the filename.
+		char *original_filename = gr_base64dec(cmd->payload, NULL);
+		GR_LOG("Loading image from shared memory %s\n",
+		       sanitized_filename(original_filename));
+		// Open the shared memory object.
+		int fd = shm_open(original_filename, O_RDONLY, 0);
+		if (fd == -1) {
+			gr_reporterror_cmd(cmd, "EBADF: shm_open: %s",
+					   strerror(errno));
+			frame->status = STATUS_UPLOADING_ERROR;
+			frame->uploading_failure = ERROR_CANNOT_OPEN_SHM;
+			fprintf(stderr, "shm_open failed for %s\n",
+				sanitized_filename(original_filename));
+			shm_unlink(original_filename);
+			free(original_filename);
+			return frame;
+		}
+		shm_unlink(original_filename);
+		free(original_filename);
+		// The offset we pass to mmap must be a multiple of the page
+		// size. If it's not, adjust it and the size.
+		size_t page_size = sysconf(_SC_PAGESIZE);
+		if (page_size == -1)
+			page_size = 1;
+		size_t offset = cmd->offset - (cmd->offset % page_size);
+		size_t size = frame->expected_size + (cmd->offset - offset);
+		// Map the shared memory object.
+		void *data =
+			mmap(NULL, size, PROT_READ, MAP_SHARED, fd, offset);
+		if (data == MAP_FAILED) {
+			gr_reporterror_cmd(cmd, "EBADF: mmap: %s",
+					   strerror(errno));
+			frame->status = STATUS_UPLOADING_ERROR;
+			frame->uploading_failure = ERROR_CANNOT_OPEN_SHM;
+			fprintf(stderr,
+				"mmap failed for size = %ld, offset = %ld\n",
+				size, offset);
+			close(fd);
+			return frame;
+		}
+		close(fd);
+		// Append the data to the cache file.
+		if (gr_append_raw_data_to_file(frame,
+					       data + (cmd->offset - offset),
+					       frame->expected_size)) {
+			frame->status = STATUS_UPLOADING_SUCCESS;
+		} else {
+			frame->status = STATUS_UPLOADING_ERROR;
+			frame->uploading_failure =
+				ERROR_CANNOT_OPEN_CACHED_FILE;
+			gr_reportuploaderror(frame);
+		}
+		// Close the cache file.
+		gr_close_disk_cache_file(frame);
+		// Unmap the data
+		if (munmap(data, size) != 0)
+			fprintf(stderr, "munmap failed: %s\n", strerror(errno));
+		// Try to load and redraw existing instances.
+		gr_schedule_image_redraw(frame->image);
+		frame = gr_loadimage_and_report(frame);
+		gr_check_limits();
+	} else {
+		gr_reporterror_cmd(
+			cmd,
+			"EINVAL: transmission medium '%c' is not supported",
+			cmd->transmission_medium);
+		return NULL;
+	}
+
+	return frame;
+}
+
+/// Handles the 'put' command by creating a placement.
+static void gr_handle_put_command(GraphicsCommand *cmd) {
+	if (cmd->image_id == 0 && cmd->image_number == 0) {
+		gr_reporterror_cmd(cmd,
+				   "EINVAL: neither image id nor image number "
+				   "are specified or both are zero");
+		return;
+	}
+
+	// Find the image with the id or number.
+	Image *img = gr_find_image_for_command(cmd);
+	if (img) {
+		cmd->image_id = img->image_id;
+	} else {
+		gr_reporterror_cmd(cmd, "ENOENT: image not found");
+		return;
+	}
+
+	// Create a placement. If a placement with the same id already exists,
+	// it will be deleted. If the id is zero, a random id will be generated.
+	ImagePlacement *placement = gr_new_placement(img, cmd->placement_id);
+	placement->virtual = cmd->virtual;
+	placement->src_pix_x = cmd->src_pix_x;
+	placement->src_pix_y = cmd->src_pix_y;
+	placement->src_pix_width = cmd->src_pix_width;
+	placement->src_pix_height = cmd->src_pix_height;
+	placement->cols = cmd->columns;
+	placement->rows = cmd->rows;
+	placement->do_not_move_cursor = cmd->do_not_move_cursor;
+
+	if (placement->virtual) {
+		placement->scale_mode = SCALE_MODE_CONTAIN;
+	} else if (placement->cols && placement->rows) {
+		// For classic placements the default is to stretch the image if
+		// both cols and rows are specified.
+		placement->scale_mode = SCALE_MODE_FILL;
+	} else if (placement->cols || placement->rows) {
+		// But if only one of them is specified, the default is to
+		// contain.
+		placement->scale_mode = SCALE_MODE_CONTAIN;
+	} else {
+		// If none of them are specified, the default is to use the
+		// original size.
+		placement->scale_mode = SCALE_MODE_NONE;
+	}
+
+	// Display the placement unless it's virtual.
+	gr_display_nonvirtual_placement(placement);
+
+	// Report success.
+	gr_reportsuccess_cmd(cmd);
+}
+
+/// Information about what to delete.
+typedef struct DeletionData {
+	uint32_t image_id;
+	uint32_t placement_id;
+	/// Visible placement found during screen traversal that need to be
+	/// deleted. Each placement must occur only once in this vector.
+	ImagePlacementVec placements_to_delete;
+} DeletionData;
+
+/// The callback called for each cell to perform deletion.
+static int gr_deletion_callback(void *data, Glyph *gp) {
+	DeletionData *del_data = data;
+	// Leave unicode placeholders alone.
+	if (!tgetisclassicplaceholder(gp))
+		return 0;
+	uint32_t image_id = tgetimgid(gp);
+	uint32_t placement_id = tgetimgplacementid(gp);
+	if (del_data->image_id && del_data->image_id != image_id)
+		return 0;
+	if (del_data->placement_id && del_data->placement_id != placement_id)
+		return 0;
+
+	ImagePlacement *placement = NULL;
+
+	// Record the placement to delete. We will actually delete it later.
+	for (int i = 0; i < kv_size(del_data->placements_to_delete); ++i) {
+		ImagePlacement *cand = kv_A(del_data->placements_to_delete, i);
+		if (cand->image->image_id == image_id &&
+		    cand->placement_id == placement_id) {
+			placement = cand;
+			break;
+		}
+	}
+	if (!placement) {
+		placement = gr_find_image_and_placement(image_id, placement_id);
+		if (placement) {
+			kv_push(ImagePlacement *,
+				del_data->placements_to_delete, placement);
+		}
+	}
+
+	// Restore the text underneath the placement if possible.
+	if (placement && placement->text_underneath) {
+		int row = tgetimgrow(gp) - 1;
+		int col = tgetimgcol(gp) - 1;
+		if (col >= 0 && row >= 0 && row < placement->rows &&
+		    col < placement->cols) {
+			*gp = placement->text_underneath[row * placement->cols +
+							 col];
+			return 1;
+		}
+	}
+
+	// Otherwise just erase the cell.
+	gp->mode = 0;
+	gp->u = ' ';
+	return 1;
+}
+
+/// Handles the delete command.
+static void gr_handle_delete_command(GraphicsCommand *cmd) {
+	DeletionData del_data = {0};
+	char delete_image_if_no_ref = isupper(cmd->delete_specifier) != 0;
+	char d = tolower(cmd->delete_specifier);
+
+	if (d == 'n') {
+		d = 'i';
+		Image *img = gr_find_image_by_number(cmd->image_number);
+		if (!img)
+			return;
+		del_data.image_id = img->image_id;
+	}
+
+	kv_init(del_data.placements_to_delete);
+
+	if (!d || d == 'a') {
+		// Delete all visible placements.
+		gr_for_each_image_cell(gr_deletion_callback, &del_data);
+	} else if (d == 'i') {
+		// Delete the specified image by image id and maybe placement
+		// id.
+		if (!del_data.image_id)
+			del_data.image_id = cmd->image_id;
+		if (!del_data.image_id) {
+			fprintf(stderr,
+				"ERROR: image id is not specified in the "
+				"delete command\n");
+			kv_destroy(del_data.placements_to_delete);
+			return;
+		}
+		del_data.placement_id = cmd->placement_id;
+		gr_for_each_image_cell(gr_deletion_callback, &del_data);
+	} else {
+		fprintf(stderr,
+			"WARNING: unsupported value of the d key: '%c'. The "
+			"command is ignored.\n",
+			cmd->delete_specifier);
+	}
+
+	// Delete the placements we have collected and maybe images too.
+	for (int i = 0; i < kv_size(del_data.placements_to_delete); ++i) {
+		ImagePlacement *placement =
+			kv_A(del_data.placements_to_delete, i);
+		// Delete the text underneath the placement and set it to NULL
+		// to avoid erasing it from the screen again.
+		free(placement->text_underneath);
+		placement->text_underneath = NULL;
+		Image *img = placement->image;
+		gr_delete_placement(placement);
+		// Delete the image if image deletion is requested (uppercase
+		// delete specifier) and there are no more placements.
+		if (delete_image_if_no_ref && kh_size(img->placements) == 0)
+			gr_delete_image(img);
+	}
+
+	// NOTE: It's not very clear whether we should delete the image
+	// even if there are no _visible_ placements to delete. We do
+	// this because otherwise there is no way to delete an image
+	// with virtual placements in one command.
+	if (d == 'i' && !del_data.placement_id && delete_image_if_no_ref)
+		gr_delete_image(gr_find_image(cmd->image_id));
+
+	kv_destroy(del_data.placements_to_delete);
+}
+
+/// Clears the cells occupied by the placement. This is normally done when
+/// implicitly deleting a classic placement.
+static void gr_erase_placement(ImagePlacement *placement) {
+	DeletionData del_data = {0};
+	del_data.image_id = placement->image->image_id;
+	del_data.placement_id = placement->placement_id;
+	kv_init(del_data.placements_to_delete);
+	gr_for_each_image_cell(gr_deletion_callback, &del_data);
+	// Delete the text underneath the placement and set it to NULL
+	// to avoid erasing it from the screen again.
+	free(placement->text_underneath);
+	placement->text_underneath = NULL;
+	kv_destroy(del_data.placements_to_delete);
+}
+
+static void gr_handle_animation_control_command(GraphicsCommand *cmd) {
+	if (cmd->image_id == 0 && cmd->image_number == 0) {
+		gr_reporterror_cmd(cmd,
+				   "EINVAL: neither image id nor image number "
+				   "are specified or both are zero");
+		return;
+	}
+
+	// Find the image with the id or number.
+	Image *img = gr_find_image_for_command(cmd);
+	if (img) {
+		cmd->image_id = img->image_id;
+	} else {
+		gr_reporterror_cmd(cmd, "ENOENT: image not found");
+		return;
+	}
+
+	// Find the frame to edit, if requested.
+	ImageFrame *frame = NULL;
+	if (cmd->edit_frame)
+		frame = gr_get_frame(img, cmd->edit_frame);
+	if (cmd->edit_frame || cmd->gap) {
+		if (!frame) {
+			gr_reporterror_cmd(cmd, "ENOENT: frame %d not found",
+					   cmd->edit_frame);
+			return;
+		}
+		if (cmd->gap) {
+			img->total_duration -= frame->gap;
+			frame->gap = cmd->gap;
+			img->total_duration += frame->gap;
+		}
+	}
+
+	// Set animation-related parameters of the image.
+	if (cmd->current_frame)
+		img->current_frame = cmd->current_frame;
+	if (cmd->animation_state) {
+		if (cmd->animation_state == 1) {
+			img->animation_state = ANIMATION_STATE_STOPPED;
+		} else if (cmd->animation_state == 2) {
+			img->animation_state = ANIMATION_STATE_LOADING;
+		} else if (cmd->animation_state == 3) {
+			img->animation_state = ANIMATION_STATE_LOOPING;
+		} else {
+			gr_reporterror_cmd(
+				cmd, "EINVAL: invalid animation state: %d",
+				cmd->animation_state);
+		}
+	}
+	// TODO: Set the number of loops to cmd->loops
+
+	// Make sure we redraw all instances of the image.
+	gr_schedule_image_redraw(img);
+}
+
+/// Handles a command.
+static void gr_handle_command(GraphicsCommand *cmd) {
+	if (!cmd->image_id && !cmd->image_number) {
+		// If there is no image id or image number, nobody expects a
+		// response, so set quiet to 2.
+		cmd->quiet = 2;
+	}
+
+	int was_transmission = 0;
+	ImageFrame *frame = NULL;
+
+	switch (cmd->action) {
+	case 0:
+		// If no action is specified, it is data transmission.
+	case 't':
+	case 'q':
+	case 'f':
+		was_transmission = 1;
+		// Transmit data. 'q' means query, which is basically the same
+		// as transmit, but the image is discarded, and the id is fake.
+		// 'f' appends a frame to an existing image.
+		gr_handle_transmit_command(cmd);
+		break;
+	case 'p':
+		// Display (put) the image.
+		gr_handle_put_command(cmd);
+		break;
+	case 'T':
+		was_transmission = 1;
+		// Transmit and display.
+		frame = gr_handle_transmit_command(cmd);
+		if (frame && !cmd->is_direct_transmission_continuation) {
+			gr_handle_put_command(cmd);
+			if (cmd->placement_id)
+				frame->image->initial_placement_id =
+					cmd->placement_id;
+		}
+		break;
+	case 'd':
+		gr_handle_delete_command(cmd);
+		break;
+	case 'a':
+		gr_handle_animation_control_command(cmd);
+		break;
+	default:
+		gr_reporterror_cmd(cmd, "EINVAL: unsupported action: %c",
+				   cmd->action);
+		break;
+	}
+
+	if (!was_transmission ||
+	    (cmd->transmission_medium && cmd->transmission_medium != 'd')) {
+		// If it wasn't a transmission command, or if the transmission
+		// wasn't direct, clear the current upload frame and close the
+		// file. (If it was a direct transmission, the current upload
+		// was handled inside `gr_append_data`.)
+		gr_set_current_upload_frame(NULL);
+	}
+}
+
+/// A partially parsed key-value pair.
+typedef struct KeyAndValue {
+	char *key_start;
+	char *val_start;
+	unsigned key_len, val_len;
+} KeyAndValue;
+
+/// Parses the value of a key and assigns it to the appropriate field of `cmd`.
+static void gr_set_keyvalue(GraphicsCommand *cmd, KeyAndValue *kv) {
+	char *key_start = kv->key_start;
+	char *key_end = key_start + kv->key_len;
+	char *value_start = kv->val_start;
+	char *value_end = value_start + kv->val_len;
+	// Currently all keys are one-character.
+	if (key_end - key_start != 1) {
+		gr_reporterror_cmd(cmd, "EINVAL: unknown key of length %ld: %s",
+				   key_end - key_start, key_start);
+		return;
+	}
+	long num = 0;
+	if (*key_start == 'a' || *key_start == 't' || *key_start == 'd' ||
+	    *key_start == 'o') {
+		// Some keys have one-character values.
+		if (value_end - value_start != 1) {
+			gr_reporterror_cmd(
+				cmd,
+				"EINVAL: value of 'a', 't' or 'd' must be a "
+				"single char: %s",
+				key_start);
+			return;
+		}
+	} else {
+		// All the other keys have integer values.
+		char *num_end = NULL;
+		num = strtol(value_start, &num_end, 10);
+		if (num_end != value_end) {
+			gr_reporterror_cmd(
+				cmd, "EINVAL: could not parse number value: %s",
+				key_start);
+			return;
+		}
+	}
+	switch (*key_start) {
+	case 'a':
+		cmd->action = *value_start;
+		break;
+	case 't':
+		cmd->transmission_medium = *value_start;
+		break;
+	case 'd':
+		cmd->delete_specifier = *value_start;
+		break;
+	case 'q':
+		cmd->quiet = num;
+		break;
+	case 'f':
+		cmd->format = num;
+		if (num != 0 && num != 24 && num != 32 && num != 100) {
+			gr_reporterror_cmd(
+				cmd,
+				"EINVAL: unsupported format specification: %s",
+				key_start);
+		}
+		break;
+	case 'o':
+		cmd->compression = *value_start;
+		if (cmd->compression != 'z') {
+			gr_reporterror_cmd(cmd,
+					   "EINVAL: unsupported compression "
+					   "specification: %s",
+					   key_start);
+		}
+		break;
+	case 's':
+		if (cmd->action == 'a')
+			cmd->animation_state = num;
+		else
+			cmd->frame_pix_width = num;
+		break;
+	case 'v':
+		if (cmd->action == 'a')
+			cmd->loops = num;
+		else
+			cmd->frame_pix_height = num;
+		break;
+	case 'i':
+		cmd->image_id = num;
+		break;
+	case 'I':
+		cmd->image_number = num;
+		break;
+	case 'p':
+		cmd->placement_id = num;
+		break;
+	case 'x':
+		cmd->src_pix_x = num;
+		cmd->frame_dst_pix_x = num;
+		break;
+	case 'y':
+		if (cmd->action == 'f')
+			cmd->frame_dst_pix_y = num;
+		else
+			cmd->src_pix_y = num;
+		break;
+	case 'w':
+		cmd->src_pix_width = num;
+		break;
+	case 'h':
+		cmd->src_pix_height = num;
+		break;
+	case 'c':
+		if (cmd->action == 'f')
+			cmd->background_frame = num;
+		else if (cmd->action == 'a')
+			cmd->current_frame = num;
+		else
+			cmd->columns = num;
+		break;
+	case 'r':
+		if (cmd->action == 'f' || cmd->action == 'a')
+			cmd->edit_frame = num;
+		else
+			cmd->rows = num;
+		break;
+	case 'm':
+		cmd->more = num;
+		break;
+	case 'S':
+		cmd->size = num;
+		break;
+	case 'O':
+		cmd->offset = num;
+		break;
+	case 'U':
+		cmd->virtual = num;
+		break;
+	case 'X':
+		if (cmd->action == 'f')
+			cmd->replace_instead_of_blending = num;
+		else
+			break; /*ignore*/
+		break;
+	case 'Y':
+		if (cmd->action == 'f')
+			cmd->background_color = num;
+		else
+			break; /*ignore*/
+		break;
+	case 'z':
+		if (cmd->action == 'f' || cmd->action == 'a')
+			cmd->gap = num;
+		else
+			break; /*ignore*/
+		break;
+	case 'C':
+		cmd->do_not_move_cursor = num;
+		break;
+	default:
+		gr_reporterror_cmd(cmd, "EINVAL: unsupported key: %s",
+				   key_start);
+		return;
+	}
+}
+
+/// Parse and execute a graphics command. `buf` must start with 'G' and contain
+/// at least `len + 1` characters. Returns 1 on success.
+int gr_parse_command(char *buf, size_t len) {
+	if (buf[0] != 'G')
+		return 0;
+
+	Milliseconds command_start_time = gr_now_ms();
+	debug_loaded_files_counter = 0;
+	debug_loaded_pixmaps_counter = 0;
+	global_command_counter++;
+	GR_LOG("### Command %lu: %.80s\n", global_command_counter, buf);
+
+	memset(&graphics_command_result, 0, sizeof(GraphicsCommandResult));
+
+	// Eat the 'G'.
+	++buf;
+	--len;
+
+	GraphicsCommand cmd = {.command = buf};
+	// The state of parsing. 'k' to parse key, 'v' to parse value, 'p' to
+	// parse the payload.
+	char state = 'k';
+	// An array of partially parsed key-value pairs.
+	KeyAndValue key_vals[32];
+	unsigned key_vals_count = 0;
+	char *key_start = buf;
+	char *key_end = NULL;
+	char *val_start = NULL;
+	char *val_end = NULL;
+	char *c = buf;
+	while (c - buf < len + 1) {
+		if (state == 'k') {
+			switch (*c) {
+			case ',':
+			case ';':
+			case '\0':
+				state = *c == ',' ? 'k' : 'p';
+				key_end = c;
+				gr_reporterror_cmd(
+					&cmd, "EINVAL: key without value: %s ",
+					key_start);
+				break;
+			case '=':
+				key_end = c;
+				state = 'v';
+				val_start = c + 1;
+				break;
+			default:
+				break;
+			}
+		} else if (state == 'v') {
+			switch (*c) {
+			case ',':
+			case ';':
+			case '\0':
+				state = *c == ',' ? 'k' : 'p';
+				val_end = c;
+				if (key_vals_count >=
+				    sizeof(key_vals) / sizeof(*key_vals)) {
+					gr_reporterror_cmd(&cmd,
+							   "EINVAL: too many "
+							   "key-value pairs");
+					break;
+				}
+				key_vals[key_vals_count].key_start = key_start;
+				key_vals[key_vals_count].val_start = val_start;
+				key_vals[key_vals_count].key_len =
+					key_end - key_start;
+				key_vals[key_vals_count].val_len =
+					val_end - val_start;
+				++key_vals_count;
+				key_start = c + 1;
+				break;
+			default:
+				break;
+			}
+		} else if (state == 'p') {
+			cmd.payload = c;
+			// break out of the loop, we don't check the payload
+			break;
+		}
+		++c;
+	}
+
+	// Set the action key ('a=') first because we need it to disambiguate
+	// some keys. Also set 'i=' and 'I=' for better error reporting.
+	for (unsigned i = 0; i < key_vals_count; ++i) {
+		if (key_vals[i].key_len == 1) {
+			char *start = key_vals[i].key_start;
+			if (*start == 'a' || *start == 'i' || *start == 'I') {
+				gr_set_keyvalue(&cmd, &key_vals[i]);
+				break;
+			}
+		}
+	}
+	// Set the rest of the keys.
+	for (unsigned i = 0; i < key_vals_count; ++i)
+		gr_set_keyvalue(&cmd, &key_vals[i]);
+
+	if (!cmd.payload)
+		cmd.payload = buf + len;
+
+	if (cmd.payload && cmd.payload[0])
+		GR_LOG("    payload size: %ld\n", strlen(cmd.payload));
+
+	if (!graphics_command_result.error)
+		gr_handle_command(&cmd);
+
+	if (graphics_debug_mode) {
+		fprintf(stderr, "Response: ");
+		for (const char *resp = graphics_command_result.response;
+		     *resp != '\0'; ++resp) {
+			if (isprint(*resp))
+				fprintf(stderr, "%c", *resp);
+			else
+				fprintf(stderr, "(0x%x)", *resp);
+		}
+		fprintf(stderr, "\n");
+	}
+
+	// Make sure that we suppress response if needed. Usually cmd.quiet is
+	// taken into account when creating the response, but it's not very
+	// reliable in the current implementation.
+	if (cmd.quiet) {
+		if (!graphics_command_result.error || cmd.quiet >= 2)
+			graphics_command_result.response[0] = '\0';
+	}
+
+	Milliseconds command_end_time = gr_now_ms();
+	GR_LOG("Command %lu took %ld ms  (loaded %d files, %d pixmaps)\n\n",
+	       global_command_counter, command_end_time - command_start_time,
+	       debug_loaded_files_counter, debug_loaded_pixmaps_counter);
+
+	return 1;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// base64 decoding part is basically copied from st.c
+////////////////////////////////////////////////////////////////////////////////
+
+static const char gr_base64_digits[] = {
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  62, 0,  0,  0,  63, 52, 53, 54,
+	55, 56, 57, 58, 59, 60, 61, 0,  0,  0,  -1, 0,  0,  0,  0,  1,  2,
+	3,  4,  5,  6,  7,  8,  9,  10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
+	20, 21, 22, 23, 24, 25, 0,  0,  0,  0,  0,  0,  26, 27, 28, 29, 30,
+	31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
+	48, 49, 50, 51, 0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0};
+
+static char gr_base64_getc(const char **src) {
+	while (**src && !isprint(**src))
+		(*src)++;
+	return **src ? *((*src)++) : '='; /* emulate padding if string ends */
+}
+
+char *gr_base64dec(const char *src, size_t *size) {
+	size_t in_len = strlen(src);
+	char *result, *dst;
+
+	result = dst = malloc((in_len + 3) / 4 * 3 + 1);
+	while (*src) {
+		int a = gr_base64_digits[(unsigned char)gr_base64_getc(&src)];
+		int b = gr_base64_digits[(unsigned char)gr_base64_getc(&src)];
+		int c = gr_base64_digits[(unsigned char)gr_base64_getc(&src)];
+		int d = gr_base64_digits[(unsigned char)gr_base64_getc(&src)];
+
+		if (a == -1 || b == -1)
+			break;
+
+		*dst++ = (a << 2) | ((b & 0x30) >> 4);
+		if (c == -1)
+			break;
+		*dst++ = ((b & 0x0f) << 4) | ((c & 0x3c) >> 2);
+		if (d == -1)
+			break;
+		*dst++ = ((c & 0x03) << 6) | d;
+	}
+	*dst = '\0';
+	if (size) {
+		*size = dst - result;
+	}
+	return result;
+}
diff --git a/graphics.h b/graphics.h
new file mode 100644
index 0000000..f9a649a
--- /dev/null
+++ b/graphics.h
@@ -0,0 +1,112 @@
+
+#include <stdint.h>
+#include <sys/types.h>
+#include <X11/Xlib.h>
+
+/// Initialize the graphics module.
+void gr_init(Display *disp, Visual *vis, Colormap cm);
+/// Deinitialize the graphics module.
+void gr_deinit();
+
+/// Add an image rectangle to a list if rectangles to draw. This function may
+/// actually draw some rectangles, or it may wait till more rectangles are
+/// appended. Must be called between `gr_start_drawing` and `gr_finish_drawing`.
+/// - `img_start_col..img_end_col` and `img_start_row..img_end_row` define the
+///   part of the image to draw (row/col indices are zero-based, ends are
+///   excluded).
+/// - `x_col` and `y_row` are the coordinates of the top-left corner of the
+///   image in the terminal grid.
+/// - `x_pix` and `y_pix` are the same but in pixels.
+/// - `reverse` indicates whether colors should be inverted.
+void gr_append_imagerect(Drawable buf, uint32_t image_id, uint32_t placement_id,
+			 int img_start_col, int img_end_col, int img_start_row,
+			 int img_end_row, int x_col, int y_row, int x_pix,
+			 int y_pix, int cw, int ch, int reverse);
+/// Prepare for image drawing. `cw` and `ch` are dimensions of the cell.
+void gr_start_drawing(Drawable buf, int cw, int ch);
+/// Finish image drawing. This functions will draw all the rectangles left to
+/// draw.
+void gr_finish_drawing(Drawable buf);
+/// Mark rows containing animations as dirty if it's time to redraw them. Must
+/// be called right after `gr_start_drawing`.
+void gr_mark_dirty_animations(int *dirty, int rows);
+
+/// Parse and execute a graphics command. `buf` must start with 'G' and contain
+/// at least `len + 1` characters (including '\0'). Returns 1 on success.
+/// Additional informations is returned through `graphics_command_result`.
+int gr_parse_command(char *buf, size_t len);
+
+/// Executes `command` with the name of the file corresponding to `image_id` as
+/// the argument. Executes xmessage with an error message on failure.
+void gr_preview_image(uint32_t image_id, const char *command);
+
+/// Executes `<st> -e less <file>` where <file> is the name of a temporary file
+/// containing the information about an image and placement, and <st> is
+/// specified with `st_executable`.
+void gr_show_image_info(uint32_t image_id, uint32_t placement_id,
+			uint32_t imgcol, uint32_t imgrow,
+			char is_classic_placeholder, int32_t diacritic_count,
+			char *st_executable);
+
+/// Dumps the internal state (images and placements) to stderr.
+void gr_dump_state();
+
+/// Unloads images to reduce RAM usage.
+void gr_unload_images_to_reduce_ram();
+
+/// Executes `callback` for each image cell. The callback should return 1 if it
+/// changed the glyph. This function is implemented in `st.c`.
+void gr_for_each_image_cell(int (*callback)(void *data, Glyph *gp),
+			    void *data);
+
+/// Marks all the rows containing the image with `image_id` as dirty.
+void gr_schedule_image_redraw_by_id(uint32_t image_id);
+
+/// Returns a pointer to the glyph under the classic placement with `image_id`
+/// and `placement_id` at `col` and `row` (1-based). May return NULL if the
+/// underneath text is unknown.
+Glyph *gr_get_glyph_underneath_image(uint32_t image_id, uint32_t placement_id,
+				     int col, int row);
+
+typedef enum {
+	GRAPHICS_DEBUG_NONE = 0,
+	GRAPHICS_DEBUG_LOG = 1,
+	GRAPHICS_DEBUG_LOG_AND_BOXES = 2,
+} GraphicsDebugMode;
+
+/// Print additional information, draw bounding bounding boxes, etc.
+extern GraphicsDebugMode graphics_debug_mode;
+
+/// Whether to display images or just draw bounding boxes.
+extern char graphics_display_images;
+
+/// The time in milliseconds until the next redraw to update animations.
+/// INT_MAX means no redraw is needed. Populated by `gr_finish_drawing`.
+extern int graphics_next_redraw_delay;
+
+#define MAX_GRAPHICS_RESPONSE_LEN 256
+
+/// A structure representing the result of a graphics command.
+typedef struct {
+	/// Indicates if the terminal needs to be redrawn.
+	char redraw;
+	/// The response of the command that should be sent back to the client
+	/// (may be empty if the quiet flag is set).
+	char response[MAX_GRAPHICS_RESPONSE_LEN];
+	/// Whether there was an error executing this command (not very useful,
+	/// the response must be sent back anyway).
+	char error;
+	/// Whether the terminal has to create a placeholder for a non-virtual
+	/// placement.
+	char create_placeholder;
+	/// The placeholder that needs to be created.
+	struct {
+		uint32_t rows, columns;
+		uint32_t image_id, placement_id;
+		char do_not_move_cursor;
+		Glyph *text_underneath;
+	} placeholder;
+} GraphicsCommandResult;
+
+/// The result of a graphics command.
+extern GraphicsCommandResult graphics_command_result;
diff --git a/icat-mini.sh b/icat-mini.sh
new file mode 100755
index 0000000..8f240e9
--- /dev/null
+++ b/icat-mini.sh
@@ -0,0 +1,875 @@
+#!/bin/sh
+
+# vim: shiftwidth=4
+
+script_name="$(basename "$0")"
+
+short_help="Usage: $script_name [OPTIONS] <image_file>
+
+This is a script to display images in the terminal using the kitty graphics
+protocol with Unicode placeholders. It is very basic, please use something else
+if you have alternatives.
+
+Options:
+  -h                  Show this help.
+  -s SCALE            The scale of the image, may be floating point.
+  -c N, --cols N      The number of columns.
+  -r N, --rows N      The number of rows.
+  --max-cols N        The maximum number of columns.
+  --max-rows N        The maximum number of rows.
+  --cell-size WxH     The cell size in pixels.
+  -m METHOD           The uploading method, may be 'file', 'direct' or 'auto'.
+  --speed SPEED       The multiplier for the animation speed (float).
+"
+
+# Exit the script on keyboard interrupt
+trap "echo 'icat-mini was interrupted' >&2; exit 1" INT
+
+cols=""
+rows=""
+file=""
+command_tty=""
+response_tty=""
+uploading_method="auto"
+cell_size=""
+scale=1
+max_cols=""
+max_rows=""
+speed=""
+
+# Parse the command line.
+while [ $# -gt 0 ]; do
+    case "$1" in
+        -c|--columns|--cols)
+            cols="$2"
+            shift 2
+            ;;
+        -r|--rows|-l|--lines)
+            rows="$2"
+            shift 2
+            ;;
+        -s|--scale)
+            scale="$2"
+            shift 2
+            ;;
+        -h|--help)
+            echo "$short_help"
+            exit 0
+            ;;
+        -m|--upload-method|--uploading-method)
+            uploading_method="$2"
+            shift 2
+            ;;
+        --cell-size)
+            cell_size="$2"
+            shift 2
+            ;;
+        --max-cols)
+            max_cols="$2"
+            shift 2
+            ;;
+        --max-rows)
+            max_rows="$2"
+            shift 2
+            ;;
+        --speed)
+            speed="$2"
+            shift 2
+            ;;
+        --)
+            file="$2"
+            shift 2
+            ;;
+        -*)
+            echo "Unknown option: $1" >&2
+            exit 1
+            ;;
+        *)
+            if [ -n "$file" ]; then
+                echo "Multiple image files are not supported: $file and $1" >&2
+                exit 1
+            fi
+            file="$1"
+            shift
+            ;;
+    esac
+done
+
+file="$(realpath "$file")"
+
+#####################################################################
+# Detect imagemagick
+#####################################################################
+
+# If there is the 'magick' command, use it instead of separate 'convert' and
+# 'identify' commands.
+if command -v magick > /dev/null; then
+    identify="magick identify"
+    convert="magick"
+else
+    identify="identify"
+    convert="convert"
+fi
+
+#####################################################################
+# Detect tmux
+#####################################################################
+
+# Check if we are inside tmux.
+inside_tmux=""
+if [ -n "$TMUX" ]; then
+    inside_tmux=1
+fi
+
+if [ -z "$command_tty" ] && [ -n "$inside_tmux" ]; then
+    # Get the pty of the current tmux pane.
+    command_tty="$(tmux display-message -t "$TMUX_PANE" -p "#{pane_tty}")"
+    if [ ! -e "$command_tty" ]; then
+        command_tty=""
+    fi
+fi
+
+#####################################################################
+# Adjust the terminal state
+#####################################################################
+
+if [ -z "$command_tty" ]; then
+    command_tty="/dev/tty"
+fi
+if [ -z "$response_tty" ]; then
+    response_tty="/dev/tty"
+fi
+
+stty_orig="$(stty -g < "$response_tty")"
+stty -echo < "$response_tty"
+# Disable ctrl-z. Pressing ctrl-z during image uploading may cause some
+# horrible issues otherwise.
+stty susp undef < "$response_tty"
+stty -icanon < "$response_tty"
+
+restore_echo() {
+    [ -n "$stty_orig" ] || return
+    stty $stty_orig < "$response_tty"
+}
+
+trap restore_echo EXIT TERM
+
+#####################################################################
+# Compute the number of rows and columns
+#####################################################################
+
+is_pos_int() {
+    if [ -z "$1" ]; then
+        return 1 # false
+    fi
+    if [ -z "$(printf '%s' "$1" | tr -d '[:digit:]')" ]; then
+        if [ "$1" -gt 0 ]; then
+            return 0 # true
+        fi
+    fi
+    return 1 # false
+}
+
+if [ -n "$cols" ] || [ -n "$rows" ]; then
+    if [ -n "$max_cols" ] || [ -n "$max_rows" ]; then
+        echo "You can't specify both max-cols/rows and cols/rows" >&2
+        exit 1
+    fi
+fi
+
+# Get the max number of cols and rows.
+[ -n "$max_cols" ] || max_cols="$(tput cols)"
+[ -n "$max_rows" ] || max_rows="$(tput lines)"
+if [ "$max_rows" -gt 255 ]; then
+    max_rows=255
+fi
+
+python_ioctl_command="import array, fcntl, termios
+buf = array.array('H', [0, 0, 0, 0])
+fcntl.ioctl(0, termios.TIOCGWINSZ, buf)
+print(int(buf[2]/buf[1]), int(buf[3]/buf[0]))"
+
+# Get the cell size in pixels if either cols or rows are not specified.
+if [ -z "$cols" ] || [ -z "$rows" ]; then
+    cell_width=""
+    cell_height=""
+    # If the cell size is specified, use it.
+    if [ -n "$cell_size" ]; then
+        cell_width="${cell_size%x*}"
+        cell_height="${cell_size#*x}"
+        if ! is_pos_int "$cell_height" || ! is_pos_int "$cell_width"; then
+            echo "Invalid cell size: $cell_size" >&2
+            exit 1
+        fi
+    fi
+    # Otherwise try to use TIOCGWINSZ ioctl via python.
+    if [ -z "$cell_width" ] || [ -z "$cell_height" ]; then
+        cell_size_ioctl="$(python3 -c "$python_ioctl_command" < "$command_tty" 2> /dev/null)"
+        cell_width="${cell_size_ioctl% *}"
+        cell_height="${cell_size_ioctl#* }"
+        if ! is_pos_int "$cell_height" || ! is_pos_int "$cell_width"; then
+            cell_width=""
+            cell_height=""
+        fi
+    fi
+    # If it didn't work, try to use csi XTWINOPS.
+    if [ -z "$cell_width" ] || [ -z "$cell_height" ]; then
+        if [ -n "$inside_tmux" ]; then
+            printf '\ePtmux;\e\e[16t\e\\' >> "$command_tty"
+        else
+            printf '\e[16t' >> "$command_tty"
+        fi
+        # The expected response will look like ^[[6;<height>;<width>t
+        term_response=""
+        while true; do
+            char=$(dd bs=1 count=1 <"$response_tty" 2>/dev/null)
+            if [ "$char" = "t" ]; then
+                break
+            fi
+            term_response="$term_response$char"
+        done
+        cell_height="$(printf '%s' "$term_response" | cut -d ';' -f 2)"
+        cell_width="$(printf '%s' "$term_response" | cut -d ';' -f 3)"
+        if ! is_pos_int "$cell_height" || ! is_pos_int "$cell_width"; then
+            cell_width=8
+            cell_height=16
+        fi
+    fi
+fi
+
+# Compute a formula with bc and round to the nearest integer.
+bc_round() {
+    LC_NUMERIC=C printf '%.0f' "$(printf '%s\n' "scale=2;($1) + 0.5" | bc)"
+}
+
+# Compute the number of rows and columns of the image.
+if [ -z "$cols" ] || [ -z "$rows" ]; then
+    # Get the size of the image and its resolution. If it's an animation, use
+    # the first frame.
+    format_output="$($identify -format '%w %h\n' "$file" | head -1)"
+    img_width="$(printf '%s' "$format_output" | cut -d ' ' -f 1)"
+    img_height="$(printf '%s' "$format_output" | cut -d ' ' -f 2)"
+    if ! is_pos_int "$img_width" || ! is_pos_int "$img_height"; then
+        echo "Couldn't get image size from identify: $format_output" >&2
+        echo >&2
+        exit 1
+    fi
+    opt_cols_expr="(${scale}*${img_width}/${cell_width})"
+    opt_rows_expr="(${scale}*${img_height}/${cell_height})"
+    if [ -z "$cols" ] && [ -z "$rows" ]; then
+        # If columns and rows are not specified, compute the optimal values
+        # using the information about rows and columns per inch.
+        cols="$(bc_round "$opt_cols_expr")"
+        rows="$(bc_round "$opt_rows_expr")"
+        # Make sure that automatically computed rows and columns are within some
+        # sane limits
+        if [ "$cols" -gt "$max_cols" ]; then
+            rows="$(bc_round "$rows * $max_cols / $cols")"
+            cols="$max_cols"
+        fi
+        if [ "$rows" -gt "$max_rows" ]; then
+            cols="$(bc_round "$cols * $max_rows / $rows")"
+            rows="$max_rows"
+        fi
+    elif [ -z "$cols" ]; then
+        # If only one dimension is specified, compute the other one to match the
+        # aspect ratio as close as possible.
+        cols="$(bc_round "${opt_cols_expr}*${rows}/${opt_rows_expr}")"
+    elif [ -z "$rows" ]; then
+        rows="$(bc_round "${opt_rows_expr}*${cols}/${opt_cols_expr}")"
+    fi
+
+    if [ "$cols" -lt 1 ]; then
+        cols=1
+    fi
+    if [ "$rows" -lt 1 ]; then
+        rows=1
+    fi
+fi
+
+#####################################################################
+# Generate an image id
+#####################################################################
+
+image_id=""
+while [ -z "$image_id" ]; do
+    image_id="$(shuf -i 16777217-4294967295 -n 1)"
+    # Check that the id requires 24-bit fg colors.
+    if [ "$(expr \( "$image_id" / 256 \) % 65536)" -eq 0 ]; then
+        image_id=""
+    fi
+done
+
+#####################################################################
+# Uploading the image
+#####################################################################
+
+# Choose the uploading method
+if [ "$uploading_method" = "auto" ]; then
+    if [ -n "$SSH_CLIENT" ] || [ -n "$SSH_TTY" ] || [ -n "$SSH_CONNECTION" ]; then
+        uploading_method="direct"
+    else
+        uploading_method="file"
+    fi
+fi
+
+# Functions to emit the start and the end of a graphics command.
+if [ -n "$inside_tmux" ]; then
+    # If we are in tmux we have to wrap the command in Ptmux.
+    graphics_command_start='\ePtmux;\e\e_G'
+    graphics_command_end='\e\e\\\e\\'
+else
+    graphics_command_start='\e_G'
+    graphics_command_end='\e\\'
+fi
+
+# Send a graphics command with the correct start and end
+gr_command() {
+    printf "${graphics_command_start}%s${graphics_command_end}" "$1" >> "$command_tty"
+}
+
+# Compute the size of a data chunk for direct transmission.
+if [ "$uploading_method" = "direct" ]; then
+    # Get the value of PIPE_BUF.
+    pipe_buf="$(getconf PIPE_BUF "$command_tty" 2> /dev/null)"
+    if is_pos_int "$pipe_buf"; then
+        # Make sure it's between 512 and 4096.
+        if [ "$(expr "$pipe_buf" \< 512)" -eq 1 ]; then
+            pipe_buf=512
+        elif [ "$(expr "$pipe_buf" \> 4096)" -eq 1 ]; then
+            pipe_buf=4096
+        fi
+    else
+        pipe_buf=512
+    fi
+
+    # The size of each graphics command shouldn't be more than PIPE_BUF, so we
+    # set the size of an encoded chunk to be PIPE_BUF - 128 to leave some space
+    # for the command.
+    chunk_size="$(expr "$pipe_buf" - 128)"
+fi
+
+# Check if the image format is supported.
+is_format_supported() {
+    arg_format="$1"
+    if [ "$arg_format" = "PNG" ]; then
+        return 0
+    elif [ "$arg_format" = "JPEG" ]; then
+        if [ -z "$inside_tmux" ]; then
+            actual_term="$TERM"
+        else
+            # Get the actual current terminal name from tmux.
+            actual_term="$(tmux display-message -p "#{client_termname}")"
+        fi
+        # st is known to support JPEG.
+        case "$actual_term" in
+            st | *-st | st-* | *-st-*)
+                return 0
+                ;;
+        esac
+        return 1
+    else
+        return 1
+    fi
+}
+
+# Send an uploading command. Usage: gr_upload <action> <command> <file>
+# Where <action> is a part of command that specifies the action, it will be
+# repeated for every chunk (if the method is direct), and <command> is the rest
+# of the command that specifies the image parameters. <action> and <command>
+# must not include the transmission method or ';'.
+# Example:
+# gr_upload "a=T,q=2" "U=1,i=${image_id},f=100,c=${cols},r=${rows}" "$file"
+gr_upload() {
+    arg_action="$1"
+    arg_command="$2"
+    arg_file="$3"
+    if [ "$uploading_method" = "file" ]; then
+        # base64-encode the filename
+        encoded_filename=$(printf '%s' "$arg_file" | base64 -w0)
+        # If the file name contains 'tty-graphics-protocol', assume it's
+        # temporary and use t=t.
+        medium="t=f"
+        case "$arg_file" in
+            *tty-graphics-protocol*)
+                medium="t=t"
+                ;;
+            *)
+                medium="t=f"
+                ;;
+        esac
+        gr_command "${arg_action},${arg_command},${medium};${encoded_filename}"
+    fi
+    if [ "$uploading_method" = "direct" ]; then
+        # Create a temporary directory to store the chunked image.
+        chunkdir="$(mktemp -d)"
+        if [ ! "$chunkdir" ] || [ ! -d "$chunkdir" ]; then
+            echo "Can't create a temp dir" >&2
+            exit 1
+        fi
+        # base64-encode the file and split it into chunks. The size of each
+        # graphics command shouldn't be more than 4096, so we set the size of an
+        # encoded chunk to be 3968, slightly less than that.
+        chunk_size=3968
+        cat "$arg_file" | base64 -w0 | split -b "$chunk_size" - "$chunkdir/chunk_"
+
+        # Issue a command indicating that we want to start data transmission for
+        # a new image.
+        gr_command "${arg_action},${arg_command},t=d,m=1"
+
+        # Transmit chunks.
+        for chunk in "$chunkdir/chunk_"*; do
+            gr_command "${arg_action},i=${image_id},m=1;$(cat "$chunk")"
+            rm "$chunk"
+        done
+
+        # Tell the terminal that we are done.
+        gr_command "${arg_action},i=$image_id,m=0"
+
+        # Remove the temporary directory.
+        rmdir "$chunkdir"
+    fi
+}
+
+delayed_frame_dir_cleanup() {
+    arg_frame_dir="$1"
+    sleep 2
+    if [ -n "$arg_frame_dir" ]; then
+        for frame in "$arg_frame_dir"/frame_*.png; do
+            rm "$frame"
+        done
+        rmdir "$arg_frame_dir"
+    fi
+}
+
+upload_image_and_print_placeholder() {
+    # Check if the file is an animation.
+    format_output=$($identify -format '%n %m\n' "$file" | head -n 1)
+    frame_count="$(printf '%s' "$format_output" | cut -d ' ' -f 1)"
+    image_format="$(printf '%s' "$format_output" | cut -d ' ' -f 2)"
+
+    if [ "$frame_count" -gt 1 ]; then
+        # The file is an animation, decompose into frames and upload each frame.
+        frame_dir="$(mktemp -d)"
+        frame_dir="$HOME/temp/frames${frame_dir}"
+        mkdir -p "$frame_dir"
+        if [ ! "$frame_dir" ] || [ ! -d "$frame_dir" ]; then
+            echo "Can't create a temp dir for frames" >&2
+            exit 1
+        fi
+
+        # Decompose the animation into separate frames.
+        $convert "$file" -coalesce "$frame_dir/frame_%06d.png"
+
+        # Get all frame delays at once, in centiseconds, as a space-separated
+        # string.
+        delays=$($identify -format "%T " "$file")
+
+        frame_number=1
+        for frame in "$frame_dir"/frame_*.png; do
+            # Read the delay for the current frame and convert it from
+            # centiseconds to milliseconds.
+            delay=$(printf '%s' "$delays" | cut -d ' ' -f "$frame_number")
+            delay=$((delay * 10))
+            # If the delay is 0, set it to 100ms.
+            if [ "$delay" -eq 0 ]; then
+                delay=100
+            fi
+
+            if [ -n "$speed" ]; then
+                delay=$(bc_round "$delay / $speed")
+            fi
+
+            if [ "$frame_number" -eq 1 ]; then
+                # Abort the previous transmission, just in case.
+                gr_command "q=2,a=t,i=${image_id},m=0"
+                # Upload the first frame with a=T
+                gr_upload "q=2,a=T" "f=100,U=1,i=${image_id},c=${cols},r=${rows}" "$frame"
+                # Set the delay for the first frame and also play the animation
+                # in loading mode (s=2).
+                gr_command "a=a,v=1,s=2,r=${frame_number},z=${delay},i=${image_id}"
+                # Print the placeholder after the first frame to reduce the wait
+                # time.
+                print_placeholder
+            else
+                # Upload subsequent frames with a=f
+                gr_upload "q=2,a=f" "f=100,i=${image_id},z=${delay}" "$frame"
+            fi
+
+            frame_number=$((frame_number + 1))
+        done
+
+        # Play the animation in loop mode (s=3).
+        gr_command "a=a,v=1,s=3,i=${image_id}"
+
+        # Remove the temporary directory, but do it in the background with a
+        # delay to avoid removing files before they are loaded by the terminal.
+        delayed_frame_dir_cleanup "$frame_dir" 2> /dev/null &
+    elif is_format_supported "$image_format"; then
+        # The file is not an animation and has a supported format, upload it
+        # directly.
+        gr_upload "q=2,a=T" "U=1,i=${image_id},f=100,c=${cols},r=${rows}" "$file"
+        # Print the placeholder
+        print_placeholder
+    else
+        # The format is not supported, try to convert it to png.
+        temp_file="$(mktemp --tmpdir "icat-mini-tty-graphics-protocol-XXXXX.png")"
+        if ! $convert "$file" "$temp_file"; then
+            echo "Failed to convert the image to PNG" >&2
+            exit 1
+        fi
+        # Upload the converted image.
+        gr_upload "q=2,a=T" "U=1,i=${image_id},f=100,c=${cols},r=${rows}" "$temp_file"
+        # Print the placeholder
+        print_placeholder
+    fi
+}
+
+#####################################################################
+# Printing the image placeholder
+#####################################################################
+
+print_placeholder() {
+    # Each line starts with the escape sequence to set the foreground color to
+    # the image id.
+    blue="$(expr "$image_id" % 256 )"
+    green="$(expr \( "$image_id" / 256 \) % 256 )"
+    red="$(expr \( "$image_id" / 65536 \) % 256 )"
+    line_start="$(printf "\e[38;2;%d;%d;%dm" "$red" "$green" "$blue")"
+    line_end="$(printf "\e[39;m")"
+
+    id4th="$(expr \( "$image_id" / 16777216 \) % 256 )"
+    eval "id_diacritic=\$d${id4th}"
+
+    # Reset the brush state, mostly to reset the underline color.
+    printf "\e[0m"
+
+    # Fill the output with characters representing the image
+    for y in $(seq 0 "$(expr "$rows" - 1)"); do
+        eval "row_diacritic=\$d${y}"
+        line="$line_start"
+        for x in $(seq 0 "$(expr "$cols" - 1)"); do
+            eval "col_diacritic=\$d${x}"
+            # Note that when $x is out of bounds, the column diacritic will
+            # be empty, meaning that the column should be guessed by the
+            # terminal.
+            if [ "$x" -ge "$num_diacritics" ]; then
+                line="${line}${placeholder}${row_diacritic}"
+            else
+                line="${line}${placeholder}${row_diacritic}${col_diacritic}${id_diacritic}"
+            fi
+        done
+        line="${line}${line_end}"
+        printf "%s\n" "$line"
+    done
+
+    printf "\e[0m"
+}
+
+d0="̅"
+d1="̍"
+d2="̎"
+d3="̐"
+d4="̒"
+d5="̽"
+d6="̾"
+d7="̿"
+d8="͆"
+d9="͊"
+d10="͋"
+d11="͌"
+d12="͐"
+d13="͑"
+d14="͒"
+d15="͗"
+d16="͛"
+d17="ͣ"
+d18="ͤ"
+d19="ͥ"
+d20="ͦ"
+d21="ͧ"
+d22="ͨ"
+d23="ͩ"
+d24="ͪ"
+d25="ͫ"
+d26="ͬ"
+d27="ͭ"
+d28="ͮ"
+d29="ͯ"
+d30="҃"
+d31="҄"
+d32="҅"
+d33="҆"
+d34="҇"
+d35="֒"
+d36="֓"
+d37="֔"
+d38="֕"
+d39="֗"
+d40="֘"
+d41="֙"
+d42="֜"
+d43="֝"
+d44="֞"
+d45="֟"
+d46="֠"
+d47="֡"
+d48="֨"
+d49="֩"
+d50="֫"
+d51="֬"
+d52="֯"
+d53="ׄ"
+d54="ؐ"
+d55="ؑ"
+d56="ؒ"
+d57="ؓ"
+d58="ؔ"
+d59="ؕ"
+d60="ؖ"
+d61="ؗ"
+d62="ٗ"
+d63="٘"
+d64="ٙ"
+d65="ٚ"
+d66="ٛ"
+d67="ٝ"
+d68="ٞ"
+d69="ۖ"
+d70="ۗ"
+d71="ۘ"
+d72="ۙ"
+d73="ۚ"
+d74="ۛ"
+d75="ۜ"
+d76="۟"
+d77="۠"
+d78="ۡ"
+d79="ۢ"
+d80="ۤ"
+d81="ۧ"
+d82="ۨ"
+d83="۫"
+d84="۬"
+d85="ܰ"
+d86="ܲ"
+d87="ܳ"
+d88="ܵ"
+d89="ܶ"
+d90="ܺ"
+d91="ܽ"
+d92="ܿ"
+d93="݀"
+d94="݁"
+d95="݃"
+d96="݅"
+d97="݇"
+d98="݉"
+d99="݊"
+d100="߫"
+d101="߬"
+d102="߭"
+d103="߮"
+d104="߯"
+d105="߰"
+d106="߱"
+d107="߳"
+d108="ࠖ"
+d109="ࠗ"
+d110="࠘"
+d111="࠙"
+d112="ࠛ"
+d113="ࠜ"
+d114="ࠝ"
+d115="ࠞ"
+d116="ࠟ"
+d117="ࠠ"
+d118="ࠡ"
+d119="ࠢ"
+d120="ࠣ"
+d121="ࠥ"
+d122="ࠦ"
+d123="ࠧ"
+d124="ࠩ"
+d125="ࠪ"
+d126="ࠫ"
+d127="ࠬ"
+d128="࠭"
+d129="॑"
+d130="॓"
+d131="॔"
+d132="ྂ"
+d133="ྃ"
+d134="྆"
+d135="྇"
+d136="፝"
+d137="፞"
+d138="፟"
+d139="៝"
+d140="᤺"
+d141="ᨗ"
+d142="᩵"
+d143="᩶"
+d144="᩷"
+d145="᩸"
+d146="᩹"
+d147="᩺"
+d148="᩻"
+d149="᩼"
+d150="᭫"
+d151="᭭"
+d152="᭮"
+d153="᭯"
+d154="᭰"
+d155="᭱"
+d156="᭲"
+d157="᭳"
+d158="᳐"
+d159="᳑"
+d160="᳒"
+d161="᳚"
+d162="᳛"
+d163="᳠"
+d164="᷀"
+d165="᷁"
+d166="᷃"
+d167="᷄"
+d168="᷅"
+d169="᷆"
+d170="᷇"
+d171="᷈"
+d172="᷉"
+d173="᷋"
+d174="᷌"
+d175="᷑"
+d176="᷒"
+d177="ᷓ"
+d178="ᷔ"
+d179="ᷕ"
+d180="ᷖ"
+d181="ᷗ"
+d182="ᷘ"
+d183="ᷙ"
+d184="ᷚ"
+d185="ᷛ"
+d186="ᷜ"
+d187="ᷝ"
+d188="ᷞ"
+d189="ᷟ"
+d190="ᷠ"
+d191="ᷡ"
+d192="ᷢ"
+d193="ᷣ"
+d194="ᷤ"
+d195="ᷥ"
+d196="ᷦ"
+d197="᷾"
+d198="⃐"
+d199="⃑"
+d200="⃔"
+d201="⃕"
+d202="⃖"
+d203="⃗"
+d204="⃛"
+d205="⃜"
+d206="⃡"
+d207="⃧"
+d208="⃩"
+d209="⃰"
+d210="⳯"
+d211="⳰"
+d212="⳱"
+d213="ⷠ"
+d214="ⷡ"
+d215="ⷢ"
+d216="ⷣ"
+d217="ⷤ"
+d218="ⷥ"
+d219="ⷦ"
+d220="ⷧ"
+d221="ⷨ"
+d222="ⷩ"
+d223="ⷪ"
+d224="ⷫ"
+d225="ⷬ"
+d226="ⷭ"
+d227="ⷮ"
+d228="ⷯ"
+d229="ⷰ"
+d230="ⷱ"
+d231="ⷲ"
+d232="ⷳ"
+d233="ⷴ"
+d234="ⷵ"
+d235="ⷶ"
+d236="ⷷ"
+d237="ⷸ"
+d238="ⷹ"
+d239="ⷺ"
+d240="ⷻ"
+d241="ⷼ"
+d242="ⷽ"
+d243="ⷾ"
+d244="ⷿ"
+d245="꙯"
+d246="꙼"
+d247="꙽"
+d248="꛰"
+d249="꛱"
+d250="꣠"
+d251="꣡"
+d252="꣢"
+d253="꣣"
+d254="꣤"
+d255="꣥"
+d256="꣦"
+d257="꣧"
+d258="꣨"
+d259="꣩"
+d260="꣪"
+d261="꣫"
+d262="꣬"
+d263="꣭"
+d264="꣮"
+d265="꣯"
+d266="꣰"
+d267="꣱"
+d268="ꪰ"
+d269="ꪲ"
+d270="ꪳ"
+d271="ꪷ"
+d272="ꪸ"
+d273="ꪾ"
+d274="꪿"
+d275="꫁"
+d276="︠"
+d277="︡"
+d278="︢"
+d279="︣"
+d280="︤"
+d281="︥"
+d282="︦"
+d283="𐨏"
+d284="𐨸"
+d285="𝆅"
+d286="𝆆"
+d287="𝆇"
+d288="𝆈"
+d289="𝆉"
+d290="𝆪"
+d291="𝆫"
+d292="𝆬"
+d293="𝆭"
+d294="𝉂"
+d295="𝉃"
+d296="𝉄"
+
+num_diacritics="297"
+
+placeholder="􎻮"
+
+#####################################################################
+# Upload the image and print the placeholder
+#####################################################################
+
+upload_image_and_print_placeholder
diff --git a/khash.h b/khash.h
new file mode 100644
index 0000000..f75f347
--- /dev/null
+++ b/khash.h
@@ -0,0 +1,627 @@
+/* The MIT License
+
+   Copyright (c) 2008, 2009, 2011 by Attractive Chaos <attractor@live.co.uk>
+
+   Permission is hereby granted, free of charge, to any person obtaining
+   a copy of this software and associated documentation files (the
+   "Software"), to deal in the Software without restriction, including
+   without limitation the rights to use, copy, modify, merge, publish,
+   distribute, sublicense, and/or sell copies of the Software, and to
+   permit persons to whom the Software is furnished to do so, subject to
+   the following conditions:
+
+   The above copyright notice and this permission notice shall be
+   included in all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+   EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+   MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+   BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+   ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+   CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+   SOFTWARE.
+*/
+
+/*
+  An example:
+
+#include "khash.h"
+KHASH_MAP_INIT_INT(32, char)
+int main() {
+	int ret, is_missing;
+	khiter_t k;
+	khash_t(32) *h = kh_init(32);
+	k = kh_put(32, h, 5, &ret);
+	kh_value(h, k) = 10;
+	k = kh_get(32, h, 10);
+	is_missing = (k == kh_end(h));
+	k = kh_get(32, h, 5);
+	kh_del(32, h, k);
+	for (k = kh_begin(h); k != kh_end(h); ++k)
+		if (kh_exist(h, k)) kh_value(h, k) = 1;
+	kh_destroy(32, h);
+	return 0;
+}
+*/
+
+/*
+  2013-05-02 (0.2.8):
+
+	* Use quadratic probing. When the capacity is power of 2, stepping function
+	  i*(i+1)/2 guarantees to traverse each bucket. It is better than double
+	  hashing on cache performance and is more robust than linear probing.
+
+	  In theory, double hashing should be more robust than quadratic probing.
+	  However, my implementation is probably not for large hash tables, because
+	  the second hash function is closely tied to the first hash function,
+	  which reduce the effectiveness of double hashing.
+
+	Reference: http://research.cs.vt.edu/AVresearch/hashing/quadratic.php
+
+  2011-12-29 (0.2.7):
+
+    * Minor code clean up; no actual effect.
+
+  2011-09-16 (0.2.6):
+
+	* The capacity is a power of 2. This seems to dramatically improve the
+	  speed for simple keys. Thank Zilong Tan for the suggestion. Reference:
+
+	   - http://code.google.com/p/ulib/
+	   - http://nothings.org/computer/judy/
+
+	* Allow to optionally use linear probing which usually has better
+	  performance for random input. Double hashing is still the default as it
+	  is more robust to certain non-random input.
+
+	* Added Wang's integer hash function (not used by default). This hash
+	  function is more robust to certain non-random input.
+
+  2011-02-14 (0.2.5):
+
+    * Allow to declare global functions.
+
+  2009-09-26 (0.2.4):
+
+    * Improve portability
+
+  2008-09-19 (0.2.3):
+
+	* Corrected the example
+	* Improved interfaces
+
+  2008-09-11 (0.2.2):
+
+	* Improved speed a little in kh_put()
+
+  2008-09-10 (0.2.1):
+
+	* Added kh_clear()
+	* Fixed a compiling error
+
+  2008-09-02 (0.2.0):
+
+	* Changed to token concatenation which increases flexibility.
+
+  2008-08-31 (0.1.2):
+
+	* Fixed a bug in kh_get(), which has not been tested previously.
+
+  2008-08-31 (0.1.1):
+
+	* Added destructor
+*/
+
+
+#ifndef __AC_KHASH_H
+#define __AC_KHASH_H
+
+/*!
+  @header
+
+  Generic hash table library.
+ */
+
+#define AC_VERSION_KHASH_H "0.2.8"
+
+#include <stdlib.h>
+#include <string.h>
+#include <limits.h>
+
+/* compiler specific configuration */
+
+#if UINT_MAX == 0xffffffffu
+typedef unsigned int khint32_t;
+#elif ULONG_MAX == 0xffffffffu
+typedef unsigned long khint32_t;
+#endif
+
+#if ULONG_MAX == ULLONG_MAX
+typedef unsigned long khint64_t;
+#else
+typedef unsigned long long khint64_t;
+#endif
+
+#ifndef kh_inline
+#ifdef _MSC_VER
+#define kh_inline __inline
+#else
+#define kh_inline inline
+#endif
+#endif /* kh_inline */
+
+#ifndef klib_unused
+#if (defined __clang__ && __clang_major__ >= 3) || (defined __GNUC__ && __GNUC__ >= 3)
+#define klib_unused __attribute__ ((__unused__))
+#else
+#define klib_unused
+#endif
+#endif /* klib_unused */
+
+typedef khint32_t khint_t;
+typedef khint_t khiter_t;
+
+#define __ac_isempty(flag, i) ((flag[i>>4]>>((i&0xfU)<<1))&2)
+#define __ac_isdel(flag, i) ((flag[i>>4]>>((i&0xfU)<<1))&1)
+#define __ac_iseither(flag, i) ((flag[i>>4]>>((i&0xfU)<<1))&3)
+#define __ac_set_isdel_false(flag, i) (flag[i>>4]&=~(1ul<<((i&0xfU)<<1)))
+#define __ac_set_isempty_false(flag, i) (flag[i>>4]&=~(2ul<<((i&0xfU)<<1)))
+#define __ac_set_isboth_false(flag, i) (flag[i>>4]&=~(3ul<<((i&0xfU)<<1)))
+#define __ac_set_isdel_true(flag, i) (flag[i>>4]|=1ul<<((i&0xfU)<<1))
+
+#define __ac_fsize(m) ((m) < 16? 1 : (m)>>4)
+
+#ifndef kroundup32
+#define kroundup32(x) (--(x), (x)|=(x)>>1, (x)|=(x)>>2, (x)|=(x)>>4, (x)|=(x)>>8, (x)|=(x)>>16, ++(x))
+#endif
+
+#ifndef kcalloc
+#define kcalloc(N,Z) calloc(N,Z)
+#endif
+#ifndef kmalloc
+#define kmalloc(Z) malloc(Z)
+#endif
+#ifndef krealloc
+#define krealloc(P,Z) realloc(P,Z)
+#endif
+#ifndef kfree
+#define kfree(P) free(P)
+#endif
+
+static const double __ac_HASH_UPPER = 0.77;
+
+#define __KHASH_TYPE(name, khkey_t, khval_t) \
+	typedef struct kh_##name##_s { \
+		khint_t n_buckets, size, n_occupied, upper_bound; \
+		khint32_t *flags; \
+		khkey_t *keys; \
+		khval_t *vals; \
+	} kh_##name##_t;
+
+#define __KHASH_PROTOTYPES(name, khkey_t, khval_t)	 					\
+	extern kh_##name##_t *kh_init_##name(void);							\
+	extern void kh_destroy_##name(kh_##name##_t *h);					\
+	extern void kh_clear_##name(kh_##name##_t *h);						\
+	extern khint_t kh_get_##name(const kh_##name##_t *h, khkey_t key); 	\
+	extern int kh_resize_##name(kh_##name##_t *h, khint_t new_n_buckets); \
+	extern khint_t kh_put_##name(kh_##name##_t *h, khkey_t key, int *ret); \
+	extern void kh_del_##name(kh_##name##_t *h, khint_t x);
+
+#define __KHASH_IMPL(name, SCOPE, khkey_t, khval_t, kh_is_map, __hash_func, __hash_equal) \
+	SCOPE kh_##name##_t *kh_init_##name(void) {							\
+		return (kh_##name##_t*)kcalloc(1, sizeof(kh_##name##_t));		\
+	}																	\
+	SCOPE void kh_destroy_##name(kh_##name##_t *h)						\
+	{																	\
+		if (h) {														\
+			kfree((void *)h->keys); kfree(h->flags);					\
+			kfree((void *)h->vals);										\
+			kfree(h);													\
+		}																\
+	}																	\
+	SCOPE void kh_clear_##name(kh_##name##_t *h)						\
+	{																	\
+		if (h && h->flags) {											\
+			memset(h->flags, 0xaa, __ac_fsize(h->n_buckets) * sizeof(khint32_t)); \
+			h->size = h->n_occupied = 0;								\
+		}																\
+	}																	\
+	SCOPE khint_t kh_get_##name(const kh_##name##_t *h, khkey_t key) 	\
+	{																	\
+		if (h->n_buckets) {												\
+			khint_t k, i, last, mask, step = 0; \
+			mask = h->n_buckets - 1;									\
+			k = __hash_func(key); i = k & mask;							\
+			last = i; \
+			while (!__ac_isempty(h->flags, i) && (__ac_isdel(h->flags, i) || !__hash_equal(h->keys[i], key))) { \
+				i = (i + (++step)) & mask; \
+				if (i == last) return h->n_buckets;						\
+			}															\
+			return __ac_iseither(h->flags, i)? h->n_buckets : i;		\
+		} else return 0;												\
+	}																	\
+	SCOPE int kh_resize_##name(kh_##name##_t *h, khint_t new_n_buckets) \
+	{ /* This function uses 0.25*n_buckets bytes of working space instead of [sizeof(key_t+val_t)+.25]*n_buckets. */ \
+		khint32_t *new_flags = 0;										\
+		khint_t j = 1;													\
+		{																\
+			kroundup32(new_n_buckets); 									\
+			if (new_n_buckets < 4) new_n_buckets = 4;					\
+			if (h->size >= (khint_t)(new_n_buckets * __ac_HASH_UPPER + 0.5)) j = 0;	/* requested size is too small */ \
+			else { /* hash table size to be changed (shrink or expand); rehash */ \
+				new_flags = (khint32_t*)kmalloc(__ac_fsize(new_n_buckets) * sizeof(khint32_t));	\
+				if (!new_flags) return -1;								\
+				memset(new_flags, 0xaa, __ac_fsize(new_n_buckets) * sizeof(khint32_t)); \
+				if (h->n_buckets < new_n_buckets) {	/* expand */		\
+					khkey_t *new_keys = (khkey_t*)krealloc((void *)h->keys, new_n_buckets * sizeof(khkey_t)); \
+					if (!new_keys) { kfree(new_flags); return -1; }		\
+					h->keys = new_keys;									\
+					if (kh_is_map) {									\
+						khval_t *new_vals = (khval_t*)krealloc((void *)h->vals, new_n_buckets * sizeof(khval_t)); \
+						if (!new_vals) { kfree(new_flags); return -1; }	\
+						h->vals = new_vals;								\
+					}													\
+				} /* otherwise shrink */								\
+			}															\
+		}																\
+		if (j) { /* rehashing is needed */								\
+			for (j = 0; j != h->n_buckets; ++j) {						\
+				if (__ac_iseither(h->flags, j) == 0) {					\
+					khkey_t key = h->keys[j];							\
+					khval_t val;										\
+					khint_t new_mask;									\
+					new_mask = new_n_buckets - 1; 						\
+					if (kh_is_map) val = h->vals[j];					\
+					__ac_set_isdel_true(h->flags, j);					\
+					while (1) { /* kick-out process; sort of like in Cuckoo hashing */ \
+						khint_t k, i, step = 0; \
+						k = __hash_func(key);							\
+						i = k & new_mask;								\
+						while (!__ac_isempty(new_flags, i)) i = (i + (++step)) & new_mask; \
+						__ac_set_isempty_false(new_flags, i);			\
+						if (i < h->n_buckets && __ac_iseither(h->flags, i) == 0) { /* kick out the existing element */ \
+							{ khkey_t tmp = h->keys[i]; h->keys[i] = key; key = tmp; } \
+							if (kh_is_map) { khval_t tmp = h->vals[i]; h->vals[i] = val; val = tmp; } \
+							__ac_set_isdel_true(h->flags, i); /* mark it as deleted in the old hash table */ \
+						} else { /* write the element and jump out of the loop */ \
+							h->keys[i] = key;							\
+							if (kh_is_map) h->vals[i] = val;			\
+							break;										\
+						}												\
+					}													\
+				}														\
+			}															\
+			if (h->n_buckets > new_n_buckets) { /* shrink the hash table */ \
+				h->keys = (khkey_t*)krealloc((void *)h->keys, new_n_buckets * sizeof(khkey_t)); \
+				if (kh_is_map) h->vals = (khval_t*)krealloc((void *)h->vals, new_n_buckets * sizeof(khval_t)); \
+			}															\
+			kfree(h->flags); /* free the working space */				\
+			h->flags = new_flags;										\
+			h->n_buckets = new_n_buckets;								\
+			h->n_occupied = h->size;									\
+			h->upper_bound = (khint_t)(h->n_buckets * __ac_HASH_UPPER + 0.5); \
+		}																\
+		return 0;														\
+	}																	\
+	SCOPE khint_t kh_put_##name(kh_##name##_t *h, khkey_t key, int *ret) \
+	{																	\
+		khint_t x;														\
+		if (h->n_occupied >= h->upper_bound) { /* update the hash table */ \
+			if (h->n_buckets > (h->size<<1)) {							\
+				if (kh_resize_##name(h, h->n_buckets - 1) < 0) { /* clear "deleted" elements */ \
+					*ret = -1; return h->n_buckets;						\
+				}														\
+			} else if (kh_resize_##name(h, h->n_buckets + 1) < 0) { /* expand the hash table */ \
+				*ret = -1; return h->n_buckets;							\
+			}															\
+		} /* TODO: to implement automatically shrinking; resize() already support shrinking */ \
+		{																\
+			khint_t k, i, site, last, mask = h->n_buckets - 1, step = 0; \
+			x = site = h->n_buckets; k = __hash_func(key); i = k & mask; \
+			if (__ac_isempty(h->flags, i)) x = i; /* for speed up */	\
+			else {														\
+				last = i; \
+				while (!__ac_isempty(h->flags, i) && (__ac_isdel(h->flags, i) || !__hash_equal(h->keys[i], key))) { \
+					if (__ac_isdel(h->flags, i)) site = i;				\
+					i = (i + (++step)) & mask; \
+					if (i == last) { x = site; break; }					\
+				}														\
+				if (x == h->n_buckets) {								\
+					if (__ac_isempty(h->flags, i) && site != h->n_buckets) x = site; \
+					else x = i;											\
+				}														\
+			}															\
+		}																\
+		if (__ac_isempty(h->flags, x)) { /* not present at all */		\
+			h->keys[x] = key;											\
+			__ac_set_isboth_false(h->flags, x);							\
+			++h->size; ++h->n_occupied;									\
+			*ret = 1;													\
+		} else if (__ac_isdel(h->flags, x)) { /* deleted */				\
+			h->keys[x] = key;											\
+			__ac_set_isboth_false(h->flags, x);							\
+			++h->size;													\
+			*ret = 2;													\
+		} else *ret = 0; /* Don't touch h->keys[x] if present and not deleted */ \
+		return x;														\
+	}																	\
+	SCOPE void kh_del_##name(kh_##name##_t *h, khint_t x)				\
+	{																	\
+		if (x != h->n_buckets && !__ac_iseither(h->flags, x)) {			\
+			__ac_set_isdel_true(h->flags, x);							\
+			--h->size;													\
+		}																\
+	}
+
+#define KHASH_DECLARE(name, khkey_t, khval_t)		 					\
+	__KHASH_TYPE(name, khkey_t, khval_t) 								\
+	__KHASH_PROTOTYPES(name, khkey_t, khval_t)
+
+#define KHASH_INIT2(name, SCOPE, khkey_t, khval_t, kh_is_map, __hash_func, __hash_equal) \
+	__KHASH_TYPE(name, khkey_t, khval_t) 								\
+	__KHASH_IMPL(name, SCOPE, khkey_t, khval_t, kh_is_map, __hash_func, __hash_equal)
+
+#define KHASH_INIT(name, khkey_t, khval_t, kh_is_map, __hash_func, __hash_equal) \
+	KHASH_INIT2(name, static kh_inline klib_unused, khkey_t, khval_t, kh_is_map, __hash_func, __hash_equal)
+
+/* --- BEGIN OF HASH FUNCTIONS --- */
+
+/*! @function
+  @abstract     Integer hash function
+  @param  key   The integer [khint32_t]
+  @return       The hash value [khint_t]
+ */
+#define kh_int_hash_func(key) (khint32_t)(key)
+/*! @function
+  @abstract     Integer comparison function
+ */
+#define kh_int_hash_equal(a, b) ((a) == (b))
+/*! @function
+  @abstract     64-bit integer hash function
+  @param  key   The integer [khint64_t]
+  @return       The hash value [khint_t]
+ */
+#define kh_int64_hash_func(key) (khint32_t)((key)>>33^(key)^(key)<<11)
+/*! @function
+  @abstract     64-bit integer comparison function
+ */
+#define kh_int64_hash_equal(a, b) ((a) == (b))
+/*! @function
+  @abstract     const char* hash function
+  @param  s     Pointer to a null terminated string
+  @return       The hash value
+ */
+static kh_inline khint_t __ac_X31_hash_string(const char *s)
+{
+	khint_t h = (khint_t)*s;
+	if (h) for (++s ; *s; ++s) h = (h << 5) - h + (khint_t)*s;
+	return h;
+}
+/*! @function
+  @abstract     Another interface to const char* hash function
+  @param  key   Pointer to a null terminated string [const char*]
+  @return       The hash value [khint_t]
+ */
+#define kh_str_hash_func(key) __ac_X31_hash_string(key)
+/*! @function
+  @abstract     Const char* comparison function
+ */
+#define kh_str_hash_equal(a, b) (strcmp(a, b) == 0)
+
+static kh_inline khint_t __ac_Wang_hash(khint_t key)
+{
+    key += ~(key << 15);
+    key ^=  (key >> 10);
+    key +=  (key << 3);
+    key ^=  (key >> 6);
+    key += ~(key << 11);
+    key ^=  (key >> 16);
+    return key;
+}
+#define kh_int_hash_func2(key) __ac_Wang_hash((khint_t)key)
+
+/* --- END OF HASH FUNCTIONS --- */
+
+/* Other convenient macros... */
+
+/*!
+  @abstract Type of the hash table.
+  @param  name  Name of the hash table [symbol]
+ */
+#define khash_t(name) kh_##name##_t
+
+/*! @function
+  @abstract     Initiate a hash table.
+  @param  name  Name of the hash table [symbol]
+  @return       Pointer to the hash table [khash_t(name)*]
+ */
+#define kh_init(name) kh_init_##name()
+
+/*! @function
+  @abstract     Destroy a hash table.
+  @param  name  Name of the hash table [symbol]
+  @param  h     Pointer to the hash table [khash_t(name)*]
+ */
+#define kh_destroy(name, h) kh_destroy_##name(h)
+
+/*! @function
+  @abstract     Reset a hash table without deallocating memory.
+  @param  name  Name of the hash table [symbol]
+  @param  h     Pointer to the hash table [khash_t(name)*]
+ */
+#define kh_clear(name, h) kh_clear_##name(h)
+
+/*! @function
+  @abstract     Resize a hash table.
+  @param  name  Name of the hash table [symbol]
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @param  s     New size [khint_t]
+ */
+#define kh_resize(name, h, s) kh_resize_##name(h, s)
+
+/*! @function
+  @abstract     Insert a key to the hash table.
+  @param  name  Name of the hash table [symbol]
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @param  k     Key [type of keys]
+  @param  r     Extra return code: -1 if the operation failed;
+                0 if the key is present in the hash table;
+                1 if the bucket is empty (never used); 2 if the element in
+				the bucket has been deleted [int*]
+  @return       Iterator to the inserted element [khint_t]
+ */
+#define kh_put(name, h, k, r) kh_put_##name(h, k, r)
+
+/*! @function
+  @abstract     Retrieve a key from the hash table.
+  @param  name  Name of the hash table [symbol]
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @param  k     Key [type of keys]
+  @return       Iterator to the found element, or kh_end(h) if the element is absent [khint_t]
+ */
+#define kh_get(name, h, k) kh_get_##name(h, k)
+
+/*! @function
+  @abstract     Remove a key from the hash table.
+  @param  name  Name of the hash table [symbol]
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @param  k     Iterator to the element to be deleted [khint_t]
+ */
+#define kh_del(name, h, k) kh_del_##name(h, k)
+
+/*! @function
+  @abstract     Test whether a bucket contains data.
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @param  x     Iterator to the bucket [khint_t]
+  @return       1 if containing data; 0 otherwise [int]
+ */
+#define kh_exist(h, x) (!__ac_iseither((h)->flags, (x)))
+
+/*! @function
+  @abstract     Get key given an iterator
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @param  x     Iterator to the bucket [khint_t]
+  @return       Key [type of keys]
+ */
+#define kh_key(h, x) ((h)->keys[x])
+
+/*! @function
+  @abstract     Get value given an iterator
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @param  x     Iterator to the bucket [khint_t]
+  @return       Value [type of values]
+  @discussion   For hash sets, calling this results in segfault.
+ */
+#define kh_val(h, x) ((h)->vals[x])
+
+/*! @function
+  @abstract     Alias of kh_val()
+ */
+#define kh_value(h, x) ((h)->vals[x])
+
+/*! @function
+  @abstract     Get the start iterator
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @return       The start iterator [khint_t]
+ */
+#define kh_begin(h) (khint_t)(0)
+
+/*! @function
+  @abstract     Get the end iterator
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @return       The end iterator [khint_t]
+ */
+#define kh_end(h) ((h)->n_buckets)
+
+/*! @function
+  @abstract     Get the number of elements in the hash table
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @return       Number of elements in the hash table [khint_t]
+ */
+#define kh_size(h) ((h)->size)
+
+/*! @function
+  @abstract     Get the number of buckets in the hash table
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @return       Number of buckets in the hash table [khint_t]
+ */
+#define kh_n_buckets(h) ((h)->n_buckets)
+
+/*! @function
+  @abstract     Iterate over the entries in the hash table
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @param  kvar  Variable to which key will be assigned
+  @param  vvar  Variable to which value will be assigned
+  @param  code  Block of code to execute
+ */
+#define kh_foreach(h, kvar, vvar, code) { khint_t __i;		\
+	for (__i = kh_begin(h); __i != kh_end(h); ++__i) {		\
+		if (!kh_exist(h,__i)) continue;						\
+		(kvar) = kh_key(h,__i);								\
+		(vvar) = kh_val(h,__i);								\
+		code;												\
+	} }
+
+/*! @function
+  @abstract     Iterate over the values in the hash table
+  @param  h     Pointer to the hash table [khash_t(name)*]
+  @param  vvar  Variable to which value will be assigned
+  @param  code  Block of code to execute
+ */
+#define kh_foreach_value(h, vvar, code) { khint_t __i;		\
+	for (__i = kh_begin(h); __i != kh_end(h); ++__i) {		\
+		if (!kh_exist(h,__i)) continue;						\
+		(vvar) = kh_val(h,__i);								\
+		code;												\
+	} }
+
+/* More convenient interfaces */
+
+/*! @function
+  @abstract     Instantiate a hash set containing integer keys
+  @param  name  Name of the hash table [symbol]
+ */
+#define KHASH_SET_INIT_INT(name)										\
+	KHASH_INIT(name, khint32_t, char, 0, kh_int_hash_func, kh_int_hash_equal)
+
+/*! @function
+  @abstract     Instantiate a hash map containing integer keys
+  @param  name  Name of the hash table [symbol]
+  @param  khval_t  Type of values [type]
+ */
+#define KHASH_MAP_INIT_INT(name, khval_t)								\
+	KHASH_INIT(name, khint32_t, khval_t, 1, kh_int_hash_func, kh_int_hash_equal)
+
+/*! @function
+  @abstract     Instantiate a hash set containing 64-bit integer keys
+  @param  name  Name of the hash table [symbol]
+ */
+#define KHASH_SET_INIT_INT64(name)										\
+	KHASH_INIT(name, khint64_t, char, 0, kh_int64_hash_func, kh_int64_hash_equal)
+
+/*! @function
+  @abstract     Instantiate a hash map containing 64-bit integer keys
+  @param  name  Name of the hash table [symbol]
+  @param  khval_t  Type of values [type]
+ */
+#define KHASH_MAP_INIT_INT64(name, khval_t)								\
+	KHASH_INIT(name, khint64_t, khval_t, 1, kh_int64_hash_func, kh_int64_hash_equal)
+
+typedef const char *kh_cstr_t;
+/*! @function
+  @abstract     Instantiate a hash map containing const char* keys
+  @param  name  Name of the hash table [symbol]
+ */
+#define KHASH_SET_INIT_STR(name)										\
+	KHASH_INIT(name, kh_cstr_t, char, 0, kh_str_hash_func, kh_str_hash_equal)
+
+/*! @function
+  @abstract     Instantiate a hash map containing const char* keys
+  @param  name  Name of the hash table [symbol]
+  @param  khval_t  Type of values [type]
+ */
+#define KHASH_MAP_INIT_STR(name, khval_t)								\
+	KHASH_INIT(name, kh_cstr_t, khval_t, 1, kh_str_hash_func, kh_str_hash_equal)
+
+#endif /* __AC_KHASH_H */
diff --git a/kvec.h b/kvec.h
new file mode 100644
index 0000000..10f1c5b
--- /dev/null
+++ b/kvec.h
@@ -0,0 +1,90 @@
+/* The MIT License
+
+   Copyright (c) 2008, by Attractive Chaos <attractor@live.co.uk>
+
+   Permission is hereby granted, free of charge, to any person obtaining
+   a copy of this software and associated documentation files (the
+   "Software"), to deal in the Software without restriction, including
+   without limitation the rights to use, copy, modify, merge, publish,
+   distribute, sublicense, and/or sell copies of the Software, and to
+   permit persons to whom the Software is furnished to do so, subject to
+   the following conditions:
+
+   The above copyright notice and this permission notice shall be
+   included in all copies or substantial portions of the Software.
+
+   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+   EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+   MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+   BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+   ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+   CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+   SOFTWARE.
+*/
+
+/*
+  An example:
+
+#include "kvec.h"
+int main() {
+	kvec_t(int) array;
+	kv_init(array);
+	kv_push(int, array, 10); // append
+	kv_a(int, array, 20) = 5; // dynamic
+	kv_A(array, 20) = 4; // static
+	kv_destroy(array);
+	return 0;
+}
+*/
+
+/*
+  2008-09-22 (0.1.0):
+
+	* The initial version.
+
+*/
+
+#ifndef AC_KVEC_H
+#define AC_KVEC_H
+
+#include <stdlib.h>
+
+#define kv_roundup32(x) (--(x), (x)|=(x)>>1, (x)|=(x)>>2, (x)|=(x)>>4, (x)|=(x)>>8, (x)|=(x)>>16, ++(x))
+
+#define kvec_t(type) struct { size_t n, m; type *a; }
+#define kv_init(v) ((v).n = (v).m = 0, (v).a = 0)
+#define kv_destroy(v) free((v).a)
+#define kv_A(v, i) ((v).a[(i)])
+#define kv_pop(v) ((v).a[--(v).n])
+#define kv_size(v) ((v).n)
+#define kv_max(v) ((v).m)
+
+#define kv_resize(type, v, s)  ((v).m = (s), (v).a = (type*)realloc((v).a, sizeof(type) * (v).m))
+
+#define kv_copy(type, v1, v0) do {							\
+		if ((v1).m < (v0).n) kv_resize(type, v1, (v0).n);	\
+		(v1).n = (v0).n;									\
+		memcpy((v1).a, (v0).a, sizeof(type) * (v0).n);		\
+	} while (0)												\
+
+#define kv_push(type, v, x) do {									\
+		if ((v).n == (v).m) {										\
+			(v).m = (v).m? (v).m<<1 : 2;							\
+			(v).a = (type*)realloc((v).a, sizeof(type) * (v).m);	\
+		}															\
+		(v).a[(v).n++] = (x);										\
+	} while (0)
+
+#define kv_pushp(type, v) ((((v).n == (v).m)?							\
+						   ((v).m = ((v).m? (v).m<<1 : 2),				\
+							(v).a = (type*)realloc((v).a, sizeof(type) * (v).m), 0)	\
+						   : 0), ((v).a + ((v).n++)))
+
+#define kv_a(type, v, i) (((v).m <= (size_t)(i)? \
+						  ((v).m = (v).n = (i) + 1, kv_roundup32((v).m), \
+						   (v).a = (type*)realloc((v).a, sizeof(type) * (v).m), 0) \
+						  : (v).n <= (size_t)(i)? (v).n = (i) + 1 \
+						  : 0), (v).a[(i)])
+
+#endif
diff --git a/rowcolumn_diacritics_helpers.c b/rowcolumn_diacritics_helpers.c
new file mode 100644
index 0000000..829c0fc
--- /dev/null
+++ b/rowcolumn_diacritics_helpers.c
@@ -0,0 +1,391 @@
+#include <stdint.h>
+
+uint16_t diacritic_to_num(uint32_t code)
+{
+	switch (code) {
+	case 0x305:
+		return code - 0x305 + 1;
+	case 0x30d:
+	case 0x30e:
+		return code - 0x30d + 2;
+	case 0x310:
+		return code - 0x310 + 4;
+	case 0x312:
+		return code - 0x312 + 5;
+	case 0x33d:
+	case 0x33e:
+	case 0x33f:
+		return code - 0x33d + 6;
+	case 0x346:
+		return code - 0x346 + 9;
+	case 0x34a:
+	case 0x34b:
+	case 0x34c:
+		return code - 0x34a + 10;
+	case 0x350:
+	case 0x351:
+	case 0x352:
+		return code - 0x350 + 13;
+	case 0x357:
+		return code - 0x357 + 16;
+	case 0x35b:
+		return code - 0x35b + 17;
+	case 0x363:
+	case 0x364:
+	case 0x365:
+	case 0x366:
+	case 0x367:
+	case 0x368:
+	case 0x369:
+	case 0x36a:
+	case 0x36b:
+	case 0x36c:
+	case 0x36d:
+	case 0x36e:
+	case 0x36f:
+		return code - 0x363 + 18;
+	case 0x483:
+	case 0x484:
+	case 0x485:
+	case 0x486:
+	case 0x487:
+		return code - 0x483 + 31;
+	case 0x592:
+	case 0x593:
+	case 0x594:
+	case 0x595:
+		return code - 0x592 + 36;
+	case 0x597:
+	case 0x598:
+	case 0x599:
+		return code - 0x597 + 40;
+	case 0x59c:
+	case 0x59d:
+	case 0x59e:
+	case 0x59f:
+	case 0x5a0:
+	case 0x5a1:
+		return code - 0x59c + 43;
+	case 0x5a8:
+	case 0x5a9:
+		return code - 0x5a8 + 49;
+	case 0x5ab:
+	case 0x5ac:
+		return code - 0x5ab + 51;
+	case 0x5af:
+		return code - 0x5af + 53;
+	case 0x5c4:
+		return code - 0x5c4 + 54;
+	case 0x610:
+	case 0x611:
+	case 0x612:
+	case 0x613:
+	case 0x614:
+	case 0x615:
+	case 0x616:
+	case 0x617:
+		return code - 0x610 + 55;
+	case 0x657:
+	case 0x658:
+	case 0x659:
+	case 0x65a:
+	case 0x65b:
+		return code - 0x657 + 63;
+	case 0x65d:
+	case 0x65e:
+		return code - 0x65d + 68;
+	case 0x6d6:
+	case 0x6d7:
+	case 0x6d8:
+	case 0x6d9:
+	case 0x6da:
+	case 0x6db:
+	case 0x6dc:
+		return code - 0x6d6 + 70;
+	case 0x6df:
+	case 0x6e0:
+	case 0x6e1:
+	case 0x6e2:
+		return code - 0x6df + 77;
+	case 0x6e4:
+		return code - 0x6e4 + 81;
+	case 0x6e7:
+	case 0x6e8:
+		return code - 0x6e7 + 82;
+	case 0x6eb:
+	case 0x6ec:
+		return code - 0x6eb + 84;
+	case 0x730:
+		return code - 0x730 + 86;
+	case 0x732:
+	case 0x733:
+		return code - 0x732 + 87;
+	case 0x735:
+	case 0x736:
+		return code - 0x735 + 89;
+	case 0x73a:
+		return code - 0x73a + 91;
+	case 0x73d:
+		return code - 0x73d + 92;
+	case 0x73f:
+	case 0x740:
+	case 0x741:
+		return code - 0x73f + 93;
+	case 0x743:
+		return code - 0x743 + 96;
+	case 0x745:
+		return code - 0x745 + 97;
+	case 0x747:
+		return code - 0x747 + 98;
+	case 0x749:
+	case 0x74a:
+		return code - 0x749 + 99;
+	case 0x7eb:
+	case 0x7ec:
+	case 0x7ed:
+	case 0x7ee:
+	case 0x7ef:
+	case 0x7f0:
+	case 0x7f1:
+		return code - 0x7eb + 101;
+	case 0x7f3:
+		return code - 0x7f3 + 108;
+	case 0x816:
+	case 0x817:
+	case 0x818:
+	case 0x819:
+		return code - 0x816 + 109;
+	case 0x81b:
+	case 0x81c:
+	case 0x81d:
+	case 0x81e:
+	case 0x81f:
+	case 0x820:
+	case 0x821:
+	case 0x822:
+	case 0x823:
+		return code - 0x81b + 113;
+	case 0x825:
+	case 0x826:
+	case 0x827:
+		return code - 0x825 + 122;
+	case 0x829:
+	case 0x82a:
+	case 0x82b:
+	case 0x82c:
+	case 0x82d:
+		return code - 0x829 + 125;
+	case 0x951:
+		return code - 0x951 + 130;
+	case 0x953:
+	case 0x954:
+		return code - 0x953 + 131;
+	case 0xf82:
+	case 0xf83:
+		return code - 0xf82 + 133;
+	case 0xf86:
+	case 0xf87:
+		return code - 0xf86 + 135;
+	case 0x135d:
+	case 0x135e:
+	case 0x135f:
+		return code - 0x135d + 137;
+	case 0x17dd:
+		return code - 0x17dd + 140;
+	case 0x193a:
+		return code - 0x193a + 141;
+	case 0x1a17:
+		return code - 0x1a17 + 142;
+	case 0x1a75:
+	case 0x1a76:
+	case 0x1a77:
+	case 0x1a78:
+	case 0x1a79:
+	case 0x1a7a:
+	case 0x1a7b:
+	case 0x1a7c:
+		return code - 0x1a75 + 143;
+	case 0x1b6b:
+		return code - 0x1b6b + 151;
+	case 0x1b6d:
+	case 0x1b6e:
+	case 0x1b6f:
+	case 0x1b70:
+	case 0x1b71:
+	case 0x1b72:
+	case 0x1b73:
+		return code - 0x1b6d + 152;
+	case 0x1cd0:
+	case 0x1cd1:
+	case 0x1cd2:
+		return code - 0x1cd0 + 159;
+	case 0x1cda:
+	case 0x1cdb:
+		return code - 0x1cda + 162;
+	case 0x1ce0:
+		return code - 0x1ce0 + 164;
+	case 0x1dc0:
+	case 0x1dc1:
+		return code - 0x1dc0 + 165;
+	case 0x1dc3:
+	case 0x1dc4:
+	case 0x1dc5:
+	case 0x1dc6:
+	case 0x1dc7:
+	case 0x1dc8:
+	case 0x1dc9:
+		return code - 0x1dc3 + 167;
+	case 0x1dcb:
+	case 0x1dcc:
+		return code - 0x1dcb + 174;
+	case 0x1dd1:
+	case 0x1dd2:
+	case 0x1dd3:
+	case 0x1dd4:
+	case 0x1dd5:
+	case 0x1dd6:
+	case 0x1dd7:
+	case 0x1dd8:
+	case 0x1dd9:
+	case 0x1dda:
+	case 0x1ddb:
+	case 0x1ddc:
+	case 0x1ddd:
+	case 0x1dde:
+	case 0x1ddf:
+	case 0x1de0:
+	case 0x1de1:
+	case 0x1de2:
+	case 0x1de3:
+	case 0x1de4:
+	case 0x1de5:
+	case 0x1de6:
+		return code - 0x1dd1 + 176;
+	case 0x1dfe:
+		return code - 0x1dfe + 198;
+	case 0x20d0:
+	case 0x20d1:
+		return code - 0x20d0 + 199;
+	case 0x20d4:
+	case 0x20d5:
+	case 0x20d6:
+	case 0x20d7:
+		return code - 0x20d4 + 201;
+	case 0x20db:
+	case 0x20dc:
+		return code - 0x20db + 205;
+	case 0x20e1:
+		return code - 0x20e1 + 207;
+	case 0x20e7:
+		return code - 0x20e7 + 208;
+	case 0x20e9:
+		return code - 0x20e9 + 209;
+	case 0x20f0:
+		return code - 0x20f0 + 210;
+	case 0x2cef:
+	case 0x2cf0:
+	case 0x2cf1:
+		return code - 0x2cef + 211;
+	case 0x2de0:
+	case 0x2de1:
+	case 0x2de2:
+	case 0x2de3:
+	case 0x2de4:
+	case 0x2de5:
+	case 0x2de6:
+	case 0x2de7:
+	case 0x2de8:
+	case 0x2de9:
+	case 0x2dea:
+	case 0x2deb:
+	case 0x2dec:
+	case 0x2ded:
+	case 0x2dee:
+	case 0x2def:
+	case 0x2df0:
+	case 0x2df1:
+	case 0x2df2:
+	case 0x2df3:
+	case 0x2df4:
+	case 0x2df5:
+	case 0x2df6:
+	case 0x2df7:
+	case 0x2df8:
+	case 0x2df9:
+	case 0x2dfa:
+	case 0x2dfb:
+	case 0x2dfc:
+	case 0x2dfd:
+	case 0x2dfe:
+	case 0x2dff:
+		return code - 0x2de0 + 214;
+	case 0xa66f:
+		return code - 0xa66f + 246;
+	case 0xa67c:
+	case 0xa67d:
+		return code - 0xa67c + 247;
+	case 0xa6f0:
+	case 0xa6f1:
+		return code - 0xa6f0 + 249;
+	case 0xa8e0:
+	case 0xa8e1:
+	case 0xa8e2:
+	case 0xa8e3:
+	case 0xa8e4:
+	case 0xa8e5:
+	case 0xa8e6:
+	case 0xa8e7:
+	case 0xa8e8:
+	case 0xa8e9:
+	case 0xa8ea:
+	case 0xa8eb:
+	case 0xa8ec:
+	case 0xa8ed:
+	case 0xa8ee:
+	case 0xa8ef:
+	case 0xa8f0:
+	case 0xa8f1:
+		return code - 0xa8e0 + 251;
+	case 0xaab0:
+		return code - 0xaab0 + 269;
+	case 0xaab2:
+	case 0xaab3:
+		return code - 0xaab2 + 270;
+	case 0xaab7:
+	case 0xaab8:
+		return code - 0xaab7 + 272;
+	case 0xaabe:
+	case 0xaabf:
+		return code - 0xaabe + 274;
+	case 0xaac1:
+		return code - 0xaac1 + 276;
+	case 0xfe20:
+	case 0xfe21:
+	case 0xfe22:
+	case 0xfe23:
+	case 0xfe24:
+	case 0xfe25:
+	case 0xfe26:
+		return code - 0xfe20 + 277;
+	case 0x10a0f:
+		return code - 0x10a0f + 284;
+	case 0x10a38:
+		return code - 0x10a38 + 285;
+	case 0x1d185:
+	case 0x1d186:
+	case 0x1d187:
+	case 0x1d188:
+	case 0x1d189:
+		return code - 0x1d185 + 286;
+	case 0x1d1aa:
+	case 0x1d1ab:
+	case 0x1d1ac:
+	case 0x1d1ad:
+		return code - 0x1d1aa + 291;
+	case 0x1d242:
+	case 0x1d243:
+	case 0x1d244:
+		return code - 0x1d242 + 295;
+	}
+	return 0;
+}
