Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,188 @@
|
||||
<?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)];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user