# Bulk Text on Image Generator (`sian.agency/bulk-text-on-image-generator`) Actor

Add headlines and captions to batches of photos — text is automatically placed inside Instagram, TikTok, YouTube Shorts and Facebook's own safe zones, clear of where each platform's UI sits.

- **URL**: https://apify.com/sian.agency/bulk-text-on-image-generator.md
- **Developed by:** [SIÁN OÜ](https://apify.com/sian.agency) (community)
- **Categories:** Marketing, Social media, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

from $8.50 / 1,000 rendered images

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Bulk Text on Image Generator — Safe-Zone Ad Creative at Scale 🖼️

[![Store SIÁN Agency](https://img.shields.io/badge/Store-SI%C3%81N%20Agency-1AE392)](https://apify.com/sian.agency?fpr=sian) [![Store Print on Demand Designs](https://img.shields.io/badge/Store-Print%20on%20Demand%20Designs-1AE392)](https://apify.com/sian.agency/t-shirt-design-and-listing-generator?fpr=sian) [![Store Instagram Account 360](https://img.shields.io/badge/Store-Instagram%20Account%20360-E4405F)](https://apify.com/sian.agency/instagram-account-360-intelligence?fpr=sian) [![Store TikTok AI Transcript Extractor](https://img.shields.io/badge/Store-TikTok%20AI%20Transcript%20Extractor-25F4EE)](https://apify.com/sian.agency/best-tiktok-ai-transcript-extractor?fpr=sian)

#### 🎉 Headlines rendered inside Instagram, TikTok, YouTube Shorts and Facebook's own safe zones — clear of where each platform's interface sits

##### Built for marketers and agencies turning one photo shoot into a week of platform-ready ad creative

***

### 🔎 What is the Bulk Text on Image Generator — and when should you use it?

The **Bulk Text on Image Generator** turns a batch of image URLs and headline text into clean, structured rows you can filter, export and feed straight into a spreadsheet, database or AI agent. No account, no portal API key, no browser automation to maintain.

**Use it when you need:** ready-to-post PNGs with the headline rendered inside each platform's safe zone, plus the exact pixel rectangle used, the font and size chosen, and a clear error code on any row that could not be rendered.

**Use something else when:** you need to generate the photo itself, not add a headline to one you already have. This Actor only adds a text layer to an existing image — it does not generate artwork, remove backgrounds or retouch the picture underneath.

### 🤖 Use with AI agents

Already connected to the [Apify MCP server](https://mcp.apify.com)? Just ask for this Actor by name: sian.agency/bulk-text-on-image-generator

**Your agent can pay for its own runs.** This Actor is eligible for [agentic payments](https://docs.apify.com/platform/actors/publishing/monetize), so an agent can discover it, run it and settle the bill over [x402](https://www.x402.org/) (USDC on Base) or [Skyfire](https://www.skyfire.xyz/) — without an Apify account or API token of its own. Billing is the same either way: per successful row, never for errors.

Otherwise copy this prompt into Claude, ChatGPT, Cursor or any MCP-enabled assistant:

```text
I want a batch of photos with an on-brand headline rendered on each one, safely inside every platform's UI-free zone using the Apify Actor `sian.agency/bulk-text-on-image-generator`.

Use it when I need: ready-to-post PNGs with the headline rendered inside each platform's safe zone, plus the exact pixel rectangle used, the font and size chosen, and a clear error code on any row that could not be rendered.

Don't use it when: This Actor only adds a text layer to an existing image — it does not generate artwork, remove backgrounds or retouch the picture underneath.

How to call it: supply one `imageUrl` + `text` pair for a single image, an array of `rows` for a handful, or a CSV for a full batch. A CSV can be uploaded from your machine or linked in `bulkImageUrls`. Pick a `preset` for the platform canvas and safe zone, then set `placement`, `textStyle` and `font` for how the headline looks.

Start with this input:
{
  "bulkImageUrls": [
    {
      "requestsFromUrl": "https://example.com/images.csv"
    }
  ],
  "preset": "instagram-story-reels",
  "placement": "bottom",
  "textStyle": "none",
  "font": "Anton"
}

Ask me which platform the images are going to, and roughly where they want the headline (top, center or bottom), then run the Actor and summarise the results as a table.
```

**Things you can ask your agent for:**

- *Take this CSV of 40 product photos and headlines and render them all as Instagram Story graphics.*
- *Turn this one photo into feed, story and TikTok versions with the same headline.*
- *Render these ad headlines with the band style so they stay legible over a busy background.*

Machine-readable API, MCP config and OpenAPI definition for this Actor are published at [apify.com/sian.agency/bulk-text-on-image-generator.md](https://apify.com/sian.agency/bulk-text-on-image-generator.md).

### 📋 Overview

**Instagram, TikTok, YouTube Shorts and Facebook all draw their own interface on top of your image** — a profile photo, a caption bar, an engagement rail — and a headline centered on the raw canvas has no idea any of that exists. This Actor renders your text inside each platform's own safe area instead, so a batch of product photos comes back ready to post rather than needing a second look at every single one.

**What you get:**

- ✅ **Platform-safe by construction**: every placement is anchored inside that platform's safe area, never the raw canvas — clear of where that platform's interface sits.
- 🎯 **Seven ready-made canvases**: Instagram Feed (4:5 and 1:1), Instagram Story/Reels, TikTok, YouTube Shorts, and Facebook Feed and Story — pick one and the crop, safe zone and text box all follow.
- 📋 **CSV in, ready-to-post PNGs out**: upload a spreadsheet of image URLs and headlines straight from your computer, or link one by URL. Any column you skip inherits your run-wide defaults.
- 🖌️ **Five text styles, 31 fonts**: plain, outline, band, scrim or drop-shadow rendering, and a font dropdown where every family has been checked to resolve, five of them bundled and instant.
- 💰 **Failures cost nothing**: a row is only billed once its PNG is generated and saved — a broken image link or an unsupported font is never charged.
- 🆓 **Try it on 5 images for free**: FREE renders at the same quality as PAID, just capped at five images per run so you can see real output first.

***

### ✨ Features

- 🧭 **Safe-zone anchoring**: `top`, `center` and `bottom` are all relative to the platform's safe area, so the guarantee holds regardless of which one you pick.
- 🎯 **Seven platform presets**: exact canvas and safe zone for Instagram Feed 4:5, Instagram Square 1:1, Instagram Story/Reels, TikTok, YouTube Shorts, Facebook Feed and Facebook Story.
- 🖼️ **Keep-original mode**: skip the crop entirely and render onto the source's own dimensions, with the safe zone scaled proportionally.
- ✂️ **Cover or contain fitting**: crop the source to fill the canvas edge-to-edge, or shrink it whole and pad the empty space with a color you choose.
- 🖌️ **Five text styles**: none (plain text), outline, band, scrim and drop-shadow, each suited to a different kind of background — see below for which to trust blind.
- 🔤 **31-font dropdown, plus anything else on Google Fonts**: pick from a checked list where five are bundled and instant, or name any other family and it resolves at render time.
- 📏 **Auto-shrink to fit**: text shrinks until it fits your safe box, and a row that still can't fit fails cleanly instead of spilling outside it.
- 🔁 **Per-row CSV overrides**: set your look once for the whole run, then override any of it — platform, placement, style, font, colors — one row at a time.
- 🧮 **Full render metadata on every row**: the exact safe-zone rectangle, font and size used, line count, and whether the source was an animated file.

***

### 🧭 How the safe zone works — and why "top" isn't the top edge

On a 9:16 canvas — Instagram Story, Instagram Reels, TikTok or Facebook Story — `placement: top` puts your headline roughly 14% of the way down the image, not flush against the top edge. That's the guarantee working correctly: "top" means the top of the *safe area*, once the platform's own interface is excluded, not the top of the raw canvas. Without knowing that, the first time you see it, it reads as a bug.

The exact offset follows the platform. Instagram and Facebook's Story/Reels canvases start their safe area about 14% down; TikTok sits at the same 14%. YouTube Shorts reserves the most room, closer to 20%, because its own published reference marks a larger clear zone up top. Formats with no interface to clear work differently: on Instagram's 1:1 Square post, the top margin drops under 10%, so `top` there sits close to the literal top edge.

The same logic holds for `bottom`, `center`, and every side of every preset: the text box this Actor draws is always fully inside the safe rectangle, which is always fully inside the canvas. That containment is the one guarantee this Actor exists to keep, on every row, every time.

### 📏 Where the safe-zone numbers actually come from

None of the four platforms publishes its safe zone as a number you can point to and cite. Meta describes it in prose and shows it as an overlay inside Ads Manager, with no pixels anywhere in the text. TikTok ships it as a downloadable template file and says outright that the zone shifts with your caption length. YouTube draws its safe area onto a reference image instead of writing it down. So every inset built into this Actor's seven presets is the **largest figure found** across that platform's own published guidance and the industry references that fill the gap it leaves. Every one of them traces back to a public source.

That's not a compromise; it's the safer default on purpose. Where two sources disagree, the larger number always wins, so getting one wrong costs you a slightly smaller text box — never a non-compliant one.

### 🖌️ Which text style to trust on a photo you haven't looked at

- **Outline** *(default)* — solid text with a contrasting stroke around every letter. Legible on any photo, because the stroke doesn't depend on what's behind it. The safest pick for a batch you're rendering sight-unseen.
- **Band** — an opaque color block sits behind the text. Just as reliable as outline, for when you want a strong graphic block instead of a stroke.
- **Scrim** — a soft gradient fade behind the text. Reads well on most photos, but the fade thins out toward its own edges, so treat it as a look rather than a legibility guarantee.
- **Shadow** — an offset drop-shadow beneath the text, with nothing solid behind either one. **This is the style that can lose contrast on a very bright or high-key photo.** Pale text and a soft shadow can both wash out against a light background, so reach for `outline` or `band` instead on any batch you haven't reviewed by eye first.

***

### 🎬 Quick Start

Give it one image and a headline, a short list of rows, or a CSV link for a full batch. Pick a platform preset and the crop, safe zone and text box are handled for you.

```bash
curl -X POST https://api.apify.com/v2/acts/sian.agency~bulk-text-on-image-generator/runs?token=YOUR_TOKEN \
-H 'Content-Type: application/json' \
-d '{"imageUrl": "https://example.com/photo.jpg", "text": "SUMMER SALE", "preset": "instagram-story-reels"}'
```

***

### 🚀 Getting Started (3 Simple Steps)

#### Step 1: Give it your images

One `imageUrl` + `text` pair for a single graphic; a list of image URLs in **Bulk Image URLs** when they all share one headline; or a CSV when each image needs its own text — uploaded from your computer, or linked in Bulk Images. Priority runs uploaded CSV → `rows` → `bulkImageUrls` → single image, and each mode is used only when the ones before it are empty.

#### Step 2: Pick a platform preset

`preset` sets the output canvas and that platform's own safe zone. Leave `outputMode` on "Keep original size" instead if you don't want a crop at all.

#### Step 3: Choose placement and style

`placement` (top/center/bottom), `textStyle` (none/outline/band/scrim/shadow) and `font` decide how the headline looks — set them once for the whole run, or override any of them per row.

**That's it. In a couple of minutes you'll have:**

- Ready-to-post PNGs, one per row, safe-zone compliant on every platform preset
- A dataset row for every input row, including any that failed and exactly why
- The precise safe-zone rectangle, font and size used for each render

***

### 📥 Input Configuration

| Field | Type | Required | Description |
|---|---|---|---|
| `imageUrl` | string | No | Single image URL — used only when the CSV fields and `rows` are all empty |
| `text` | string | No | Headline for the single-image field above; `\n` forces a line break |
| `rows` | array | No | `[{ imageUrl, text, ...overrides }]` for a handful of images |
| `bulkImageUrls` | array | No | The main bulk input — Add / Bulk edit / Text file. A linked `.txt` is a URL list (shared headline); a linked `.csv` carries its own per-row text |
| `csvFile` | file | No | Upload a CSV from a **local** file — highest priority. A CSV already at a URL goes in `bulkImageUrls` instead |
| `preset` | string | No | `instagram-feed-4x5` (default), `instagram-square-1x1`, `instagram-story-reels`, `tiktok`, `youtube-shorts`, `facebook-feed`, `facebook-story` |
| `outputMode` | string | No | `preset` — crop to the chosen canvas (default), or `keepOriginal` — keep the source's own dimensions |
| `fitMode` | string | No | `cover` — crop to fill (default), or `contain` — fit the whole image, padded |
| `padColor` | string | No | Hex with or without the `#` (`#ffffff`, `ffffff`, `#fff`) or a CSS named color (e.g. `white`) for the padding `contain` leaves (default `#ffffff`) |
| `placement` | string | No | `top`, `center` or `bottom` — anchored to the safe area, not the raw canvas (default `bottom`) |
| `textStyle` | string | No | `none` (default — plain text), `outline`, `band`, `scrim` or `shadow` |
| `font` | string | No | Pick from 31 checked families in the dropdown; the five bundled ones load instantly with no lookup — `Anton` (default), `Bebas Neue`, `Archivo Black`, `Lato`, `PT Serif` |
| `customFont` | string | No | Any Google Fonts family not in the dropdown (e.g. `Space Grotesk`) — overrides `font` when set |
| `fontFile` | file | No | Upload your own `.ttf`/`.otf` — overrides both `font` and `customFont`. No WOFF/WOFF2, no variable fonts |
| `textColor` | string | No | Hex with or without the `#`, or a CSS named color, for the text (default `#ffffff`) |
| `accentColor` | string | No | Hex with or without the `#`, or a CSS named color, for the stroke, background or shadow — meaning depends on `textStyle` (default `#111111`) |
| `textCase` | string | No | `none` — as typed (default), or `upper` — UPPERCASE |
| `maxTextHeightPct` | number | No | Largest share of the safe zone's height the text may fill before auto-shrinking stops (default `30`) |
| `marginPct` | number | No | Empty space kept clear on every side of the text box, capped at 45 (default `5`) |
| `maxOutputEdgePx` | integer | No | Longest-edge cap when `outputMode` is `keepOriginal` (default `2048`) |

**Example — single image:**

```json
{
  "imageUrl": "https://example.com/photo.jpg",
  "text": "SUMMER SALE\n50% OFF",
  "preset": "instagram-story-reels",
  "placement": "top"
}
```

**Example — bulk rows, one overriding the platform:**

```json
{
  "rows": [
    { "imageUrl": "https://example.com/photo1.jpg", "text": "SUMMER SALE" },
    { "imageUrl": "https://example.com/photo2.jpg", "text": "NEW ARRIVALS", "preset": "tiktok", "placement": "top" }
  ]
}
```

#### 📂 CSV batches — the exact template

Upload the file directly, or link it in Bulk Images. Either way the columns are the same. Only `imageUrl` and `text` are required, so a two-column CSV is valid on its own. Every other column is optional and named identically to its matching setting above. An empty cell inherits that run-wide setting, and **a value in the row always beats the run-wide default.** Unrecognized columns are ignored with a warning rather than an error, so a spreadsheet with columns of your own still works unchanged.

```csv
imageUrl,text,placement,textStyle,font,textColor,accentColor
https://cdn.example.com/1.jpg,"SUMMER SALE",top,outline,,,
https://cdn.example.com/2.jpg,"50% OFF\nTODAY ONLY",bottom,band,Bebas Neue,#ffffff,#e11d48
```

The full list of overridable columns: `preset`, `fitMode`, `padColor`, `placement`, `textStyle`, `font`, `textColor`, `accentColor`, `textCase`, `maxTextHeightPct`, `marginPct`. `\n` inside a `text` cell forces a line break, exactly like the single-image field.

***

### 📤 Output

Every input row becomes exactly one dataset row — successes and failures alike — so the row count always matches what you sent in.

| Field | Type | Description |
|---|---|---|
| `status` | string | `ok` or `failed` |
| `imageUrl` / `text` | string | Echoed from the input |
| `outputUrl` | string | Direct link to the rendered PNG — null on a failed row |
| `outputWidth` / `outputHeight` / `outputBytes` | integer | Dimensions and file size of the PNG |
| `preset` / `placement` / `textStyle` | string | The settings actually used for this row, after any CSV override |
| `fontUsed` / `fontSource` / `fontSizePx` | string / integer | The resolved font, whether it was bundled or fetched from Google Fonts, and the size it shrank to |
| `linesRendered` | integer | How many lines the text wrapped to |
| `safeRect` | object | The exact pixel rectangle of the safe zone used for this render |
| `safeRectApproximated` | boolean | `true` when `outputMode` was "Keep original size" and the safe zone was scaled proportionally rather than read from the preset directly |
| `sourceWasAnimated` | boolean | `true` when the source had multiple frames (e.g. an animated GIF) — only the first frame is rendered |
| `errorCode` / `error` | string | Machine-readable code and human-readable detail — null on a successful row |

**Example — a successful row:**

```json
{
  "imageUrl": "https://example.com/photo1.jpg",
  "text": "SUMMER SALE",
  "outputUrl": "https://api.apify.com/v2/key-value-stores/.../records/image-00001.png",
  "outputWidth": 1080,
  "outputHeight": 1920,
  "outputBytes": 1842213,
  "preset": "instagram-story-reels",
  "placement": "bottom",
  "textStyle": "outline",
  "fontUsed": "Anton",
  "fontSource": "bundled",
  "fontSizePx": 96,
  "linesRendered": 2,
  "safeRect": { "left": 65, "top": 270, "width": 950, "height": 882 },
  "safeRectApproximated": false,
  "sourceWasAnimated": false,
  "status": "ok",
  "errorCode": null,
  "error": null
}
```

**Every error code:**

| Code | Meaning |
|---|---|
| `invalid_url` | `imageUrl` isn't a valid `http(s)` link |
| `download_failed` | The image couldn't be fetched — timeout, DNS failure or a non-2xx response |
| `unsupported_format` | Something downloaded, but it isn't a readable image — most often an error page served with a 200, an expired link, or hotlink protection |
| `image_too_large` | Over 25 MB or 40 megapixels |
| `text_empty` | `text` was blank |
| `font_unavailable` | The named font doesn't exist, isn't on Google Fonts, or has no fixed bold weight |
| `glyphs_unsupported` | The font can't render one or more characters in the text |
| `text_does_not_fit` | The text still doesn't fit the safe box at the smallest readable size |
| `render_failed` | The image failed to composite |
| `store_failed` | The finished PNG couldn't be saved |
| `invalid_preset` | An unrecognized `preset` value — most often a CSV override typo |
| `invalid_option` | An unrecognized `placement`, `textStyle`, `fitMode` or `textCase` value (case-sensitive: `Bottom` is not `bottom`), or an unrecognized `textColor`, `accentColor` or `padColor` value — the message lists what's allowed |

A row is only billed once its PNG exists in storage, so every code above describes a row that cost you nothing.

***

### 💼 Use Cases & Examples

#### 1. A week of ad creative from one photo shoot

**A marketer has ten product photos and needs Feed, Story and TikTok versions of each.**

**Input:** the same ten rows, run once per `preset`
**Output:** thirty platform-ready PNGs, each with the text inside that platform's own safe zone
**Use:** post the same campaign everywhere without re-cropping anything by hand

#### 2. Batch headlines from a content-calendar spreadsheet

**An agency manages one spreadsheet of product photos and this week's headlines for five clients.**

**Input:** the export linked in Bulk Images (or uploaded), with a client-specific `font` or `accentColor` set per row
**Output:** one finished PNG per row
**Use:** turn Monday's planning meeting straight into Tuesday's ready-to-post assets

#### 3. A/B testing headlines on the same photo

**A performance marketer wants to test three headline variants on one hero image.**

**Input:** three rows sharing one `imageUrl`, each with different `text`
**Output:** three PNGs to run as separate ad variants
**Use:** find the winning headline without opening a design tool

#### 4. Seasonal sale banners across every platform at once

**A retailer needs "50% OFF" on the storefront photo before a flash sale goes live.**

**Input:** one row per `preset`, same `imageUrl` and `text`
**Output:** seven differently-cropped PNGs, each safe-zone compliant for its own platform
**Use:** ship the whole sale kit in a single run

#### 5. Product photos with unpredictable backgrounds

**A seller's catalogue photos vary wildly — some plain, some cluttered.**

**Input:** `textStyle: "band"` set once for the whole batch
**Output:** legible headlines regardless of what's behind them
**Use:** skip reviewing every source photo individually before choosing a style

#### 6. A Reel cover that respects Instagram's own UI

**A creator has a vertical photo and wants text near the top, clear of the profile row.**

**Input:** `preset: "instagram-story-reels"`, `placement: "top"`
**Output:** a PNG with the headline anchored just past Instagram's own interface clearance
**Use:** trust the placement instead of eyeballing where the profile photo ends

#### 7. Recovering from a broken image link mid-batch

**A CSV import has forty rows and two dead image links.**

**Input:** the same CSV, unedited
**Output:** thirty-eight PNGs, plus two `failed` rows carrying `download_failed` and the exact URL that didn't resolve
**Use:** fix the two broken links and re-run, instead of guessing which rows are missing

***

### 🔗 Integration Examples

#### JavaScript/Node.js

```javascript
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_TOKEN' });

const run = await client.actor('sian.agency/bulk-text-on-image-generator').call({
  bulkImageUrls: [{ requestsFromUrl: 'https://example.com/images.csv' }],
  preset: 'instagram-story-reels'
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items.filter((r) => r.status === 'ok').map((r) => r.outputUrl));
```

#### Python

```python
from apify_client import ApifyClient
client = ApifyClient('YOUR_TOKEN')

run = client.actor('sian.agency/bulk-text-on-image-generator').call(
    run_input={'imageUrl': 'https://example.com/photo.jpg', 'text': 'SUMMER SALE'}
)

for item in client.dataset(run['defaultDatasetId']).iterate_items():
    print(item['status'], item['outputUrl'])
```

#### cURL

```bash
curl -X POST 'https://api.apify.com/v2/acts/sian.agency~bulk-text-on-image-generator/runs?token=YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"bulkImageUrls": [{ "requestsFromUrl": "https://example.com/images.csv" }], "preset": "tiktok"}'
```

#### Automation Workflows (N8N / Zapier / Make)

1. **Trigger**: a new row lands in a content-calendar sheet
2. **HTTP Request**: call the Actor with that row's image URL and headline
3. **Wait**: for the run to finish
4. **Action**: pull `outputUrl` and post it, or save it to your asset library

***

### 📊 Performance & Pricing

#### FREE Tier (Try It Now)

- **5 images** per run, after exact duplicates are removed — full feature access, same render quality as PAID
- No credit card required
- Perfect for testing a preset and style before committing to a full batch

#### PAID Tier (Production Ready)

- **Unlimited** images per run
- Every preset, style and font available
- Billed only once an image is rendered and saved — a failed row is free

💰 **You only pay for images that exist.** Validation runs before any charge, and the charge itself fires only after the PNG is saved to storage — a bad URL, an unsupported font, or text that can't fit costs you nothing.

🔗 [View current pricing](https://apify.com/sian.agency/bulk-text-on-image-generator?fpr=sian)

***

### ❓ Frequently Asked Questions

**Q: How many images can I process?**
A: FREE tier: 5 per run, after removing exact duplicates. PAID tier: unlimited.

**Q: Why did `placement: top` land partway down the image instead of at the edge?**
A: `placement` is relative to the platform's safe area, not the raw canvas. On a 9:16 Story or Reel that safe area itself starts about 14% down, so `top` puts your text there — not at pixel zero. It's the guarantee working as intended: the text box stays inside the safe area, clear of where the platform's caption bar and profile ring typically sit.

**Q: Are the safe-zone numbers official platform specifications?**
A: No — no platform publishes one. They're the largest figure found across each platform's own published guidance and the industry references that fill the gap it leaves, so a disagreement between sources costs you a slightly smaller text box, never a non-compliant image.

**Q: Which text style should I use if I haven't looked at my source photos?**
A: `outline` or `band` — both guarantee contrast regardless of what's in the photo. `shadow` can lose contrast on a very bright or high-key image, because nothing solid sits behind the text.

**Q: What happens to a row that fails?**
A: It's still written to the dataset with `status: "failed"` and an `errorCode`, so the output row count always matches what you sent in — and you're never charged for it.

**Q: Can I use my own font?**
A: Yes. The Font dropdown offers 31 families that have each been checked to resolve to a real bold face, five of them bundled and instant. For anything else on Google Fonts, type the family name into Custom Font Family and it overrides the dropdown. That family is fetched at bold weight the first time a row uses it. And if the font is yours rather than Google's, upload the `.ttf`/`.otf` directly — that beats both. Web-font formats (`.woff`/`.woff2`) and variable fonts are rejected with a clear message: export a single static weight first.

**Q: What image formats are supported?**
A: JPEG, PNG, WebP, GIF, TIFF and AVIF, up to 25 MB and 40 megapixels. An animated source renders its first frame only, and the row reports that it happened.

**Q: What output formats are available?**
A: The rendered images are always PNG. The dataset itself exports as JSON, CSV or Excel.

***

### 🐛 Troubleshooting

**A row failed with `text_does_not_fit`**

- The headline is too long for the safe box at the smallest readable size. Raise `maxTextHeightPct`, lower `marginPct`, or shorten the text.

**A row failed with `font_unavailable`**

- The `font` name isn't on Google Fonts, or exists only as a variable font with no fixed bold weight. Try one of the five bundled fonts, or a different Google Fonts family.

**A row failed with `glyphs_unsupported`**

- The chosen font can't render one or more characters in your text — common with emoji or non-Latin scripts on a narrow display font. Switch to a font family built for that script.

**My CSV columns aren't being picked up**

- Column names must match exactly: `imageUrl`, `text`, and the override columns listed above. An unrecognized column is ignored with a warning, not an error — check the run log.

**A CSV row's override didn't apply**

- An empty cell inherits the run-wide setting; only a non-empty cell overrides it. Check for stray whitespace in the cell.

**Text looks washed out on one image**

- That row likely used `textStyle: "shadow"` over a bright photo. Switch it to `outline` or `band`.

***

### 🤝 Support

[![Telegram Support](https://img.shields.io/badge/Telegram-Support%20Group-0088cc?logo=telegram)](https://t.me/+vyh1sRE08sAxMGRi)

**Join our active support community**

- For issues or questions, open an issue in the actor's repository
- Check [SIÁN Agency Store](https://apify.com/sian.agency?fpr=sian) for more automation tools
- 📧 <apify@sian-agency.online>

***

**Built by [SIÁN Agency](https://www.sian-agency.online)** | **[More Tools](https://apify.com/sian.agency?fpr=sian)**

# Actor input Schema

## `imageUrl` (type: `string`):

🖼️ **SINGLE IMAGE MODE:** paste one image URL and fill in the Headline Text field below to render it — the fastest way to try the actor. Ignored whenever any bulk mode below is filled in (those take priority).

✅ **Supported:** direct `http(s)` links to JPEG, PNG, WebP, GIF, TIFF or AVIF files, up to 25 MB and 40 megapixels.

❌ **Not supported:** a platform page link (e.g. an Instagram post URL) — only a direct image file link works.

## `text` (type: `string`):

✍️ The headline or caption rendered on top of the Single Image URL above. Use `\n` for an explicit line break — otherwise the text wraps automatically to fit the safe zone.

💡 Ignored in Bulk Rows or CSV mode — each row supplies its own text there.

## `bulkImageUrls` (type: `array`):

🚀 **BULK MODE — the main way to run many images.**

- **Add** / **Bulk edit** — paste image URLs, one at a time or as a list.
- **Text file** — link a `.txt` of one URL per line, **or a `.csv`** with `imageUrl` + `text` columns. Detected automatically: a CSV gives each image its own headline, a URL list gives them all the Headline Text above.

CSV needs `imageUrl` + `text`; per-row override columns optional (see the README).

📊 FREE renders the first 5 after dedup; PAID renders every one.

## `csvFile` (type: `string`):

📎 Use this only when your CSV is a **local file** — if it already lives at a URL, link it in Bulk Images above instead.

Required columns: `imageUrl`, `text`. Any per-row override column may also be included — an empty cell inherits the global setting below, a filled cell beats it. Unrecognized columns are ignored with a warning.

📊 FREE renders the first 5 rows after dedup; PAID renders every row.

💡 **Takes priority over everything else.**

## `rows` (type: `array`):

📋 **BULK MODE:** one entry per image — each object needs `imageUrl` and `text`. Any per-row override key (see the README's CSV section for the full list) can be set here too; **a value on the row always beats the matching global setting below**, an omitted key inherits it. `outputMode`/`maxOutputEdgePx` are global-only.

📊 FREE renders the first 5 rows after dedup; PAID renders every row.

💡 Ignored when a CSV is uploaded — the CSV takes priority.

## `preset` (type: `string`):

🎯 The output canvas size and platform safe zone the text is kept inside. Only used when Output Mode is "Fit to preset canvas".

- **Instagram Feed (4:5)** — 1080×1350
- **Instagram Feed (1:1 Square)** — 1080×1080
- **Instagram Story / Reels (9:16)** — 1080×1920
- **TikTok (9:16)** — 1080×1920
- **YouTube Shorts (9:16)** — 1080×1920
- **Facebook Feed (1.91:1 Landscape)** — 1200×628
- **Facebook Story (9:16)** — 1080×1920

## `outputMode` (type: `string`):

🖼️ **Fit to preset canvas:** resizes/crops the image to the exact pixel size of the Platform Preset above. **Keep original size:** keeps the source image's own dimensions (downscaled only if its longest edge exceeds Max Output Edge below); the safe zone is then scaled proportionally to match.

## `fitMode` (type: `string`):

✂️ How the source image fills the preset canvas — only used when Output Mode is "Fit to preset canvas". **Cover:** crops the image to fill the canvas edge-to-edge. **Contain:** shrinks the image to fit inside the canvas whole, padding any empty space with the Pad Color below.

## `padColor` (type: `string`):

🎨 Hex with or without the `#` (`#ffffff` or `ffffff`) or a CSS named color (e.g. `white`), used to fill empty space when Fit Mode is "Contain". Ignored otherwise.

## `placement` (type: `string`):

📍 Where the text box sits inside the platform's safe zone.

## `textStyle` (type: `string`):

🖌️ How the text is rendered against the photo. **None:** plain text, nothing behind it — cleanest over flat or uncluttered images. **Outline:** solid text with a contrasting stroke, the safest choice over a photo you haven't looked at. **Band:** an opaque color block behind the text. **Scrim:** a soft gradient fade behind the text. **Shadow:** an offset drop-shadow behind the text.

## `font` (type: `string`):

🔤 The typeface your headline is set in. The first five (⚡) are bundled with the actor and render instantly with no lookup; the rest are fetched from Google Fonts at bold weight the first time a row uses them. Every family in this list has been checked to resolve to a real bold face.

💡 Need a family that isn't listed? Type it into **Custom Font Family** below — that field overrides this dropdown.

## `customFont` (type: `string`):

🔠 Any Google Fonts family name (e.g. `Cormorant Garamond`, `Space Grotesk`) — use this when the family you want isn't in the Font dropdown above. Leave empty to use the dropdown.

⚠️ Fetched at bold weight when the row renders. If the family doesn't exist, isn't on Google Fonts, or has no bold static weight, that row fails with a clear error rather than silently substituting a different font.

## `fontFile` (type: `string`):

📤 Upload a **`.ttf` or `.otf`** to render every headline in your own brand typeface. Overrides the Font dropdown and Custom Font Family.

❌ Not supported: `.woff`/`.woff2` (convert to TTF/OTF first), `.ttc` collections, and **variable fonts** — export the single static weight you want (e.g. Bold) and upload that.

⚖️ Use only fonts you are licensed to use. Max 8 MB.

💡 A `font` column in a CSV still wins per row.

## `textColor` (type: `string`):

🎨 Hex with or without the `#` (`#ffffff` or `ffffff`) or a CSS named color (e.g. `white`) for the rendered text.

## `accentColor` (type: `string`):

🎨 Hex with or without the `#` (`#111111` or `111111`) or a CSS named color (e.g. `steelblue`) for the stroke (Outline style), background block (Band), gradient (Scrim) or drop-shadow (Shadow) — whichever Text Style is selected.

## `textCase` (type: `string`):

🔠 **As typed:** render the text exactly as entered. **UPPERCASE:** convert the text to all caps before rendering.

## `maxTextHeightPct` (type: `number`):

📏 The largest share of the safe zone's height the text block may fill, as a percentage. Text automatically shrinks to fit within this limit; a row fails if it still doesn't fit at the smallest readable size.

## `marginPct` (type: `number`):

↔️ Empty space kept clear on every side of the text box, as a percentage of the safe zone. Values above 45 are automatically capped at 45, so the text box never disappears entirely.

## `maxOutputEdgePx` (type: `integer`):

📐 When Output Mode is "Keep original size", the source image is downscaled so its longest edge never exceeds this many pixels. Ignored when Output Mode is "Fit to preset canvas".

💡 Capped at 2560 px. Every platform preset tops out at 1920 px on the long edge, so the default of 2048 already exceeds what any social canvas needs — the headroom above it is for keeping a product photo close to its original resolution.

## Actor input object example

```json
{
  "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg",
  "text": "SUMMER SALE\n50% OFF",
  "rows": [
    {
      "imageUrl": "https://example.com/photo1.jpg",
      "text": "SUMMER SALE"
    },
    {
      "imageUrl": "https://example.com/photo2.jpg",
      "text": "NEW ARRIVALS",
      "preset": "tiktok",
      "placement": "top"
    }
  ],
  "preset": "instagram-feed-4x5",
  "outputMode": "preset",
  "fitMode": "cover",
  "padColor": "#ffffff",
  "placement": "bottom",
  "textStyle": "none",
  "font": "Anton",
  "customFont": "Space Grotesk",
  "textColor": "#ffffff",
  "accentColor": "#111111",
  "textCase": "none",
  "maxTextHeightPct": 30,
  "marginPct": 5,
  "maxOutputEdgePx": 2048
}
```

# Actor output Schema

## `results` (type: `string`):

Every image row the run processed: successes with their rendered PNG and render details, failures with the reason.

## `scrapingSummary` (type: `string`):

HTML summary of the run — how many images rendered, failed or were skipped as duplicates, and how many were charged.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg",
    "text": `SUMMER SALE
50% OFF`
};

// Run the Actor and wait for it to finish
const run = await client.actor("sian.agency/bulk-text-on-image-generator").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg",
    "text": """SUMMER SALE
50% OFF""",
}

# Run the Actor and wait for it to finish
run = client.actor("sian.agency/bulk-text-on-image-generator").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/3/3a/Cat03.jpg",
  "text": "SUMMER SALE\\n50% OFF"
}' |
apify call sian.agency/bulk-text-on-image-generator --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,sian.agency/bulk-text-on-image-generator"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/XcTfhwegcTXCewEB7/builds/VJ4KxoYRSapberR9D/openapi.json
