Files
timebank-cc-public/app/Services/GeneratedLogo.php
T

189 lines
6.8 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Str;
/**
* A text logo with the platform name, for platforms that have not uploaded their own email logo.
*
* Emails and PDFs need a PNG (many mail clients do not show SVG), so the name is drawn with GD in the
* theme's logo colour, or white for dark backgrounds, and cached under storage/app/public. A new name
* or colour gives a new file.
*/
class GeneratedLogo
{
/** Bump to redraw every generated logo after a change to the drawing below. */
private const VERSION = 4;
private const WIDTH = 900;
private const HEIGHT = 700;
private const DIR = 'app-images/generated';
/**
* The logo's path relative to storage/app/public, drawn on first use. Null when it cannot be drawn
* (no GD with FreeType, the font is missing, or storage is not writable).
*/
public static function path(bool $inverted = false): ?string
{
$name = trim((string) platform_name());
if ($name === '') {
return null;
}
$color = $inverted ? '#FFFFFF' : (theme_color('logo') ?: theme_color('brand') ?: '#000000');
$font = public_path('fonts/static/roboto/Roboto-Bold.ttf');
$hash = substr(md5(implode('|', [$name, $color, self::VERSION])), 0, 10);
$file = self::DIR . '/' . (Str::slug($name) ?: 'platform') . ($inverted ? '-inverted' : '') . "-{$hash}.png";
$absolute = storage_path('app/public/' . $file);
if (is_file($absolute)) {
return $file;
}
if (!function_exists('imagettfbbox') || !is_file($font)) {
return null;
}
try {
if (!is_dir(dirname($absolute)) && !mkdir(dirname($absolute), 0775, true) && !is_dir(dirname($absolute))) {
return null;
}
$png = self::draw($name, $color, $font);
// Written to a temporary name first, so a parallel request never serves a half-written file
$temporary = $absolute . '.' . Str::random(8) . '.tmp';
if ($png === null || file_put_contents($temporary, $png) === false || !rename($temporary, $absolute)) {
@unlink($temporary);
return null;
}
} catch (\Throwable $e) {
report($e);
return null;
}
return $file;
}
/** The name centred on a transparent canvas, split over the lines that allow the largest letters. */
private static function draw(string $name, string $hexColor, string $font): ?string
{
$lines = [$name];
$size = 0;
foreach (self::layouts($name) as $candidate) {
$fit = self::fittingSize($candidate, $font);
// Fewer lines win a tie (layouts() lists them first); more lines must be clearly larger
if ($fit > $size * 1.1) {
[$lines, $size] = [$candidate, $fit];
}
}
$image = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
if ($image === false) {
return null;
}
imagesavealpha($image, true);
imagealphablending($image, false);
imagefill($image, 0, 0, imagecolorallocatealpha($image, 0, 0, 0, 127));
imagealphablending($image, true);
[$r, $g, $b] = sscanf(ltrim($hexColor, '#'), '%02x%02x%02x');
$color = imagecolorallocate($image, $r ?? 0, $g ?? 0, $b ?? 0);
$lineHeight = $size * 1.18;
$top = (self::HEIGHT - $lineHeight * count($lines)) / 2;
foreach ($lines as $i => $line) {
$box = imagettfbbox($size, 0, $font, $line);
$lineWidth = $box[2] - $box[0];
$x = (int) round((self::WIDTH - $lineWidth) / 2 - $box[0]);
// Baseline: the line's box, plus the font's ascent (about 0.93 of the size for Roboto)
$y = (int) round($top + $i * $lineHeight + $size * 0.93 + ($lineHeight - $size * 1.17) / 2);
imagettftext($image, $size, 0, $x, $y, $color, $font, $line);
}
ob_start();
imagepng($image);
imagedestroy($image);
return ob_get_clean() ?: null;
}
/**
* The name on one line, then every split into two lines, then into three. Lines break at spaces
* and after hyphens (Rotterdam-|Zuid keeps its hyphen at the end of the line).
*/
private static function layouts(string $name): array
{
// Parts and the glue that goes before each part when they stay on one line
$parts = [];
$glue = [];
foreach (preg_split('/\s+/u', $name) as $w => $word) {
foreach (preg_split('/(?<=-)(?=.)/u', $word) as $p => $part) {
$glue[] = $p > 0 ? '' : ($w > 0 ? ' ' : '');
$parts[] = $part;
}
}
// A long single word without hyphens: hyphenate it into two or three equal chunks as a last resort
if (count($parts) === 1 && mb_strlen($name) > 16) {
$layouts = [[$name]];
foreach ([2, 3] as $chunks) {
$length = (int) ceil(mb_strlen($name) / $chunks);
$lines = [];
for ($c = 0; $c < $chunks; $c++) {
$lines[] = mb_substr($name, $c * $length, $length) . ($c < $chunks - 1 ? '-' : '');
}
$layouts[] = array_values(array_filter($lines, fn ($l) => $l !== '' && $l !== '-'));
}
return $layouts;
}
$count = count($parts);
$join = function (int $from, int $to) use ($parts, $glue) {
$line = '';
for ($k = $from; $k < $to; $k++) {
$line .= ($k > $from ? $glue[$k] : '') . $parts[$k];
}
return $line;
};
$layouts = [[$name]];
for ($i = 1; $i < $count; $i++) {
$layouts[] = [$join(0, $i), $join($i, $count)];
}
for ($i = 1; $i < $count; $i++) {
for ($j = $i + 1; $j < $count; $j++) {
$layouts[] = [$join(0, $i), $join($i, $j), $join($j, $count)];
}
}
return $layouts;
}
/** The largest font size at which every line fits 84% of the width and all lines 64% of the height. */
private static function fittingSize(array $lines, string $font): int
{
for ($size = 260; $size > 20; $size -= 4) {
[$width, $height] = self::measure($lines, $font, $size);
if ($width <= self::WIDTH * 0.84 && $height <= self::HEIGHT * 0.64) {
return $size;
}
}
return 20;
}
/** Width of the widest line and the total height, at a font size. */
private static function measure(array $lines, string $font, int $size): array
{
$width = 0;
foreach ($lines as $line) {
$box = imagettfbbox($size, 0, $font, $line);
$width = max($width, $box[2] - $box[0]);
}
return [$width, $size * 1.18 * count($lines)];
}
}