yilanboy/preview

Generate the preview image

Maintainers

Package info

github.com/yilanboy/preview

pkg:composer/yilanboy/preview

Statistics

Installs: 79

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 1

2.2.0 2026-06-24 06:29 UTC

README

A simple package to generate a preview image.

Installation

Install the package with composer.

composer require yilanboy/preview

Then create an image generator.

use Yilanboy\Preview\Canvas\Solid;
use Yilanboy\Preview\Canvas\Enums\Margin;
use Yilanboy\Preview\Canvas\Enums\Format;
use Yilanboy\Preview\Canvas\Enums\Size;
use Yilanboy\Preview\Generator;
use Yilanboy\Preview\Text\Enums\Font;
use Yilanboy\Preview\Text\Enums\FontSize;
use Yilanboy\Preview\Text\TextBlock;

new Generator()
    ->size(Size::OpenGraph)
    ->margin(Margin::Medium)
    ->format(Format::PNG)
    ->background(new Solid('#777bb3'))
    ->title(new TextBlock(
        text: 'Preview',
        color: 'white',
        fontSize: FontSize::Large,
        font: Font::Inter,
    ))
    ->description(new TextBlock(
        text: 'A simple PHP package to create preview image',
        color: 'white',
        fontSize: FontSize::Small,
        font: Font::Inter,
    ))
    ->output();

This code will display the following image on the web page.

preview

Examples

The most common use for this package is generating an Open Graph image — the preview card shown when a link is shared on social media. Instead of designing one by hand for every blog post, generate it from the post's own title and description. (Generator defaults to Size::OpenGraph, so the dimensions are already right.)

Laravel

Serve it on the fly. Point a route at each post and render the image straight to the response:

use App\Models\Post;
use Yilanboy\Preview\Canvas\Enums\Format;
use Yilanboy\Preview\Canvas\Gradient;
use Yilanboy\Preview\Generator;
use Yilanboy\Preview\Text\Enums\FontSize;
use Yilanboy\Preview\Text\TextBlock;

Route::get('/posts/{post}/og.png', function (Post $post) {
    $format = Format::PNG;

    $image = new Generator()
        ->format($format)
        ->background(new Gradient(from: '#1e3a8a', to: '#9333ea'))
        ->title(new TextBlock(text: $post->title, color: 'white', fontSize: FontSize::Medium))
        ->bytes();

    return response($image, 200, [
        'Content-Type' => $format->mimeType(),
    ]);
})->name('posts.og');

Then reference the route in your page's <head> so social platforms pick it up:

<meta property="og:image" content="{{ route('posts.og', $post) }}">

Or generate it once, ahead of time. If you'd rather not render on every request, build the image when a post is published and save it to disk, then serve the static file:

$image = new Generator()
    ->background(new Gradient(from: '#1e3a8a', to: '#9333ea'))
    ->title(new TextBlock(text: $post->title, color: 'white', fontSize: FontSize::Medium))
    ->bytes();

Storage::put("images/posts/{$post->id}/og.png", $image);

Canvas

Size

Pick a preset that matches where the image will be embedded. Generator defaults to Size::OpenGraph.

use Yilanboy\Preview\Canvas\Enums\Size;

$generator->size(Size::Square);
Preset Dimensions Where it's used
OpenGraph 1200 × 630 Facebook, generic Open Graph previews
Square 1080 × 1080 Instagram, LinkedIn square posts
Landscape 1920 × 1080 16:9 landscape, slide / hero images
Portrait 1080 × 1920 9:16 vertical, stories and reels
YouTube 1280 × 720 YouTube video thumbnails

If no preset fits, set the width and height yourself. Both must be at least 1, otherwise an InvalidInput exception is thrown. size() and dimensions() set the same canvas size, so the last call wins.

$generator->dimensions(width: 800, height: 418);

Margin

Text is inset from the canvas edges by a fixed pixel margin. Generator defaults to Margin::Medium (60px).

use Yilanboy\Preview\Canvas\Enums\Margin;

$generator->margin(Margin::Large);
Preset Pixels
None 0
Small 30
Medium 60
Large 90
ExtraLarge 120

Backgrounds

Generator::background() accepts anything implementing the Background interface. Three implementations ship with the package.

Solid — a flat color.

use Yilanboy\Preview\Canvas\Solid;

$generator->background(new Solid('#777bb3'));

Gradient — two colors interpolated across the canvas.

use Yilanboy\Preview\Canvas\Gradient;
use Yilanboy\Preview\Canvas\Enums\GradientDirection;

$generator->background(new Gradient(
    from: '#1e3a8a',
    to: '#9333ea',
    direction: GradientDirection::Diagonal,
));

GradientDirection cases: Vertical (default) · Horizontal · Diagonal.

Image — render a bitmap behind your text.

use Yilanboy\Preview\Canvas\Image;
use Yilanboy\Preview\Canvas\Enums\ImageFit;

$generator->background(new Image(
    path: __DIR__.'/cover.jpg',
    fit: ImageFit::Cover,
    opacity: 0.6,
    tint: '#000000',
));

ImageFit cases: Cover (default) · Contain · Stretch · Tile.

opacity is a float between 0.0 and 1.0 (default 1.0). When opacity < 1.0, the canvas is filled with tint first so the tint color shows through the partially transparent image — use it to darken or wash the background. tint defaults to #000000.

See all three modes interactively in the playground (next section).

Text

TextBlock

TextBlock is a final readonly class, so it's immutable. Every constructor argument is named and defaulted — to vary a field, construct a new instance:

$base = new TextBlock(text: 'Hello');
$red  = new TextBlock(text: 'Hello', color: 'red');
$big  = new TextBlock(text: 'Hello', fontSize: FontSize::Huge);

If no FontSize preset fits, pass a custom size in pixels instead. It must be at least 1, otherwise an InvalidInput exception is thrown.

$custom = new TextBlock(text: 'Hello', fontSize: 42);

Available customization enums live under Yilanboy\Preview\Text\Enums:

Enum Cases
Font Inter · InterMedium · Roboto · RobotoMedium · JetBrainsMono · JetBrainsMonoMedium · NotoSans · NotoSansMedium · NotoSansSC · NotoSansSCMedium · NotoSansTC · NotoSansTCMedium · NotoSansJP · NotoSansJPMedium
FontSize ExtraSmall (24) · Small (32) · Medium (50) · Large (64) · ExtraLarge (80) · Huge (100)
Alignment Left · Center · Right
LineHeight Snug (1.15) · Normal (1.3) · Relaxed (1.5) · Loose (1.75)

All 14 bundled fonts are static TrueType instances shipped under SIL OFL, with separate files per weight because GD cannot select a weight from a variable font: each family ships a Regular (400) and a Medium (500) variant (the *Medium cases). Inter is the 24pt optical cut. NotoSansTC covers Latin + Traditional Chinese, NotoSansSC covers Latin + Simplified Chinese, and NotoSansJP covers Latin + Japanese; NotoSans, Inter, Roboto, and JetBrainsMono (a monospaced family) are Latin-only.

Currently, the text supports English, Chinese (Traditional and Simplified), and Japanese.

Alignment controls how each line is positioned horizontally within the margins. TextBlock defaults to Alignment::Left.

use Yilanboy\Preview\Text\Enums\Alignment;
use Yilanboy\Preview\Text\TextBlock;

$generator->title(new TextBlock(
    text: 'Preview',
    alignment: Alignment::Center,
));

Line Height

When text wraps to multiple lines, LineHeight controls the spacing between them. The value is a unit-less multiplier of the text's font size (CSS line-height semantics). TextBlock defaults to LineHeight::Normal (1.3×).

use Yilanboy\Preview\Text\Enums\LineHeight;
use Yilanboy\Preview\Text\TextBlock;

$generator->description(new TextBlock(
    text: 'A longer description that wraps to multiple lines for demonstration purposes.',
    lineHeight: LineHeight::Loose,
));
Preset Multiplier
Snug 1.15×
Normal 1.3×
Relaxed 1.5×
Loose 1.75×

Custom Fonts

The font argument also accepts a filesystem path to your own font file, instead of a bundled Font case.

use Yilanboy\Preview\Text\TextBlock;

new TextBlock(
    text: 'Hello',
    font: __DIR__.'/fonts/MyFont.ttf',
);

Only TrueType (.ttf) files are supported. OpenType (.otf) is rejected. A path is accepted only when all of the following hold:

  • the file exists and is readable;
  • the extension is .ttf (case-insensitive);
  • the file's first 4 bytes are the TrueType sfnt header (0x00010000) — this is what rejects an .otf renamed to .ttf, whose header is OTTO.

If the path is not a valid TrueType font, the constructor throws an InvalidInput exception with the message The font path is not a valid TrueType font file. Validation runs in the constructor, so an invalid TextBlock can never exist — construction fails fast.

Security: the font path is read straight off disk and is treated as trusted input. It must come from you, the developer — never from unsanitised end-user input, which would enable arbitrary file reads and file-existence probing.

Output

There are three ways to produce the final image:

  • output() renders it, sets the HTTP Content-Type header from the format's MIME type, and writes the bytes straight to the response. This is convenient for plain PHP scripts; in frameworks like Laravel or Symfony, prefer bytes() and return a framework response instead.
  • save($path) renders it and writes the bytes to a file.
  • bytes() renders it and returns the encoded bytes as a string, which is useful for framework responses or object storage.
$generator->output();            // serve in the HTTP response
$generator->save('preview.png'); // write to a file
$image = $generator->bytes();    // return encoded image bytes

format() selects the encoding. Format has three cases — PNG (default), JPEG, and WEBP.

use Yilanboy\Preview\Canvas\Enums\Format;

$generator->format(Format::JPEG);

Note: the format set via format() always wins — save() does not look at the file extension. The configured format is the single source of truth, so format(Format::JPEG)->save('preview.png') writes JPEG bytes into a file named preview.png. Keep the extension and format in sync yourself.

Exceptions

Everything the library throws lives under Yilanboy\Preview\Exceptions. Invalid input (bad colors, font paths, dimensions, etc.) throws InvalidInput, and render-time GD failures throw RenderFailure. Both implement the PreviewException marker interface and still extend their SPL parents (InvalidArgumentException / RuntimeException), so existing catch blocks keep working.

use Yilanboy\Preview\Exceptions\PreviewException;

try {
    $generator->save($path);
} catch (PreviewException $e) {
    // anything this library threw
}

Start a Local Server to Show the Image

There is a playground.php file in examples folder. Start a local server to open an interactive form where you can edit the title, description, font, and font size, switch between Solid / Gradient / Image backgrounds, preview gradients live via CSS, and tweak image opacity and tint with sliders.

php -S localhost:8000 examples/playground.php

Then open your browser and visit localhost:8000.

Development

Run tests:

composer tests

Format code with Pint (only dirty files):

composer fmt

Run all checks (Pint + PHPStan):

composer check

Snapshot Testing

The image tests use snapshot testing: each test generates a PNG and compares it against a stored reference image in tests/Fixtures/. The comparison is tolerant rather than pixel-exact — it ignores anti-aliasing noise that differs between macOS and Linux FreeType builds, while still catching real rendering changes (different text, color, or layout).

When you make an intentional change to rendering output, regenerate the fixtures by setting the UPDATE_SNAPSHOTS environment variable (any non-empty value other than 0 works):

UPDATE_SNAPSHOTS=1 composer tests

Each snapshot test detects the variable and overwrites its fixture in tests/Fixtures/ with the freshly generated image. Always open the regenerated PNGs and confirm they look correct before committing — once a fixture is updated, it becomes the new source of truth.