# Generate an Image from Text with a Transformation

A [Transformation](/docs/transformations) can return bytes instead of JSON or text. When it does, Hookdeck delivers those bytes to your [Destination](/docs/destinations) 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](/docs/sources) 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](/docs/transformations#return-value) for the full rules.

## Add the transformation

Open the [Connection](/docs/connections) that should produce images, add a transformation rule, and paste this code. See [create a transformation](/docs/transformations#create-a-transformation) for the step-by-step.

```js
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.

```bash
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](./images/text-to-image-showcase.png)

## 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](/docs/events#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](./images/text-to-image-characters.png)

## 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.

```js
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](/docs/filters) 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](/docs/transformations#nodejs-compatibility).