// Couleurs logo : rouge, bleu fonce, orange
$accents = [
imagecolorallocate($img, 230, 51, 41),
imagecolorallocate($img, 22, 58, 130),
imagecolorallocate($img, 245, 148, 29),
];
private int $logo_ai_id;
private int $logo_banner_id;
private string $upload_dir;
private string $api_key;
private string $provider;
public function __construct() {
$this->logo_ai_id = (int)get_option('aiag_logo_ai_id', 0);
$this->logo_banner_id = (int)get_option('aiag_logo_banner_id', 0);
$uploads = wp_upload_dir();
$this->upload_dir = $uploads['basedir'] . '/armenie-info-visuals/';
$this->api_key = (string)get_option('aiag_api_key', '');
$this->provider = (string)get_option('aiag_api_provider', 'claude');
if ( ! file_exists($this->upload_dir) ) wp_mkdir_p($this->upload_dir);
}
public function generate( array $article, string $photo_url = '' ): string {
if ( ! extension_loaded('gd') ) return '';
$mem = (int)ini_get('memory_limit');
if ( $mem > 0 && $mem < 128 ) ini_set('memory_limit','128M');
$W = self::W;
$H = self::H;
$photoH = (int)($H * 0.62);
$bandH = $H - $photoH;
$cat = $article['category'] ?? 'Societe';
// Normaliser les clés avec accents
$cat_map = [
'Politique'=>'Politique','Economie'=>'Economie','Societe'=>'Societe',
'Diaspora'=>'Diaspora','Region'=>'Region','Culture'=>'Culture','Sport'=>'Sport',
'Économie'=>'Economie','Société'=>'Societe','Région'=>'Region',
];
$cat_key = isset($cat_map[$cat]) ? $cat_map[$cat] : 'Societe';
$rgb = self::CAT_COLORS[$cat_key] ?? self::CAT_COLORS['Societe'];
// 1. Photo (source -> page OG/image -> génération IA OpenAI en fallback)
$visual_source = $this->resolve_photo_source($photo_url, $article);
$band_rgb = $this->resolve_banner_rgb($visual_source, $rgb);
$img = imagecreatetruecolor($W, $H);
$band = imagecolorallocate($img, $band_rgb[0], $band_rgb[1], $band_rgb[2]);
$wht = imagecolorallocate($img, 255, 255, 255);
$blk = imagecolorallocate($img, 17, 17, 17);
imagefilledrectangle($img, 0, 0, $W, $H, $band);
$this->draw_photo($img, $visual_source, $W, $photoH);
imagefilledrectangle($img, 0, $photoH, $W, $H, $band);
// 2. Logo AI cercle parfait
$this->draw_logo_ai($img, $blk, $W);
// 3. Calculer position logo bannière AVANT le titre
// Réserver plus d'espace vertical pour éviter toute superposition
// avec les titres longs ou les logos de bannière plus hauts que prévu.
$logo_w = (int)($W * 0.64);
$logo_h = max(72, (int)($bandH * 0.20));
$logo_x = (int)(($W - $logo_w) / 2);
$logo_gap = max(18, (int)($bandH * 0.06));
$logo_y = $H - $logo_h - (int)($bandH * 0.07);
// 4. Titre avec taille automatique, en gardant une vraie zone tampon
// au-dessus du logo du bas.
$this->draw_title($img, $article['title_fr'] ?? '', $wht, $photoH, $bandH, $W, $logo_y, $logo_gap, $logo_h);
// 5. Logo bannière ancré en bas
$this->draw_logo_banner($img, $W, $logo_w, $logo_h, $logo_x, $logo_y);
$file = 'visual-' . time() . '-' . wp_rand(1000,9999) . '.jpg';
$path = $this->upload_dir . $file;
imagejpeg($img, $path, 92);
imagedestroy($img);
return $path;
}
private function get_logo_url( int $id ): string {
if ( ! $id ) return '';
return wp_get_attachment_url($id) ?: '';
}
private function resolve_photo_source( string $photo_url, array $article ): string {
if ( ! empty($photo_url) && $this->looks_like_remote_image($photo_url) ) return $photo_url;
$source_url = (string)($article['source_url'] ?? '');
if ( $source_url !== '' ) {
$found = $this->extract_image_from_article_url($source_url);
if ( ! empty($found) ) return $found;
}
if ( $this->provider === 'openai' && ! empty($this->api_key) ) {
$generated = $this->generate_ai_photo((string)($article['title_fr'] ?? ''), (string)($article['category'] ?? ''));
if ( ! empty($generated) ) return $generated;
}
return '';
}
private function extract_image_from_article_url( string $url ): string {
$response = wp_remote_get($url, [
'timeout' => 8,
'sslverify' => false,
'user-agent' => 'ArmenieInfo/4.0',
]);
if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200 ) return '';
$html = wp_remote_retrieve_body($response);
if ( empty($html) ) return '';
if ( strlen($html) > 250000 ) $html = substr($html, 0, 250000);
$base = $this->base_from_url($url);
$candidates = [];
$patterns = [
'/]+property=["\']og:image(?::url)?["\'][^>]+content=["\']([^"\']+)["\']/i',
'/]+name=["\']twitter:image(?::src)?["\'][^>]+content=["\']([^"\']+)["\']/i',
'/]+property=["\']og:image:secure_url["\'][^>]+content=["\']([^"\']+)["\']/i',
'/]+rel=["\']image_src["\'][^>]+href=["\']([^"\']+)["\']/i',
'/
]+(?:data-lazy-src|data-original|data-src|data-image|src)=["\']([^"\']+)["\'][^>]*>/i',
];
foreach ( $patterns as $pattern ) {
if ( preg_match_all($pattern, $html, $matches) ) {
foreach ( $matches[1] as $raw ) {
$candidate = $this->abs_url(trim(html_entity_decode($raw, ENT_QUOTES, 'UTF-8')), $base);
if ( $this->looks_like_remote_image($candidate) ) $candidates[] = $candidate;
}
}
}
if ( preg_match_all('/(?:\"image\"|\"thumbnailUrl\"|\"contentUrl\")\s*:\s*\"(https?:\\/\\/[^"\\]+)\"/i', $html, $matches) ) {
foreach ( $matches[1] as $raw ) {
$candidate = stripslashes($raw);
if ( $this->looks_like_remote_image($candidate) ) $candidates[] = $candidate;
}
}
if ( preg_match_all('/srcset=["\']([^"\']+)["\']/i', $html, $srcsets) ) {
foreach ( $srcsets[1] as $srcset ) {
$parts = preg_split('/\s*,\s*/', $srcset);
foreach ( $parts as $part ) {
$candidate = trim((string)preg_replace('/\s+\d+[wx]$/i', '', $part));
$candidate = $this->abs_url($candidate, $base);
if ( $this->looks_like_remote_image($candidate) ) $candidates[] = $candidate;
}
}
}
$civic_match = $this->extract_civic_image_for_article($html, $url, $base);
if ( ! empty($civic_match) ) array_unshift($candidates, $civic_match);
return $this->select_best_image_candidate($candidates, $base);
}
private function extract_civic_image_for_article( string $html, string $article_url, string $base = '' ): string {
$host = strtolower((string)parse_url($base ?: $article_url, PHP_URL_HOST));
if ( ! str_contains($host, 'civic.am') ) return '';
if ( ! preg_match('#/(?:news|politics|society|economy|culture|international|article)/(\d+)#i', $article_url, $id_match) ) return '';
$article_id = $id_match[1];
$quoted_id = preg_quote($article_id, '#');
$candidates = [];
$patterns = [
'#]+href=["\"][^"\"]*/' . $quoted_id . '(?:/[^"\"]*)?["\"][^>]*>.*?
]+(?:data-lazy-src|data-original|data-src|data-image|src)=["\"]([^"\"]+)["\"][^>]*>#is',
'#
]+(?:data-lazy-src|data-original|data-src|data-image|src)=["\"]([^"\"]+)["\"][^>]*>.*?]+href=["\"][^"\"]*/' . $quoted_id . '(?:/[^"\"]*)?["\"][^>]*>#is',
'#https?://(?:www\.)?civic\.am/thumbs/108x108/[^"\'\s>]+#i',
'#https?://(?:www\.)?civic\.am/thumbs/696x444/[^"\'\s>]+#i',
];
foreach ( $patterns as $idx => $pattern ) {
if ( ! preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) ) continue;
foreach ( $matches as $match ) {
$raw = $idx < 2 ? ($match[1] ?? '') : ($match[0] ?? '');
$candidate = $this->abs_url(trim(html_entity_decode((string)$raw, ENT_QUOTES, 'UTF-8')), $base ?: $article_url);
if ( ! $this->is_usable_photo_url($candidate) && ! $this->looks_like_remote_image($candidate) ) continue;
$candidates[] = $candidate;
}
}
if ( preg_match_all('#]+href=["\"][^"\"]*/' . $quoted_id . '(?:/[^"\"]*)?["\"][^>]*>(.*?)#is', $html, $blocks) ) {
foreach ( $blocks[1] as $block ) {
if ( preg_match_all('#(?:data-lazy-src|data-original|data-src|data-image|src)=["\"]([^"\"]+)["\"]#i', $block, $img_matches) ) {
foreach ( $img_matches[1] as $raw ) {
$candidate = $this->abs_url(trim(html_entity_decode((string)$raw, ENT_QUOTES, 'UTF-8')), $base ?: $article_url);
if ( ! $this->is_usable_photo_url($candidate) && ! $this->looks_like_remote_image($candidate) ) continue;
$candidates[] = $candidate;
}
}
}
}
$candidates = array_values(array_unique(array_filter($candidates)));
if ( empty($candidates) ) return '';
$best = '';
$best_score = -999999;
foreach ( $candidates as $candidate ) {
$score = $this->score_image_candidate($candidate, $base ?: $article_url);
if ( str_contains(strtolower($candidate), '/thumbs/108x108/') ) $score += 15000;
if ( preg_match('#/' . $quoted_id . '(?:[^0-9]|$)#', $html) ) $score += 1000;
if ( $score > $best_score ) {
$best_score = $score;
$best = $candidate;
}
}
if ( $best === '' ) return '';
if ( preg_match('#/thumbs/108x108/#i', $best) ) {
return preg_replace('#/thumbs/108x108/#i', '/thumbs/696x444/', $best, 1) ?: $best;
}
return $best;
}
private function select_best_image_candidate( array $candidates, string $base = '' ): string {
$candidates = array_values(array_unique(array_filter($candidates)));
if ( empty($candidates) ) return '';
$best = '';
$best_score = -999999;
foreach ( $candidates as $candidate ) {
$score = $this->score_image_candidate($candidate, $base);
if ( $score > $best_score ) {
$best_score = $score;
$best = $candidate;
}
}
return $best;
}
private function score_image_candidate( string $url, string $base = '' ): int {
$score = 0;
$u = strtolower($url);
$host = strtolower((string)parse_url($base, PHP_URL_HOST));
if ( preg_match('#/(\d{2,4})x(\d{2,4})/#i', $u, $m) ) {
$w = (int)$m[1];
$h = (int)$m[2];
$area = $w * $h;
$score += (int)min(120000, $area / 20);
if ( $w >= 600 || $h >= 400 ) $score += 5000;
if ( $w <= 180 && $h <= 180 ) $score -= 25000;
}
if ( preg_match('#[?&](?:w|width)=(\d{2,4})#i', $u, $mw) ) $score += min(12000, (int)$mw[1] * 8);
if ( preg_match('#[?&](?:h|height)=(\d{2,4})#i', $u, $mh) ) $score += min(8000, (int)$mh[1] * 5);
foreach ( ['og-image', 'opengraph', 'featured', 'feature', 'hero', 'cover', 'full', 'large', 'original', 'main', 'content', 'post', 'article'] as $good ) {
if ( str_contains($u, $good) ) $score += 2500;
}
foreach ( ['thumb', 'thumbnail', 'small', 'tiny', 'avatar', 'profile', 'icon', 'logo', 'placeholder', 'lazy', 'default'] as $bad ) {
if ( str_contains($u, $bad) ) $score -= 3500;
}
if ( str_contains($u, '.webp') ) $score += 300;
if ( str_contains($u, '.jpg') || str_contains($u, '.jpeg') ) $score += 200;
$score += min(2000, strlen($u));
if ( $host !== '' && str_contains($u, $host) ) $score += 800;
if ( str_contains($host, 'civic.am') ) {
if ( preg_match('#/thumbs/696x444/#i', $u) ) $score += 50000;
if ( preg_match('#/thumbs/108x108/#i', $u) ) $score -= 50000;
}
return $score;
}
private function resolve_banner_rgb( string $visual_source, array $fallback_rgb ): array {
$rgb = $this->extract_banner_rgb_from_source($visual_source);
if ( empty($rgb) ) return $fallback_rgb;
return $rgb;
}
private function extract_banner_rgb_from_source( string $visual_source ): array {
if ( empty($visual_source) ) return [];
if ( file_exists($visual_source) ) {
$data = @file_get_contents($visual_source);
} else {
$ctx = stream_context_create([
'http' => ['timeout' => 8, 'user_agent' => 'ArmenieInfo/4.0'],
'ssl' => ['verify_peer' => false],
]);
$data = @file_get_contents($visual_source, false, $ctx);
}
if ( ! $data ) return [];
$src = @imagecreatefromstring($data);
unset($data);
if ( ! $src ) return [];
$src_w = imagesx($src);
$src_h = imagesy($src);
if ( $src_w <= 0 || $src_h <= 0 ) {
imagedestroy($src);
return [];
}
$sample = 32;
$thumb = imagecreatetruecolor($sample, $sample);
imagecopyresampled($thumb, $src, 0, 0, 0, 0, $sample, $sample, $src_w, $src_h);
imagedestroy($src);
$sum_r = 0;
$sum_g = 0;
$sum_b = 0;
$count = 0;
for ( $x = 0; $x < $sample; $x++ ) {
for ( $y = (int)($sample * 0.55); $y < $sample; $y++ ) {
$px = imagecolorat($thumb, $x, $y);
$r = ($px >> 16) & 0xFF;
$g = ($px >> 8) & 0xFF;
$b = $px & 0xFF;
$sum_r += $r;
$sum_g += $g;
$sum_b += $b;
$count++;
}
}
imagedestroy($thumb);
if ( $count === 0 ) return [];
$r = (int)round($sum_r / $count);
$g = (int)round($sum_g / $count);
$b = (int)round($sum_b / $count);
$r = max(12, (int)round($r * 0.52));
$g = max(12, (int)round($g * 0.52));
$b = max(12, (int)round($b * 0.52));
return [ $r, $g, $b ];
}
private function generate_ai_photo( string $title, string $category = '' ): string {
$title = trim($title);
if ( $title === '' ) return '';
$prompt = 'Illustration photojournalistique réaliste, sans texte, sans logo, sans watermark. Sujet: ' . $title . '.';
if ( $category !== '' ) {
$prompt .= ' Contexte éditorial: ' . $category . '.';
}
$prompt .= ' Format vertical 4:5, composition propre, style photo de presse moderne.';
$response = wp_remote_post('https://api.openai.com/v1/images/generations', [
'timeout' => 120,
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $this->api_key,
],
'body' => wp_json_encode([
'model' => 'gpt-image-1',
'prompt' => $prompt,
'size' => '1024x1536',
'response_format' => 'b64_json',
]),
]);
if ( is_wp_error($response) || wp_remote_retrieve_response_code($response) >= 300 ) return '';
$data = json_decode(wp_remote_retrieve_body($response), true);
$b64 = $data['data'][0]['b64_json'] ?? '';
if ( empty($b64) ) return '';
$bin = base64_decode($b64);
if ( ! $bin ) return '';
$file = $this->upload_dir . 'generated-' . time() . '-' . wp_rand(1000, 9999) . '.png';
if ( file_put_contents($file, $bin) === false ) return '';
return $file;
}
private function base_from_url( string $url ): string {
$scheme = parse_url($url, PHP_URL_SCHEME) ?: 'https';
$host = parse_url($url, PHP_URL_HOST) ?: '';
return $host ? $scheme . '://' . $host : $url;
}
private function abs_url( string $url, string $base ): string {
if ( $url === '' ) return '';
if ( strpos($url, 'http') === 0 ) return $url;
if ( strpos($url, '//') === 0 ) return 'https:' . $url;
if ( strpos($url, '/') === 0 ) return rtrim($base, '/') . $url;
return rtrim($base, '/') . '/' . ltrim($url, '/');
}
private function looks_like_remote_image( string $url ): bool {
if ( $url === '' || str_contains($url, 'data:image') ) return false;
if ( ! preg_match('#^https?://#i', $url) ) return false;
$bad = ['/logo.', '/logo_', '/icon.', '/icon_', 'favicon', 'sprite', 'placeholder', 'default-image', '/blank.'];
$u = strtolower($url);
foreach ( $bad as $needle ) {
if ( str_contains($u, $needle) ) return false;
}
return true;
}
// ── Photo ──────────────────────────────────────────────────────────────
private function draw_photo( $img, string $url, int $W, int $pH ): void {
if ( empty($url) ) return;
if ( file_exists($url) ) {
$data = @file_get_contents($url);
} else {
$ctx = stream_context_create([
'http' => ['timeout' => 8, 'user_agent' => 'ArmenieInfo/4.0'],
'ssl' => ['verify_peer' => false],
]);
$data = @file_get_contents($url, false, $ctx);
}
if ( ! $data ) return;
$src = @imagecreatefromstring($data);
unset($data);
if ( ! $src ) return;
$src_w = imagesx($src);
$src_h = imagesy($src);
if ( $src_w <= 0 || $src_h <= 0 ) {
imagedestroy($src);
return;
}
$dst_ratio = $W / $pH;
$src_ratio = $src_w / $src_h;
if ( $src_ratio > $dst_ratio ) {
// Image plus large que la zone : on coupe à gauche/droite, centré.
$crop_h = $src_h;
$crop_w = (int) round($src_h * $dst_ratio);
$src_x = (int) max(0, round(($src_w - $crop_w) / 2));
$src_y = 0;
} else {
// Image plus haute que la zone : on coupe en haut/bas, centré.
$crop_w = $src_w;
$crop_h = (int) round($src_w / $dst_ratio);
$src_x = 0;
$src_y = (int) max(0, round(($src_h - $crop_h) / 2));
}
imagecopyresampled(
$img,
$src,
0,
0,
$src_x,
$src_y,
$W,
$pH,
max(1, $crop_w),
max(1, $crop_h)
);
imagedestroy($src);
}
// ── Logo AI : VRAI cercle parfait antialiasing 4x ──────────────────────
private function draw_logo_ai( $img, $blk, int $W ): void {
$sz = (int)($W * 0.155);
$mg = (int)($W * 0.037);
// Disque blanc haute résolution 4x → antialiasing parfait
$hr = $sz * 4;
$disc_hr = imagecreatetruecolor($hr, $hr);
imagealphablending($disc_hr, false);
imagesavealpha($disc_hr, true);
$transp = imagecolorallocatealpha($disc_hr, 0, 0, 0, 127);
imagefill($disc_hr, 0, 0, $transp);
$white_hr = imagecolorallocate($disc_hr, 255, 255, 255);
imagefilledellipse($disc_hr, $hr/2, $hr/2, $hr, $hr, $white_hr);
// Redimensionner à taille finale (antialiasing)
$disc = imagecreatetruecolor($sz, $sz);
imagealphablending($disc, false);
imagesavealpha($disc, true);
imagecopyresampled($disc, $disc_hr, 0, 0, 0, 0, $sz, $sz, $hr, $hr);
imagedestroy($disc_hr);
imagecopy($img, $disc, $mg, $mg, 0, 0, $sz, $sz);
imagedestroy($disc);
// Logo rogné en cercle
$url = $this->get_logo_url($this->logo_ai_id);
if ( $url ) {
$ctx = stream_context_create(['http'=>['timeout'=>5],'ssl'=>['verify_peer'=>false]]);
$data = @file_get_contents($url, false, $ctx);
if ( $data ) {
$logo = @imagecreatefromstring($data);
if ( $logo ) {
$lw = imagesx($logo);
$lh = imagesy($logo);
$inner = (int)($sz * 0.82);
$offset = (int)(($sz - $inner) / 2);
// Redimensionner le logo
$logo_r = imagecreatetruecolor($inner, $inner);
imagealphablending($logo_r, true);
$white = imagecolorallocate($logo_r, 255, 255, 255);
imagefill($logo_r, 0, 0, $white);
imagecopyresampled($logo_r, $logo, 0, 0, 0, 0, $inner, $inner, $lw, $lh);
imagedestroy($logo);
// Masque circulaire HR
$mhr = $inner * 4;
$mask_hr = imagecreatetruecolor($mhr, $mhr);
imagealphablending($mask_hr, false);
imagesavealpha($mask_hr, true);
$t = imagecolorallocatealpha($mask_hr, 0, 0, 0, 127);
imagefill($mask_hr, 0, 0, $t);
imagefilledellipse($mask_hr, $mhr/2, $mhr/2, $mhr, $mhr,
imagecolorallocate($mask_hr, 255, 255, 255));
// Appliquer le masque pixel par pixel
for ( $x = 0; $x < $inner; $x++ ) {
for ( $y = 0; $y < $inner; $y++ ) {
$mp = imagecolorat($mask_hr, $x*4, $y*4);
$a = ($mp >> 24) & 0x7F;
if ( $a < 80 ) {
$sp = imagecolorat($logo_r, $x, $y);
$r = ($sp >> 16) & 0xFF;
$g = ($sp >> 8) & 0xFF;
$b = $sp & 0xFF;
imagesetpixel($img, $mg + $offset + $x, $mg + $offset + $y,
imagecolorallocate($img, $r, $g, $b));
}
}
}
imagedestroy($logo_r);
imagedestroy($mask_hr);
unset($data);
return;
}
}
}
// Fallback texte "AI"
$font = $this->find_font();
$fs = (int)($sz * 0.38);
$cx = $mg + (int)($sz / 2);
$cy = $mg + (int)($sz / 2);
if ( $font && function_exists('imagettftext') ) {
$bb = imagettfbbox($fs, 0, $font, 'AI');
imagettftext($img, $fs, 0,
$cx - (int)(abs($bb[2]-$bb[0])/2),
$cy + (int)(abs($bb[7]-$bb[1])/2),
$blk, $font, 'AI');
} else {
imagestring($img, 5, $cx-20, $cy-12, 'AI', $blk);
}
}
// ── Titre : taille automatique, centré, jamais sur logo bannière ────────
private function draw_title( $img, string $title, $wht, int $photoH, int $bandH, int $W, int $logo_y, int $logo_gap = 24, int $logo_h = 0 ): void {
$padX = (int)($W * 0.05);
$maxW = $W - ($padX * 2);
$font = $this->find_font();
if ( ! $font || ! function_exists('imagettftext') ) return;
// Trouver la taille de police qui rentre dans la zone disponible
$zone_top = $photoH + (int)($bandH * 0.07);
$zone_bottom = $logo_y - $logo_gap;
$zone_h = max(80, $zone_bottom - $zone_top);
$best_fs = 32;
$best_lines = [ $title ];
for ( $fs = 80; $fs >= 28; $fs -= 2 ) {
$line_h = (int)($fs * 1.24);
// Word wrap
$lines = [];
$words = preg_split('/\s+/', trim($title)) ?: [];
$cur = '';
foreach ( $words as $word ) {
$test = $cur !== '' ? "$cur $word" : $word;
$bb = imagettfbbox($fs, 0, $font, $test);
$test_w = abs($bb[2] - $bb[0]);
if ( $test_w > $maxW && $cur !== '' ) {
$lines[] = $cur;
$cur = $word;
} else {
$cur = $test;
}
}
if ( $cur !== '' ) $lines[] = $cur;
if ( empty($lines) ) $lines = [ $title ];
$total_h = count($lines) * $line_h;
$fits_height = $total_h <= (int)($zone_h * 0.88);
$fits_width = true;
foreach ( $lines as $line ) {
$bb = imagettfbbox($fs, 0, $font, $line);
if ( abs($bb[2] - $bb[0]) > $maxW ) {
$fits_width = false;
break;
}
}
if ( $fits_height && $fits_width ) {
$best_fs = $fs;
$best_lines = $lines;
break;
}
}
$line_h = (int)($best_fs * 1.24);
$text_h = count($best_lines) * $line_h;
$y = $zone_top + (int)(($zone_h - $text_h) / 2) + $line_h;
$vivid = $this->extract_vivid_color($img, $W, $photoH);
$accents = [
imagecolorallocate($img, $vivid[0], $vivid[1], $vivid[2]),
imagecolorallocate($img, $vivid[2], $vivid[0], $vivid[1]),
imagecolorallocate($img, min(255,$vivid[1]+100), min(255,$vivid[0]+60), max(0,$vivid[2]-80)),
];
$proper_noun_idx = 0;
$title_word_pos = 0;
foreach ( $best_lines as $line ) {
$bb = imagettfbbox($best_fs, 0, $font, $line);
$line_w = abs($bb[2] - $bb[0]);
$x = max($padX, (int)(($W - $line_w) / 2));
$blk = imagecolorallocate($img, 0, 0, 0);
imagettftext($img, $best_fs, 0, $x-2, $y-2, $blk, $font, $line);
imagettftext($img, $best_fs, 0, $x+2, $y-2, $blk, $font, $line);
imagettftext($img, $best_fs, 0, $x-2, $y+2, $blk, $font, $line);
imagettftext($img, $best_fs, 0, $x+2, $y+2, $blk, $font, $line);
imagettftext($img, $best_fs, 0, $x, $y-2, $blk, $font, $line);
imagettftext($img, $best_fs, 0, $x, $y+2, $blk, $font, $line);
imagettftext($img, $best_fs, 0, $x-2, $y, $blk, $font, $line);
imagettftext($img, $best_fs, 0, $x+2, $y, $blk, $font, $line);
$words_in_line = preg_split('/\s+/', trim($line)) ?: [$line];
$wx = $x;
foreach ($words_in_line as $w) {
if ($w === '') continue;
$is_proper = ($title_word_pos > 0 && mb_strlen($w) > 2 && ctype_upper(mb_substr($w, 0, 1)));
imagettftext($img, $best_fs, 0, $wx, $y, $is_proper ? $accents[$proper_noun_idx % 3] : $wht, $font, $w);
if ($is_proper) $proper_noun_idx++;
$wb = imagettfbbox($best_fs, 0, $font, $w . ' ');
$wx += abs($wb[2] - $wb[0]);
$title_word_pos++;
}
$y += $line_h;
}
}
// ── Logo bannière : ancré en bas, jamais sur le texte ───────────────────
private function draw_logo_banner( $img, int $W, int $lw, int $lh, int $lx, int $ly ): void {
$wht = imagecolorallocate($img, 255, 255, 255);
imagefilledrectangle($img, $lx, $ly, $lx + $lw, $ly + $lh, $wht);
$url = $this->get_logo_url($this->logo_banner_id);
if ( ! $url ) return;
$ctx = stream_context_create(['http'=>['timeout'=>5],'ssl'=>['verify_peer'=>false]]);
$data = @file_get_contents($url, false, $ctx);
if ( ! $data ) return;
$logo = @imagecreatefromstring($data);
if ( ! $logo ) return;
$bw = imagesx($logo);
$bh = imagesy($logo);
$s = min(($lw * 0.88) / $bw, ($lh * 0.80) / $bh);
$nw = (int)($bw * $s);
$nh = (int)($bh * $s);
$dx = $lx + (int)(($lw - $nw) / 2);
$dy = $ly + (int)(($lh - $nh) / 2);
imagecopyresampled($img, $logo, $dx, $dy, 0, 0, $nw, $nh, $bw, $bh);
imagedestroy($logo);
unset($data);
}
private function find_font(): string {
foreach ([
AIAG_DIR . 'assets/font.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
'/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf',
'C:\\Windows\\Fonts\\arialbd.ttf',
] as $f) {
if ( file_exists($f) ) return $f;
}
$dest = AIAG_DIR . 'assets/font.ttf';
$ctx = stream_context_create(['http'=>['timeout'=>15],'ssl'=>['verify_peer'=>false]]);
$data = @file_get_contents('https://github.com/dejavu-fonts/dejavu-fonts/raw/master/ttf/DejaVuSans-Bold.ttf', false, $ctx);
if ($data && strlen($data) > 10000) { @file_put_contents($dest, $data); return $dest; }
return '';
}
private function extract_vivid_color( $img, int $w, int $max_y ): array {
$best_sat = 0;
$best = [255, 200, 50];
$step = 40;
for ( $x = $step; $x < $w - $step; $x += $step ) {
for ( $y = $step; $y < $max_y - $step; $y += $step ) {
$px = imagecolorat( $img, $x, $y );
$r = ( $px >> 16 ) & 0xFF;
$g = ( $px >> 8 ) & 0xFF;
$b = $px & 0xFF;
$mx = max( $r, $g, $b );
$mn = min( $r, $g, $b );
$sat = $mx - $mn;
if ( $sat > $best_sat && $mx > 60 ) {
$best_sat = $sat;
$best = [ $r, $g, $b ];
}
}
}
$mx = max( $best );
if ( $mx > 0 && $mx < 160 ) {
$f = 220 / $mx;
$best = [ min(255,(int)($best[0]*$f)), min(255,(int)($best[1]*$f)), min(255,(int)($best[2]*$f)) ];
}
return $best;
}
}