Files
sachgiaokhoaorg/app/Libraries/CoverArt.php
T
sachgiaokhoaorg e6b265db99 Sách Giáo Khoa Việt Nam - sachgiaokhoa.org
CodeIgniter 4 website:
- Catalog 999 SGK (lớp 1-12) từ API, lọc theo lớp/môn/NXB, tìm kiếm
- Trang chủ grouped browse (bậc học -> lớp -> môn, ưu tiên môn quan trọng)
- Chi tiết sách: metadata đầy đủ, cover nghệ thuật /thumb/{W}x{H}/
- Đọc online qua gate captcha + countdown, PDF viewer pdf.js
- Download: captcha + token một lần + auto cache-bust version
- Tự đồng bộ catalog/cover khi API thay đổi (refresh:catalog)
- Legal: DMCA, miễn trừ trách nhiệm, điều khoản, bảo mật, liên hệ
2026-08-23 07:05:49 +00:00

224 lines
8.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* Art-directed cover thumbnails served from /thumb/{W}x{H}/{id}.jpg.
* Generated on demand from the cached original cover into
* writable/cache/covers/thumbs/{W}x{H}/ and served from disk afterwards.
*/
class CoverArt
{
/** Allowed thumbnail sizes — requests snap to the nearest one. */
public const SIZES = [
'small' => [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);
}
}