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ệ
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user