Generate an Image from Text with a Transformation
A Transformation can return bytes instead of JSON or text. When it does, Hookdeck delivers those bytes to your Destination unchanged, with the content type the transformation sets. Your destination receives finished content, generated as part of delivery.
This guide turns a JSON request such as { "text": "Hookdeck transformations\nSky's the limit!" } into a PNG image. The transformation has no dependencies: it draws the text with a built-in pixel font and encodes the PNG with node:zlib, so you can paste it straight into the transformation editor.
How it works
- Your Source receives a JSON request with a
textfield. - The transformation renders the text into a grayscale bitmap and encodes it as a PNG.
- It sets
request.bodyto the PNG bytes (aBuffer) andcontent-typetoimage/png. - Hookdeck stores the event as a binary payload and delivers the PNG to your destination.
Returning a Buffer, Uint8Array or ArrayBuffer turns any request into a binary payload. See return value for the full rules.
Add the transformation
Open the Connection that should produce images, add a transformation rule, and paste this code. See create a transformation for the step-by-step.
const { deflateSync, crc32 } = require('node:zlib');
// 5x7 pixel font: 7 rows per character, 5 bits per row, as hex.
const FONT = {
A: '0e11111f111111', B: '1e11111e11111e', C: '0e11101010110e', D: '1e11111111111e',
E: '1f10101e10101f', F: '1f10101e101010', G: '0e11101711110f',
H: '1111111f111111', I: '0e04040404040e', J: '0702020202120c', K: '11121418141211',
L: '1010101010101f', M: '111b1515111111', N: '11111915131111', O: '0e11111111110e',
P: '1e11111e101010', Q: '0e11111115120d', R: '1e11111e141211', S: '0f10100e01011e',
T: '1f040404040404', U: '1111111111110e', V: '11111111110a04',
W: '1111111515150a', X: '11110a040a1111', Y: '1111110a040404', Z: '1f01020408101f',
0: '0e11131519110e', 1: '040c040404040e', 2: '0e11010204081f',
3: '1f02040201110e', 4: '02060a121f0202', 5: '1f101e0101110e', 6: '0608101e11110e',
7: '1f010204080808', 8: '0e11110e11110e', 9: '0e11110f01020c', ' ': '00000000000000',
'.': '00000000000c0c', ',': '000000000c0408', '!': '04040404040004', '?': '0e110102040004',
'-': '0000001f000000', ':': '000c0c000c0c00', '#': '0a0a1f0a1f0a0a', '$': '040f140e051e04',
"'": '04040800000000', '"': '0a0a0000000000', '(': '02040808080402', ')': '08040202020408',
'/': '01010204081010', '+': '0004041f040400', '=': '00001f001f0000', '_': '0000000000001f',
';': '000c0c000c0408', '@': '0e11171517100e', '&': '0c12140815120d', '*': '0004150e150400',
'%': '18190204081303', '<': '02040810080402', '>': '08040201020408', '[': '0e08080808080e',
']': '0e02020202020e',
};
const SCALE = 4; // each font pixel becomes SCALE x SCALE image pixels
const PAD = 8;
// Renders text (one line per \n) as a black-on-white grayscale PNG.
function renderPng(text) {
const lines = text
.replace(/[\u2018\u2019]/g, "'") // curly quotes, as typed on phones and in editors
.replace(/[\u201c\u201d]/g, '"')
.toUpperCase()
.split('\n');
const cols = Math.max(...lines.map((line) => line.length));
const width = PAD * 2 + cols * 6 * SCALE - SCALE;
const height = PAD * 2 + lines.length * 8 * SCALE - SCALE;
const pixels = Buffer.alloc(width * height, 255);
lines.forEach((line, row) => {
[...line].forEach((char, col) => {
const glyph = Buffer.from(FONT[char] ?? FONT['?'], 'hex');
glyph.forEach((bits, y) => {
for (let x = 0; x < 5; x++) {
if (!(bits & (0x10 >> x))) continue;
const left = PAD + (col * 6 + x) * SCALE;
const top = PAD + (row * 8 + y) * SCALE;
for (let dy = 0; dy < SCALE; dy++) {
const start = (top + dy) * width + left;
pixels.fill(0, start, start + SCALE);
}
}
});
});
});
// Each scanline starts with a filter byte (0 = none).
const raw = Buffer.alloc((width + 1) * height);
for (let y = 0; y < height; y++) pixels.copy(raw, y * (width + 1) + 1, y * width, (y + 1) * width);
const chunk = (type, data) => {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, crc]);
};
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8; // bit depth
header[9] = 0; // grayscale
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', header),
chunk('IDAT', deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}
addHandler('transform', (request) => {
request.body = renderPng(String(request.body.text));
request.headers['content-type'] = 'image/png';
return request;
});
Send a request
Send a JSON request with a text field to your source URL. Use \n in the text to start a new line.
curl -X POST "https://hkdk.events/src_xxxxxxxx" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{ "text": "Hookdeck transformations\nSky's the limit!" }
EOF
The transformation renders this image:

What your destination receives
Your destination receives a POST with Content-Type: image/png and the PNG as the body, about 900 bytes for the request above. The other headers, the path and the query string from the original request are kept.
In the dashboard, the event's body shows Binary payload · image/png, with a preview of the image and a button. See inspect an event.
How the code works
The font. FONT maps each character to a 5x7 pixel glyph. Each glyph is 7 rows, and each row is one byte written as two hex digits, where the 5 low bits are the pixels from left to right. A is 0e11111f111111: 0e is 01110, the top of the letter, and 1f is 11111, its crossbar.
Scaling and layout. SCALE turns every font pixel into a 4x4 block of image pixels, and PAD adds an 8-pixel margin. Each character takes 6 font pixels horizontally (5 plus a 1-pixel gap), and each line takes 8 vertically. The image is as wide as the longest line. Increase SCALE for a larger image.
The bitmap. renderPng allocates one byte per pixel, filled with 255 (white), and sets the pixels of each glyph to 0 (black).
The PNG. A PNG file is an 8-byte signature followed by chunks. Each chunk is its length, a 4-letter type, its data, and a CRC-32 of the type and data, computed with crc32 from node:zlib. The transformation writes three chunks:
IHDR: the width, the height, a bit depth of 8, and color type 0 (grayscale).IDAT: the pixel rows compressed withdeflateSyncfromnode:zlib. Each row starts with a filter byte of0, meaning the row is stored as is.IEND: an empty chunk that ends the file.
Supported characters
The font covers:
AtoZ(text is converted to uppercase)0to9- space and
. , ! ? - : # $ ' " ( ) / + = _; @ & * % < > [ ]
Curly quotes, as typed on phones and in many editors, become straight quotes. Any other character, such as an accented letter or ~, renders as ?. To support more characters, add entries to FONT.
This image shows every punctuation character, rendered from the text Hookdeck transformations\nSky's the limit! Sky’s too\n"Quotes" (50% & more) @ 3+2=5\n<a> [b] *x* /c/ d_:

Vector output with SVG
If your destination accepts SVG, you can return an SVG document as a string instead. The text is drawn with a real font by whatever displays the image, and you don't need a pixel font.
addHandler('transform', (request) => {
const text = String(request.body.text).replace(/[<&>]/g, (c) => `&#${c.charCodeAt(0)};`);
request.headers['content-type'] = 'image/svg+xml';
request.body = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="60"><rect width="100%" height="100%" fill="white"/><text x="10" y="38" font-family="sans-serif" font-size="24">${text}</text></svg>`;
return request;
});
The replace call escapes <, & and > so the text can't break the SVG markup. Your destination receives the SVG as text, with Content-Type: image/svg+xml.
Limits and pitfalls
- Execution time. Transformations must finish within 1 second. Rendering a few lines takes a few milliseconds, but very long text increases the work.
- Memory. The bitmap uses one byte per pixel, and transformations have 128 MB of memory. At
SCALE = 4, each character is about 24 x 32 pixels, so typical messages are far below the limit. Cap the length oftextif senders can send arbitrary input. - Missing
text. The code rendersString(request.body.text), so a request withouttextrenders the wordUNDEFINED. Add a filter before the transformation to only process requests that have atextfield. - Uppercase only. The font has no lowercase letters, and unsupported characters render as
?.
For the modules and globals transformations can use, see Transformation Node.js compatibility.