We run Wadifa Info, a Moroccan site that lists public-sector recruitment exams and job offers in Arabic and French. Every job page needs a preview image: for link previews on WhatsApp and Facebook, for square social posts, and for portrait pins. We used to have a white rectangle with a logo. Now one renderer draws all of them on the server, from the job's own data, with no design tool and no stock photos.
This post covers the parts that were harder than expected: fitting text of unknown length, Arabic right-to-left, picking a layout without randomness, and not letting the endpoint become an abuse vector. The stack is plain ASP.NET MVC and System.Drawing (GDI+).
One card model, three formats
The renderer takes a small object (institution, job title, specialty, number of posts, deadline, language) and a canvas size:
-
og1200×630 for link previews -
sq1080×1080 for social posts -
pt1080×1350 for portrait feeds
Nothing in the layout is hard-coded to a size. Everything is expressed in a unit U = min(width, height) / 100, and the layout is decided from the shape: a wide canvas gets a text column with an art panel beside it, a tall canvas gets an art band on top with the text stacked below.
bool wide = W >= H * 1.5f;
var c = new WfCtx(g) { U = Math.Min(W, H) / 100f, Rtl = d.Rtl };
Adding the portrait format later was one line in the size switch. The layout code did not change.
Text that always fits
A job title can be "Technicien" or a 120-character ministry name. The rule we settled on is: shrink, then wrap, then drop optional rows, then ellipsis.
WFit tries font sizes from the maximum down to the minimum, wraps at each size, and returns the first size where the text fits the allowed number of lines:
static Font WFit(WfCtx c, string text, FontFamily fam, float maxPx, float minPx,
float maxW, int maxLines, out List<string> lines)
{
float step = Math.Max(2, (maxPx - minPx) / 10);
for (float px = maxPx; px >= minPx; px -= step)
{
var f = c.F(fam, px); var ls = WWrap(c, text, f, maxW);
if (ls.Count <= maxLines && ls.All(l => WTW(c, l, f) <= maxW * 1.001f))
{ lines = ls; return f; }
}
// still too long at the minimum size: keep maxLines and end with an ellipsis
...
}
One case survives all of that: a single word wider than the column at the minimum size. For that, the draw call condenses the line horizontally instead of overflowing:
float w = WTW(c, s, f), sx = (maxW > 0 && w > maxW) ? maxW / w : 1f;
c.G.TranslateTransform(left, y);
if (sx < 1) c.G.ScaleTransform(sx, 1);
A slightly condensed word looks far better than a clipped one.
Arabic and French in the same card
Two things made bilingual rendering work:
-
Direction per string, not per card. A card in Arabic can contain a Latin acronym, and a French card can contain an Arabic institution name. Each string is measured and drawn with a right-to-left
StringFormatif it contains Arabic characters, and left-to-right otherwise. GDI+ then does the shaping and joins the letters correctly. -
One font family for both scripts. We load Tajawal (an open-licence family with Arabic and Latin glyphs) from the app's own folder with
PrivateFontCollection, so the server does not need any font installed. Arabic lines also get more line height (1.30 against 1.14), because the letterforms need it.
The whole layout mirrors for Arabic: the art panel, the alignment anchor, the arrow in the call-to-action button.
One GDI+ detail to know: Font and StringFormat objects are not thread-safe, so they live in a per-request context object, never in static fields.
Variety without randomness
A feed where every card looks identical is dull, but a card that changes every time it is fetched breaks caching and confuses people who share it. So the template is chosen from a stable hash of the job:
static uint WfHash(string s)
{ uint h = 2166136261; foreach (char ch in s ?? "") { h ^= ch; h *= 16777619; } return h; }
t = cands[(int)(seed % (uint)cands.Count)];
The candidate list depends on the data. A card only gets the "key figure" template if there is a real figure to show (number of posts, days left when the deadline is within five days). There is no invented urgency: the status badge is computed from the real deadline.
The background art is vector, drawn in code: a network for IT jobs, arches for teaching, a pulse line for health, an eight-point star lattice as the default. Recently we added a "document" drawing (a white sheet with a CV-like skeleton) for pages that are about a document, such as CV and cover-letter templates.
Do not render arbitrary text on your domain
Generic pages (guides, salary tables) also get a card, built from the page title. The title travels in the image URL, which means anyone could request an image with any text on it, served from our domain. So the title is signed with an HMAC, and the endpoint refuses anything whose signature does not match:
if (t.Length == 0 || t.Length > 120 || !string.Equals(s, WfSig(t), StringComparison.Ordinal))
return Redirect("/img/og-default");
If you build an image endpoint that takes text from the query string, do this from day one.
Caching
Rendering takes real CPU, so each image is written to disk under a key made of every input that affects the pixels, and served as a static file afterwards. Cards near a deadline include the date in the key, because their "N days left" badge changes daily. Everything else is sent with a one-week cache header.
What we would do differently
-
System.Drawingis Windows-only in modern .NET. It suits our hosting, but on Linux we would start with SkiaSharp. - We would design the portrait format first. Link previews are forgiving; tall formats expose every spacing mistake.
- An earlier, unrelated endpoint on the site accepted unsigned tokens and had to be fixed after an audit. That is why this one was signed from its first version.
You can see the result on any page of the site, for example the civil-service salary grid: its link preview is one of these cards.
Have you built server-side image generation with right-to-left text? We would like to hear how you handled mixed-direction lines.
Top comments (0)