Image Placeholder Service

29 Jul 2020

Hello! Today I would like to share experience of creating placeholder image placeholder service.

Placeholder images can be used for website template development. In case there is no real image, but we need to use something instead of it. There is an example of such template.

It looks like that:

External services are ususally used for this, for example https://placeholder.com/ etc.

It's clear, that in most cases placeholders shouldn't be used in real projects. Then it's ok to use external service for placeholders. But it could be such case, when placeholders need to be used in production. For example, it can be website constructor. In this case external service for placeholders could be a problem.

Then I decided to implement small self-hosted web service for placeholders generation. Service inspired by https://placeholder.com/. Functional is almost completely the same. But API is different, because I decided to make it more clear.

Technologies

Service built with PHP. PHP GD library used for images generation. Initially there was idea to make it as a single php file, that can be easily added to any project. Another option was to create library that can be installed with composer.

But finally I decided to make it as a separate service, built with Symfony 5.

Functional

There are following options:

  • width / height
  • text - text that will be in the center of image
  • text_size - size of the text
  • color_text/color_bg - color of text or background

Image always created in png format for now.

Install / launch

For local install need to clone repository

git clone https://github.com/antonshell/placeholder-service.git

Install dependencies

cd placeholder-service
composer install

Start PHP local server(or configure apache/nginx).

php -S 127.0.0.1:8000 public/index.php

Implementation

Added 2 routes: index - for service health check, image - for image generation.

index:
    path: /
    controller: App\Controller\MainController::index

image:
    path: /img
    controller: App\Controller\MainController::image

Then PlaceholderGenerator service is used in MainController.

    public function image(Request $request): Response
    {
        $imageRequest = ImageRequest::create($request);
        $resolution = $this->resolutionService->createFromRequest($imageRequest);

        return $this->placeholderGenerator->generate(
            $resolution->getWidth(),
            $resolution->getHeight(),
            $imageRequest->getText(),
            $imageRequest->getTextSize(),
            $imageRequest->getColorText(),
            $imageRequest->getColorBg()
        );
    }

Image generation happens in PlaceholderGenerator. Image created by imagecreatetruecolor function, text and background colors are created with imagecolorallocate. Then image filled with imagefilledrectangle.

For text centering need to do some calculations. Text block size calculated with imagettfbbox function, Then text coordinates are calculated and text created with imagettftext function. For Symfony Response capability image should be stored in the temporary directory. Image can be deleted after Response created.

    public function generate(
        int $width,
        int $height,
        ?string $text = null,
        int $textSize = self::DEFAULT_TEXT_SIZE,
        string $colorText = self::COLOR_WHITE,
        string $colorBg = self::COLOR_GREY
    ): Response {
        if($text === null) {
            $text = sprintf('%sx%s', $width, $height);
        }

        // generate image
        $im = imagecreatetruecolor($width, $height);

        // create colors
        $colorText = $this->hex2rgb($colorText);
        $colorText = imagecolorallocate($im, $colorText->getRed(), $colorText->getGreen(), $colorText->getBlue());

        $colorBg = $this->hex2rgb($colorBg);
        $colorBg = imagecolorallocate($im, $colorBg->getRed(), $colorBg->getGreen(), $colorBg->getBlue());

        // fill image with bg color
        imagefilledrectangle($im, 0, 0, $width - 1, $height - 1, $colorBg);

        //create text
        $angle = 0;
        $font = __DIR__ . '/../../resources/fonts/ArialRegular.ttf';
        $points = imagettfbbox($textSize, $angle, $font, $text);
        $textWidth = abs($points[2]);
        $textHeight = abs($points[5]);

        $textStartX = $width / 2 - $textWidth / 2;
        $textStartY = $height / 2 + $textHeight / 2;
        imagettftext($im, $textSize, $angle, $textStartX, $textStartY, $colorText, $font, $text);

        // save image to temp folder
        $hash = md5(sprintf('%s_%s_%s_%s_%s_%s', $width, $height, $text, $textSize, $colorText, $colorBg));
        $filepath = sprintf(__DIR__ . '/../../temp/%s.png', $hash);
        imagepng($im, $filepath);
        imagedestroy($im);

        // create response, remove image
        $response = $this->createResponse($filepath);
        unlink($filepath);

        return $response;
    }

Symfony Response created with such code:

    private function createResponse(string $filepath): Response
    {
        $response = new Response();
        $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_INLINE, 'image.png');
        $response->headers->set('Content-Disposition', $disposition);
        $response->headers->set('Content-Type', 'image/png');
        $response->headers->set('Content-length', filesize($filepath));
        $response->sendHeaders();
        $response->setContent(file_get_contents($filepath));

        return $response;
    }

Colors need to be converted from HEX to RGB:

    private function hex2rgb(string $hex): ColorRgb
    {
        $hex = str_replace("#", "", $hex);

        if(strlen($hex) == 3) {
            $r = hexdec(substr($hex,0,1).substr($hex,0,1));
            $g = hexdec(substr($hex,1,1).substr($hex,1,1));
            $b = hexdec(substr($hex,2,1).substr($hex,2,1));
        } else {
            $r = hexdec(substr($hex,0,2));
            $g = hexdec(substr($hex,2,2));
            $b = hexdec(substr($hex,4,2));
        }

        return new ColorRgb($r, $g, $b);
    }

Probably, PlaceholderGenerator can be used separately from placeholder-service.

Tests

Md5 hash is used for testing correct image generation. Hash of created image compared with hash of existing file. Existing files stored in resources/test_images. Test looks like that:

    public function testImage()
    {
        $configuration = [
            [
                'url' => '/img', // 300x300
                'file' => 'img.png',
            ],
            [
                'url' => 'http://127.0.0.1:8000/img?width=500', // 500x500
                'file' => 'img_width=500.png',
            ],
            [
                'url' => 'http://127.0.0.1:8000/img?height=400', // 400x400
                'file' => 'img_height=400.png',
            ],
            [
                'url' => 'http://127.0.0.1:8000/img?width=320&height=240', // 320x240
                'file' => 'img_width=320_height=240.png',
            ],
            [
                'url' => 'http://127.0.0.1:8000/img?text=Hello', // custom text
                'file' => 'img_text=Hello.png',
            ],
            [
                'url' => 'http://127.0.0.1:8000/img?width=800&text_size=40', // text size
                'file' => 'img_width=800_text_size=40.png',
            ],
            [
                'url' => 'http://127.0.0.1:8000/img?color_text=000', // text color
                'file' => 'img_color_text=000.png',
            ],
            [
                'url' => 'http://127.0.0.1:8000/img?color_bg=000', // background color
                'file' => 'img_color_bg=000.png',
            ],
        ];

        $expectedFilesDir = __DIR__ . '/../../resources/test_images';

        $client = static::createClient();
        foreach ($configuration as $row) {
            $client->request('GET', $row['url']);
            $this->assertEquals(200, $client->getResponse()->getStatusCode());

            $expectedFilePath = sprintf('%s/%s', $expectedFilesDir, $row['file']);
            $expectedFileHash = md5(file_get_contents($expectedFilePath));

            $contentHash = md5($client->getResponse()->getContent());
            $this->assertEquals($expectedFileHash, $contentHash);
        }
    }

Placeholder service is available on github: https://github.com/antonshell/placeholder-service. There is also working demo.

That's all for today. Thank you for your attention!

Tags: PHP, Images