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

  1. Your Source receives a JSON request with a text field.
  2. The transformation renders the text into a grayscale bitmap and encodes it as a PNG.
  3. It sets request.body to the PNG bytes (a Buffer) and content-type to image/png.
  4. 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:

A black-on-white pixel-font image reading HOOKDECK TRANSFORMATIONS on the first line and SKY'S THE LIMIT! on the second

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 Download 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 with deflateSync from node:zlib. Each row starts with a filter byte of 0, meaning the row is stored as is.
  • IEND: an empty chunk that ends the file.

Supported characters

The font covers:

  • A to Z (text is converted to uppercase)
  • 0 to 9
  • 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_e; ok:

A pixel-font image with four lines of uppercase text that use every supported punctuation character

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 of text if senders can send arbitrary input.
  • Missing text. The code renders String(request.body.text), so a request without text renders the word UNDEFINED. Add a filter before the transformation to only process requests that have a text field.
  • 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.