Ashcroft v0.2
GitHub

Ashcroft

Generate Open Graph images, X cards, and blog headers from C#. Set a background, describe content, pin it to an anchor, save. Typography, wrapping, and legibility are computed; nothing is positioned by pixel.

$ dotnet add package Ashcroft

.NET 10 · SkiaSharp + HarfBuzz · renders to PNG · MIT

Every code sample on this page is pulled straight from the doc site's compiled source, and every image below it was produced by running that exact sample through Ashcroft — there are no screenshots here, only live output.

Important

On Linux / Docker / CI, SkiaSharp needs its native binaries explicitly: dotnet add package SkiaSharp.NativeAssets.Linux.NoDependencies. Windows and macOS get them in the box. This is the #1 support question for every Skia-based library.

The whole pitch

Five lines to a good-looking 1200×630 PNG. A built-in gradient, a centered title, all defaults:

csharp
return SocialCard.Create()
    .Background(Backgrounds.LinearGradient("#0f172a", "#1e3a8a"))
    .At(Anchor.Center, s => s.Title("April Release Notes"));

Minimum viable card

Backgrounds and the scrim

A background can be a color, a built-in gradient, an image (cover-fit and center-cropped), or a lambda over the raw SKCanvas. When an anchored group contains text and the background is a photo or a lambda, Ashcroft draws a subtle dark scrim behind that region — fading from ~55% black at the nearest edge to transparent toward the center. This single default is most of why zero-config output looks deliberate. Watch the bottom of the card darken:

csharp
return SocialCard.Create()
    .Background("assets/hero.png")
    .At(Anchor.BottomLeft, s => s
        .Title("Legible over any photograph")
        .Subtitle("The scrim is a load-bearing default — no configuration required"));

Text over a photo with the automatic scrim

To see what that default buys you, here is the same card with and without it — opt out with .NoScrim(), or pin a specific strength with .Scrim(0.7f):

The card with the automatic scrim

default — automatic scrim

The same card rendered with NoScrim, the text fighting the photo

.NoScrim() — text fights the photo

Anchors, stacks, and elements

Content is a stack pinned to one of nine anchors; alignment is inherited from the anchor. Stacks hold role-based elements — Title, Subtitle, Meta — plus Image, Spacer, and a Row for the avatar-and-byline pattern. The roles encode a tested type scale and opacity ramp, so you describe what the text is, not what it looks like. Here's the expected common case, a blog card:

csharp
return SocialCard.Create()
    .Background("assets/hero.png")
    .At(Anchor.TopRight, s => s.Image("assets/logo.png", height: 48))
    .At(Anchor.BottomLeft, s => s
        .Title("Why Your OG Images Look Like Everyone Else's")
        .Subtitle("What HarfBuzz actually does, and why you want it")
        .Spacer(8)
        .Row(r => r
            .Image("assets/avatar.png", height: 44, shape: ImageShape.Circle)
            .Meta("Phil Scott · June 2026")));

Blog post card with logo, title, subtitle, and an avatar row

The text engine

Every string is shaped by HarfBuzz and wrapped on shaped-cluster boundaries — measurement and drawing share the same glyphs, so nothing ever clips mid-character. The payoff shows up when a title refuses to fit: a Title wraps up to three lines, then steps its size down toward a 70% floor, and only then ellipsizes. It never throws and never overflows the card:

csharp
return SocialCard.Create()
    .Background(Backgrounds.LinearGradient("#312e81", "#0b1020"))
    .At(Anchor.BottomLeft, s => s
        .Title("Everything I learned shipping a cross-platform text rendering pipeline " +
               "built on SkiaSharp, HarfBuzz, and an unreasonable number of glyph metrics, " +
               "and what I would do differently if I had to start over today")
        .Meta("ashcroft · the text engine"));

A very long title that shrank and ellipsized instead of overflowing

The same layout with a title of each length — no code changes between them:

A short title rendered at the full 64px

short — full 64px

A long title shrunk toward the 70% floor and ellipsized

long — shrunk to 70%, then ellipsized

Shaping is also per run: when the primary face can't cover a codepoint, Ashcroft finds a system face that can — emoji and CJK in one string need zero configuration:

csharp
return SocialCard.Create()
    .Background(Backgrounds.LinearGradient("#7c2d12", "#0c0a09"))
    .At(Anchor.Center, s => s
        .Title("Shipping 🚀 to 東京")
        .Subtitle("Emoji and CJK fall back per run — they just work"));

A title mixing Latin text, an emoji, and Japanese characters

Generative backgrounds

Need something we didn't anticipate? Draw it yourself — Background takes a (canvas, size) lambda over the raw Skia surface, and the role text still sits on top with its defaults intact:

csharp
return SocialCard.Create(CardSize.Square)
    .Theme(new Theme { TextColor = "#a7f3d0" })
    .Background(DrawIsoGrid)
    .At(Anchor.Center, s => s
        .Title("ashcroft v1.0", size: 88)
        .Meta("social cards for .NET"));

Generative iso-grid background with a centered title

Theming

Card-wide changes go through a Theme — font family, text color, and a scale multiplier over the whole type ramp. One-off overrides ride on optional parameters per element. Colors are hex strings everywhere, so casual users never construct an SKColor:

csharp
return SocialCard.Create()
    .Theme(new Theme { TextColor = "#a7f3d0" })
    .Background(Backgrounds.RadialGradient("#0b1020", "#020617"))
    .At(Anchor.BottomLeft, s => s
        .Title("Designed by default")
        .Subtitle("Describe what the text is, not what it looks like")
        .Meta("dotnet add package Ashcroft"));

Custom-themed card over a radial gradient

Coloring individual elements

When the theme is right but one element isn't, every role takes an optional color: and size: — overrides are opt-in, not all-or-nothing. And when the roles themselves aren't enough, Text() takes a full TextStyle (size, weight, letter-spacing, line height); its color is used exactly as given, with no role opacity ramp applied. Here the kicker is a custom Text(), the title stays on the theme default, and the subtitle and meta are tinted:

csharp
return SocialCard.Create()
    .Background(Backgrounds.RadialGradient("#1e1b4b", "#0b1020"))
    .At(Anchor.BottomLeft, s => s
        .Text("CASE STUDY", new TextStyle { Size = 22, Weight = 600, LetterSpacing = 4, Color = "#fbbf24" })
        .Title("Coloring outside the theme")
        .Subtitle("Every role takes an optional color and size", color: "#93c5fd")
        .Meta("ashcroft.dev", color: "#fbbf24"));

A card with an amber kicker, default title, and tinted subtitle and meta

Custom fonts

Two ways in. Theme.FontFamily asks for an installed font by name and falls back silently down a chain (requested → Segoe UI → Helvetica Neue → platform sans) — hook AshcroftDiagnostics.Log to hear about it. Theme.FontPath skips resolution entirely and loads a TTF/OTF you ship with your app, which is the reproducible choice for CI and containers. One file is one face — it carries every weight on the card, so pick a face that reads well everywhere it'll land:

csharp
return SocialCard.Create()
    .Theme(new Theme { FontPath = "assets/SpaceGrotesk-Bold.ttf" })
    .Background(Backgrounds.LinearGradient("#134e4a", "#0f172a"))
    .At(Anchor.Center, s => s
        .Title("Bring your own typeface")
        .Meta("Theme.FontPath · any TTF or OTF"));

A card set in Space Grotesk loaded from a bundled font file

Fine-tuning the layout

The defaults — 64px padding, 12px gap, text wrapping at the card width — are tuned for the common case, and each has an override: Padding on the card; MaxWidth, Gap, and Align on a stack; Spacer for a one-off gap between two elements. Images clip to Rounded (with a corner radius) or Circle. Here a capped text column shares the card with a rounded image on the opposite edge:

csharp
return SocialCard.Create()
    .Padding(80)
    .Background("#0b1020")
    .At(Anchor.MiddleLeft, s => s
        .MaxWidth(560)
        .Gap(20)
        .Title("Every default has an override")
        .Subtitle("Padding, MaxWidth, Gap, Align — when the defaults aren't enough"))
    .At(Anchor.MiddleRight, s => s
        .Image("assets/hero.png", width: 360, shape: ImageShape.Rounded, cornerRadius: 24));

A two-column card: a narrow text stack on the left, a rounded image on the right

Output

Rendering is deferred — nothing rasterizes until you ask for bytes:

csharp
card.Save("og.png");                       // format inferred from the extension
card.Save(stream, ImageFormat.Png);
byte[] bytes = card.ToBytes(ImageFormat.Webp, quality: 90);
using SKImage img = card.ToImage();        // escape hatch for further Skia work

.Scale(2) renders at 2× pixel density with all layout values multiplied — crisp on high-DPI surfaces. PNG is the default and the recommendation for OG images.