[240, 320], // grade previews, dense grids 'medium' => [360, 480], // catalog cards 'large' => [540, 720], // detail sidebar / og:image ]; public static function sizes(): array { return self::SIZES; } public static function thumb(string $id, string $fullFile, array $book = [], int $w = 360, int $h = 480): ?string { [$w, $h] = self::closestSize($w, $h); $dir = dirname($fullFile) . '/thumbs/' . $w . 'x' . $h; $file = $dir . '/' . basename($fullFile); if (is_file($file) && filemtime($file) >= filemtime($fullFile)) { return $file; } $src = @imagecreatefromstring((string) file_get_contents($fullFile)); if (! $src) { return $book ? self::fallback($dir, $file, $book, $w, $h) : null; } $sw = imagesx($src); $sh = imagesy($src); // landscape low-res = upstream placeholder → designed fallback if ($sw <= 400 && $sh <= 200) { imagedestroy($src); return $book ? self::fallback($dir, $file, $book, $w, $h) : null; } // flatten alpha (upstream PNGs have transparency) onto light background $flat = imagecreatetruecolor($sw, $sh); imagefill($flat, 0, 0, imagecolorallocate($flat, 232, 236, 244)); imagecopy($flat, $src, 0, 0, 0, 0, $sw, $sh); imagedestroy($src); $src = $flat; // center-crop to target ratio (favor top 40% when cropping height: // Vietnamese textbook covers carry their title in the upper part) $targetRatio = $w / $h; $ratio = $sw / $sh; if ($ratio > $targetRatio) { $cw = (int) round($sh * $targetRatio); $cx = (int) (($sw - $cw) / 2); $cy = 0; $ch = $sh; } else { $ch = (int) round($sw / $targetRatio); $cy = (int) max(0, ($sh - $ch) * 0.40); $cx = 0; $cw = $sw; } $dst = imagecreatetruecolor($w, $h); imagefill($dst, 0, 0, imagecolorallocate($dst, 232, 236, 244)); imagecopyresampled($dst, $src, 0, 0, $cx, $cy, $w, $h, $cw, $ch); self::sharpen($dst); self::save($dst, $dir, $file, 86); imagedestroy($src); imagedestroy($dst); return $file; } private static function closestSize(int $w, int $h): array { $best = null; $bestDiff = PHP_INT_MAX; foreach (self::SIZES as [$tw, $th]) { $diff = abs($tw - $w) + abs($th - $h); if ($diff < $bestDiff) { $bestDiff = $diff; $best = [$tw, $th]; } } return $best; } private static function save($img, string $dir, string $file, int $quality): void { if (! is_dir($dir)) { @mkdir($dir, 0775, true); } $tmp = $file . '.tmp' . getmypid(); imagejpeg($img, $tmp, $quality); rename($tmp, $file); } /** * Designed fallback cover: vertical brand gradient, SGK badge, * subject, wrapped title, grade + publisher footer. */ private static function fallback(string $dir, string $file, array $book, int $w, int $h): ?string { $dst = imagecreatetruecolor($w, $h); // vertical gradient: deep blue → azure $r0 = 30; $g0 = 58; $b0 = 138; $r1 = 59; $g1 = 130; $b1 = 246; for ($y = 0; $y < $h; $y++) { $t = $y / $h; imageline($dst, 0, $y, $w, $y, imagecolorallocate($dst, (int) ($r0 + ($r1 - $r0) * $t), (int) ($g0 + ($g1 - $g0) * $t), (int) ($b0 + ($b1 - $b0) * $t))); } // decorative translucent circles (scale with size) $s = $w / 360; $acc = imagecolorallocatealpha($dst, 255, 255, 255, 100); imagefilledellipse($dst, (int) (400 * $s), (int) (90 * $s), (int) (220 * $s), (int) (220 * $s), $acc); imagefilledellipse($dst, (int) (80 * $s), (int) (560 * $s), (int) (260 * $s), (int) (260 * $s), $acc); $white = imagecolorallocate($dst, 255, 255, 255); $soft = imagecolorallocate($dst, 219, 231, 254); $gold = imagecolorallocate($dst, 245, 158, 11); // SGK brand badge imagefilledrectangle($dst, (int) (24 * $s), (int) (28 * $s), (int) (122 * $s), (int) (66 * $s), imagecolorallocatealpha($dst, 255, 255, 255, 88)); imagestring($dst, 5, (int) (38 * $s), (int) (39 * $s), 'SGK.ORG', $gold); // subject $subject = mb_strtoupper($book['subject'] !== '' ? $book['subject'] : 'Giao khoa'); imagestring($dst, 5, (int) (26 * $s), (int) (120 * $s), self::ascii($subject), $soft); // wrapped title $lines = self::wrapAscii((string) ($book['title'] ?? ''), (int) ($w - 52 * $s), 5); $y = (int) (170 * $s); foreach ($lines as $line) { imagestring($dst, 5, (int) (26 * $s), $y, self::ascii($line), $white); $y += (int) (24 * $s); if ($y > $h - 110 * $s) break; } // footer: grade + publisher $bottom = trim(($book['grade'] ? 'LOP ' . $book['grade'] . ' . ' : '') . $book['publisher']); imageline($dst, (int) (26 * $s), (int) ($h - 86 * $s), (int) ($w - 26 * $s), (int) ($h - 86 * $s), $gold); imagestring($dst, 4, (int) (26 * $s), (int) ($h - 72 * $s), self::ascii($bottom), $gold); self::save($dst, $dir, $file, 88); imagedestroy($dst); return $file; } /** Vietnamese diacritics → ASCII for GD built-in fonts. */ private static function ascii(string $s): string { $map = [ 'à'=>'a','á'=>'a','ạ'=>'a','ả'=>'a','ã'=>'a','â'=>'a','ầ'=>'a','ấ'=>'a','ậ'=>'a','ẩ'=>'a','ẫ'=>'a', 'ă'=>'a','ằ'=>'a','ắ'=>'a','ặ'=>'a','ẳ'=>'a','ẵ'=>'a','À'=>'A','Á'=>'A','Ạ'=>'A','Ả'=>'A','Ã'=>'A', 'Â'=>'A','Ă'=>'A','è'=>'e','é'=>'e','ẹ'=>'e','ẻ'=>'e','ẽ'=>'e','ê'=>'e','ề'=>'e','ế'=>'e','ệ'=>'e', 'ể'=>'e','ễ'=>'e','È'=>'E','É'=>'E','Ẹ'=>'E','Ẻ'=>'E','Ẽ'=>'E','Ê'=>'E','ì'=>'i','í'=>'i','ị'=>'i', 'ỉ'=>'i','ĩ'=>'i','Ì'=>'I','Í'=>'I','Ị'=>'I','Ỉ'=>'I','Ĩ'=>'I','ò'=>'o','ó'=>'o','ọ'=>'o','ỏ'=>'o', 'õ'=>'o','ô'=>'o','ồ'=>'o','ố'=>'o','ộ'=>'o','ổ'=>'o','ỗ'=>'o','ơ'=>'o','ờ'=>'o','ớ'=>'o','ợ'=>'o', 'ở'=>'o','ỡ'=>'o','Ò'=>'O','Ó'=>'O','Ọ'=>'O','Ỏ'=>'O','Õ'=>'O','Ô'=>'O','Ơ'=>'O','ù'=>'u','ú'=>'u', 'ụ'=>'u','ủ'=>'u','ũ'=>'u','ư'=>'u','ừ'=>'u','ứ'=>'u','ự'=>'u','ử'=>'u','ữ'=>'u','Ù'=>'U','Ú'=>'U', 'Ụ'=>'U','Ủ'=>'U','Ũ'=>'U','Ư'=>'U','ỳ'=>'y','ý'=>'y','ỵ'=>'y','ỷ'=>'y','ỹ'=>'y','Ỳ'=>'Y', 'Ý'=>'Y','Ỵ'=>'Y','Ỷ'=>'Y','Ỹ'=>'Y','đ'=>'d','Đ'=>'D','Ừ'=>'U','Ữ'=>'U', ]; $s = strtr($s, $map); return preg_replace('/[^\x20-\x7E]/', '', $s) ?: 'SGK'; } /** Word-wrap for GD font 5 (~9px/char). */ private static function wrapAscii(string $text, int $maxW, int $maxLines): array { $maxChars = max(8, (int) ($maxW / 9)); $words = preg_split('/\s+/', trim($text)) ?: []; $lines = []; $cur = ''; foreach ($words as $wd) { $try = $cur === '' ? $wd : "$cur $wd"; if (strlen($try) <= $maxChars) { $cur = $try; continue; } if ($cur !== '') $lines[] = $cur; $cur = strlen($wd) > $maxChars ? substr($wd, 0, $maxChars - 1) . '-' : $wd; if (count($lines) >= $maxLines) { $cur = ''; break; } } if ($cur !== '' && count($lines) < $maxLines) $lines[] = $cur; if (count($lines) === $maxLines) { $lines[$maxLines - 1] = rtrim($lines[$maxLines - 1]) . '...'; } return $lines ?: ['SGK']; } private static function sharpen($img): void { // mild unsharp mask imageconvolution($img, [ [0.0, -0.6, 0.0], [-0.6, 3.4, -0.6], [0.0, -0.6, 0.0], ], 1.0, 0.0); } }