Ashcroft v0.5
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 the native binaries ship in separate packages and Ashcroft can't reference them for you — add both SkiaSharp.NativeAssets.Linux.NoDependencies and HarfBuzzSharp.NativeAssets.Linux to your startup project. Windows and macOS bundle both in the box. The HarfBuzz one is the easy miss: without it Skia paints the background fine and then the first line of text throws. Full setup and the exact versions are in Running on Linux, Docker, or CI.

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

Mixing fonts in one card

Theme.FontPath sets a single face for the whole card, but a display headline over body text wants two. Theme.FontFiles registers any number of bundled files under the family name each one reports, so a per-element FontFamily resolves to the bundled file before any system lookup — deterministic on every machine, with nothing installed. Here the headline is Space Grotesk from a shipped file while the body and meta stay on the embedded Noto Sans default:

csharp
return SocialCard.Create()
    .Theme(new Theme { FontFiles = ["assets/SpaceGrotesk-Bold.ttf"] })
    .Background(Backgrounds.LinearGradient("#0f172a", "#312e81"))
    .At(Anchor.MiddleLeft, s => s
        .Gap(10)
        .Text("Two faces, one card", new TextStyle { FontFamily = "Space Grotesk", Size = 64, Weight = 700, Color = "#f8fafc" })
        .Subtitle("This line is the embedded Noto Sans — humanist, even, built for reading at small sizes.")
        .Meta("Display: Space Grotesk (bundled file) · Body: Noto Sans (embedded)", color: "#a5b4fc"));

A Space Grotesk display headline over Noto Sans body text in one card

Weight and letter-spacing

The bundled default is a variable Noto Sans, so Weight is a continuous knob from 100 to 900 — not a handful of presets. Pair it with LetterSpacing on a Text() and one typeface covers a whole specimen: a wide-tracked label, a hairline subhead, a heavy headline. Because shaping is weight-aware, measurement follows the weight too, so wrapping and centering stay honest at every step:

csharp
return SocialCard.Create()
    .Background(Backgrounds.LinearGradient("#0b1020", "#1e293b"))
    .At(Anchor.MiddleLeft, s => s
        .Gap(10)
        .Text("VARIABLE  WEIGHT", new TextStyle { Size = 24, Weight = 600, LetterSpacing = 8, Color = "#7dd3fc" })
        .Text("Light when it whispers", new TextStyle { Size = 58, Weight = 300, Color = "#e2e8f0" })
        .Text("Black when it shouts", new TextStyle { Size = 58, Weight = 900, Color = "#f8fafc" })
        .Text("one font · 100–900 · tracked to taste", new TextStyle { Size = 22, Weight = 400, LetterSpacing = 2, Color = "#94a3b8" }));

A type specimen mixing a letter-spaced label with light and black weights of one variable font

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.

Running on Linux, Docker, or CI

Ashcroft renders with SkiaSharp and shapes every string with HarfBuzz, so it needs two native libraries at runtime: libSkiaSharp and libHarfBuzzSharp. On Windows and macOS both arrive automatically with the SkiaSharp and SkiaSharp.HarfBuzz packages Ashcroft already depends on, so there's nothing to do. On Linux those base packages contain no native binaries — you add the platform-specific native asset packages yourself, and you need both. NuGet won't pull them in transitively, and Ashcroft deliberately doesn't list them as dependencies: doing so would force the Linux .so files onto every Windows and macOS consumer that never needs them.

Add both to the project that actually builds or runs your app — your web app or worker, not a class library in the middle:

xml
<ItemGroup>
  <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.148.0-rc.1.2" />
  <PackageReference Include="HarfBuzzSharp.NativeAssets.Linux" Version="14.2.0-rc.1.2" />
</ItemGroup>

A few things that trip people up:

  • Two packages, two version lines. SkiaSharp and HarfBuzzSharp version independently — Skia is on 4.148.x here while HarfBuzzSharp is on 14.2.x. Don't try to align the numbers; use the versions Ashcroft is built against (above, matching what the SkiaSharp / SkiaSharp.HarfBuzz packages resolve to).
  • The HarfBuzz native is the one people forget. With only the Skia asset present, the background and shapes draw and then the first run of text throws DllNotFoundException: libHarfBuzzSharp. The mirror symptom, DllNotFoundException: libSkiaSharp, means the Skia asset is the one missing.
  • .NoDependencies vs plain. SkiaSharp.NativeAssets.Linux.NoDependencies is the headless build and the right default for containers and CI — it needs no system fontconfig installed. Reach for plain SkiaSharp.NativeAssets.Linux only if you specifically want fontconfig-based system-font discovery; Ashcroft's embedded fonts render without it.
  • Build host vs deploy target. The references above are unconditional, so they're included no matter where you build — the right call when you build on Windows or macOS and deploy to Linux. If you only ever build on the same OS you ship to, you can guard the group with Condition="$([MSBuild]::IsOSPlatform('Linux'))" to keep the Linux .so out of other outputs.